Initial working implementation (MVP)
This commit is contained in:
@@ -0,0 +1,24 @@
|
||||
[package]
|
||||
name = "rmon-ui"
|
||||
version.workspace = true
|
||||
edition.workspace = true
|
||||
authors.workspace = true
|
||||
license.workspace = true
|
||||
|
||||
[dependencies]
|
||||
eframe = "0.24"
|
||||
egui = "0.24"
|
||||
egui_plot = "0.24"
|
||||
tokio = { workspace = true, features = ["full"] }
|
||||
tracing.workspace = true
|
||||
tracing-subscriber.workspace = true
|
||||
serde.workspace = true
|
||||
bincode.workspace = true
|
||||
rmon-common.workspace = true
|
||||
anyhow.workspace = true
|
||||
futures.workspace = true
|
||||
tokio-util.workspace = true
|
||||
bytes.workspace = true
|
||||
chrono.workspace = true
|
||||
itertools.workspace = true
|
||||
libc.workspace = true
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,109 @@
|
||||
use anyhow::Result;
|
||||
use tokio::net::TcpStream;
|
||||
use tokio_util::codec::{Framed, Decoder, Encoder};
|
||||
use futures::{StreamExt, SinkExt};
|
||||
use rmon_common::protocol::{ClientMessage, AgentMessage, BincodeCodec, PROTOCOL_VERSION};
|
||||
use bytes::BytesMut;
|
||||
use std::io;
|
||||
use tokio::sync::mpsc;
|
||||
|
||||
pub mod ssh;
|
||||
pub use self::ssh::SshConnection;
|
||||
|
||||
pub struct ClientCodec;
|
||||
|
||||
impl Decoder for ClientCodec {
|
||||
type Item = AgentMessage;
|
||||
type Error = io::Error;
|
||||
|
||||
fn decode(&mut self, src: &mut BytesMut) -> Result<Option<Self::Item>, Self::Error> {
|
||||
let mut codec = BincodeCodec::<AgentMessage>::default();
|
||||
codec.decode(src)
|
||||
}
|
||||
}
|
||||
|
||||
impl Encoder<ClientMessage> for ClientCodec {
|
||||
type Error = io::Error;
|
||||
|
||||
fn encode(&mut self, item: ClientMessage, dst: &mut BytesMut) -> Result<(), Self::Error> {
|
||||
let mut codec = BincodeCodec::<ClientMessage>::default();
|
||||
codec.encode(item, dst)
|
||||
}
|
||||
}
|
||||
|
||||
pub struct ProtocolClient {
|
||||
tx: mpsc::Sender<ClientMessage>,
|
||||
}
|
||||
|
||||
impl ProtocolClient {
|
||||
pub async fn connect(addr: &str, ui_tx: mpsc::Sender<crate::app::UiMessage>) -> Result<Self> {
|
||||
tracing::info!("Connecting to agent at {}...", addr);
|
||||
let stream = TcpStream::connect(addr).await?;
|
||||
let mut framed = Framed::new(stream, ClientCodec);
|
||||
|
||||
// 1. Handshake
|
||||
framed.send(ClientMessage::Handshake {
|
||||
protocol_version: PROTOCOL_VERSION,
|
||||
client_version: env!("CARGO_PKG_VERSION").to_string(),
|
||||
}).await?;
|
||||
|
||||
match framed.next().await {
|
||||
Some(Ok(AgentMessage::HandshakeAck { accepted: true, .. })) => {
|
||||
tracing::info!("Handshake accepted");
|
||||
}
|
||||
Some(Ok(AgentMessage::HandshakeAck { accepted: false, reject_reason, .. })) => {
|
||||
return Err(anyhow::anyhow!("Handshake rejected: {}", reject_reason.unwrap_or_default()));
|
||||
}
|
||||
_ => return Err(anyhow::anyhow!("Handshake failed")),
|
||||
}
|
||||
|
||||
let (tx, mut rx) = mpsc::channel::<ClientMessage>(100);
|
||||
let ui_tx_clone = ui_tx.clone();
|
||||
|
||||
// Start message pump BEFORE sending ListSignals to ensure response is caught
|
||||
tokio::spawn(async move {
|
||||
tracing::debug!("UI message pump started");
|
||||
loop {
|
||||
tokio::select! {
|
||||
msg_res = framed.next() => {
|
||||
match msg_res {
|
||||
Some(Ok(msg)) => {
|
||||
if let Err(_) = ui_tx_clone.send(crate::app::UiMessage::Agent(msg)).await { break; }
|
||||
}
|
||||
Some(Err(e)) => {
|
||||
let _ = ui_tx_clone.send(crate::app::UiMessage::Error(format!("Protocol error: {}", e))).await;
|
||||
break;
|
||||
}
|
||||
None => {
|
||||
let _ = ui_tx_clone.send(crate::app::UiMessage::Log("Agent closed connection".to_string())).await;
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
cmd_opt = rx.recv() => {
|
||||
match cmd_opt {
|
||||
Some(cmd) => {
|
||||
if let Err(e) = framed.send(cmd).await {
|
||||
let _ = ui_tx_clone.send(crate::app::UiMessage::Error(format!("Send failed: {}", e))).await;
|
||||
break;
|
||||
}
|
||||
}
|
||||
None => break, // ProtocolClient handle dropped
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
let _ = ui_tx_clone.send(crate::app::UiMessage::Disconnected).await;
|
||||
tracing::info!("UI message pump stopped");
|
||||
});
|
||||
|
||||
// Now request signals
|
||||
tx.send(ClientMessage::ListSignals).await?;
|
||||
|
||||
Ok(Self { tx })
|
||||
}
|
||||
|
||||
pub async fn send(&self, msg: ClientMessage) -> Result<()> {
|
||||
self.tx.send(msg).await.map_err(|_| anyhow::anyhow!("Disconnected"))
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,190 @@
|
||||
use anyhow::Result;
|
||||
use std::process::{Command, Stdio, Child};
|
||||
use std::io::Write;
|
||||
use std::path::PathBuf;
|
||||
use std::fs;
|
||||
use std::os::unix::fs::PermissionsExt;
|
||||
use std::os::unix::process::CommandExt;
|
||||
use std::time::Duration;
|
||||
|
||||
/// Embedded agent binaries for different architectures.
|
||||
const AGENT_X86_64: &[u8] = include_bytes!("../../../target/x86_64-unknown-linux-musl/release/rmon-agent");
|
||||
|
||||
pub struct SshConnection {
|
||||
host: String,
|
||||
port: u16,
|
||||
control_path: PathBuf,
|
||||
}
|
||||
|
||||
impl SshConnection {
|
||||
pub fn new(host: String, port: u16) -> Self {
|
||||
let temp_dir = std::env::temp_dir();
|
||||
// Use a unique socket path per host/port
|
||||
let control_path = temp_dir.join(format!("rmon_ssh_sock_{}_{}", host.replace(".", "_"), port));
|
||||
Self { host, port, control_path }
|
||||
}
|
||||
|
||||
fn setup_askpass(&self) -> Result<PathBuf> {
|
||||
let temp_dir = std::env::temp_dir();
|
||||
let askpass_path = temp_dir.join(format!("rmon_askpass_{}", self.port));
|
||||
let binary_path = std::env::current_exe()?;
|
||||
let script = format!(
|
||||
"#!/bin/sh\n\"{}\" --askpass-prompt \"$1\" --askpass-port {}\n",
|
||||
binary_path.display(),
|
||||
self.port
|
||||
);
|
||||
fs::write(&askpass_path, script)?;
|
||||
let mut perms = fs::metadata(&askpass_path)?.permissions();
|
||||
perms.set_mode(0o700);
|
||||
fs::set_permissions(&askpass_path, perms)?;
|
||||
Ok(askpass_path)
|
||||
}
|
||||
|
||||
fn ssh_base(&self) -> Command {
|
||||
let mut cmd = Command::new("ssh");
|
||||
cmd.arg("-o").arg("RemoteCommand=none");
|
||||
cmd.arg("-o").arg("StrictHostKeyChecking=no");
|
||||
cmd.arg("-o").arg("ConnectTimeout=15");
|
||||
cmd.arg("-o").arg(format!("ControlPath={}", self.control_path.display()));
|
||||
cmd.arg(&self.host);
|
||||
cmd
|
||||
}
|
||||
|
||||
pub fn establish_master(&self) -> Result<()> {
|
||||
if self.control_path.exists() {
|
||||
let check = Command::new("ssh")
|
||||
.arg("-o").arg(format!("ControlPath={}", self.control_path.display()))
|
||||
.arg("-O").arg("check")
|
||||
.arg(&self.host)
|
||||
.output();
|
||||
if let Ok(out) = check {
|
||||
if out.status.success() {
|
||||
return Ok(());
|
||||
}
|
||||
}
|
||||
let _ = fs::remove_file(&self.control_path);
|
||||
}
|
||||
|
||||
let askpass = self.setup_askpass()?;
|
||||
tracing::info!("Establishing SSH master connection to {}...", self.host);
|
||||
|
||||
let mut cmd = Command::new("ssh");
|
||||
cmd.arg("-o").arg("RemoteCommand=none");
|
||||
cmd.arg("-o").arg("StrictHostKeyChecking=no");
|
||||
cmd.arg("-o").arg("ControlMaster=yes");
|
||||
cmd.arg("-o").arg("ControlPersist=30m");
|
||||
cmd.arg("-o").arg(format!("ControlPath={}", self.control_path.display()));
|
||||
cmd.arg("-o").arg("BatchMode=no");
|
||||
cmd.env("SSH_ASKPASS", askpass);
|
||||
cmd.env("SSH_ASKPASS_REQUIRE", "force");
|
||||
cmd.env("DISPLAY", ":0");
|
||||
cmd.arg("-T");
|
||||
cmd.arg(&self.host);
|
||||
cmd.arg("true");
|
||||
cmd.stdin(Stdio::null());
|
||||
|
||||
unsafe { cmd.pre_exec(|| { libc::setsid(); Ok(()) }); }
|
||||
|
||||
let mut child = cmd.spawn()?;
|
||||
|
||||
let mut count = 0;
|
||||
while !self.control_path.exists() && count < 3000 {
|
||||
if let Ok(Some(status)) = child.try_wait() {
|
||||
if !self.control_path.exists() {
|
||||
return Err(anyhow::anyhow!("SSH authentication failed (status: {})", status));
|
||||
}
|
||||
break;
|
||||
}
|
||||
std::thread::sleep(Duration::from_millis(100));
|
||||
count += 1;
|
||||
}
|
||||
|
||||
if !self.control_path.exists() {
|
||||
let _ = child.kill();
|
||||
return Err(anyhow::anyhow!("Authentication timed out (5m limit reached)"));
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub fn kill_local_orphan_tunnels(&self) -> Result<()> {
|
||||
let pattern = format!("rmon_ssh_sock_{}_{}", self.host.replace(".", "_"), self.port);
|
||||
let _ = Command::new("pkill").arg("-f").arg(&pattern).output();
|
||||
let _ = fs::remove_file(&self.control_path);
|
||||
let fwd_pattern = format!("-L {}:127.0.0.1:{}", self.port, self.port);
|
||||
let _ = Command::new("pkill").arg("-f").arg(&fwd_pattern).output();
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub fn deploy_agent(&self) -> Result<()> {
|
||||
let mut child = self.ssh_base()
|
||||
.arg("mkdir -p ~/.rmon && cat > ~/.rmon/agent && chmod +x ~/.rmon/agent")
|
||||
.stdin(Stdio::piped())
|
||||
.spawn()?;
|
||||
|
||||
let mut stdin = child.stdin.take().ok_or_else(|| anyhow::anyhow!("Failed to open stdin"))?;
|
||||
stdin.write_all(AGENT_X86_64)?;
|
||||
drop(stdin);
|
||||
|
||||
let status = child.wait()?;
|
||||
if !status.success() { return Err(anyhow::anyhow!("Failed to upload agent binary")); }
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub fn check_running(&self) -> Result<bool> {
|
||||
// More robust check: use ss or netstat to see if anything is listening on that port
|
||||
// We look for the port followed by a space to avoid partial matches
|
||||
let cmd = format!("(ss -tln || netstat -tln || true) | grep -q ':{} '", self.port);
|
||||
let output = self.ssh_base()
|
||||
.arg(cmd)
|
||||
.output()?;
|
||||
Ok(output.status.success())
|
||||
}
|
||||
|
||||
pub fn start_agent(&self) -> Result<()> {
|
||||
self.deploy_agent()?;
|
||||
// Use absolute path for log file in home directory
|
||||
let cmd = format!("nohup ~/.rmon/agent --bind 0.0.0.0 --port {} >> ~/.rmon/agent.log 2>&1 &", self.port);
|
||||
let output = self.ssh_base()
|
||||
.arg(cmd)
|
||||
.output()?;
|
||||
if !output.status.success() { return Err(anyhow::anyhow!("Failed to start agent")); }
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub fn create_tunnel(&self) -> Result<Child> {
|
||||
let child = self.ssh_base()
|
||||
.arg("-o").arg("ExitOnForwardFailure=yes")
|
||||
.arg("-N")
|
||||
.arg("-L").arg(format!("{}:127.0.0.1:{}", self.port, self.port))
|
||||
.spawn()?;
|
||||
Ok(child)
|
||||
}
|
||||
|
||||
pub fn kill_agent(&self) -> Result<()> {
|
||||
let _ = self.ssh_base().arg(format!("pkill -f \"~/.rmon/agent --port {}\"", self.port)).output();
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub fn close_master(&self) -> Result<()> {
|
||||
let _ = Command::new("ssh")
|
||||
.arg("-o").arg(format!("ControlPath={}", self.control_path.display()))
|
||||
.arg("-O").arg("exit")
|
||||
.arg(&self.host)
|
||||
.output();
|
||||
let _ = fs::remove_file(&self.control_path);
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn test_ssh_connection_init() {
|
||||
let conn = SshConnection::new("myhost".to_string(), 1234);
|
||||
assert_eq!(conn.host, "myhost");
|
||||
assert_eq!(conn.port, 1234);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,69 @@
|
||||
use eframe::egui;
|
||||
use tokio::runtime::Runtime;
|
||||
|
||||
mod app;
|
||||
mod connection;
|
||||
mod state;
|
||||
mod ui;
|
||||
|
||||
use crate::app::RMonApp;
|
||||
|
||||
fn main() -> eframe::Result<()> {
|
||||
let args: Vec<String> = std::env::args().collect();
|
||||
|
||||
// Check if we are being called as an askpass helper
|
||||
if let Some(pos) = args.iter().position(|a| a == "--askpass-prompt") {
|
||||
let prompt = args.get(pos + 1).cloned().unwrap_or_default();
|
||||
let port = args.get(pos + 3).and_then(|p| p.parse::<u16>().ok()).unwrap_or(7891);
|
||||
|
||||
let temp_dir = std::env::temp_dir();
|
||||
let request_path = temp_dir.join(format!("rmon_auth_req_{}", port));
|
||||
let response_path = temp_dir.join(format!("rmon_auth_res_{}", port));
|
||||
|
||||
// Signal the UI that we need input
|
||||
let _ = std::fs::write(&request_path, &prompt);
|
||||
|
||||
// Wait for response
|
||||
let mut count = 0;
|
||||
while !response_path.exists() && count < 600 { // 60s timeout
|
||||
std::thread::sleep(std::time::Duration::from_millis(100));
|
||||
count += 1;
|
||||
}
|
||||
|
||||
if let Ok(res) = std::fs::read_to_string(&response_path) {
|
||||
let _ = std::fs::remove_file(&request_path);
|
||||
let _ = std::fs::remove_file(&response_path);
|
||||
println!("{}", res.trim());
|
||||
}
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
tracing_subscriber::fmt()
|
||||
.with_writer(std::io::stderr)
|
||||
.with_env_filter(tracing_subscriber::EnvFilter::from_default_env())
|
||||
.init();
|
||||
|
||||
// Create a tokio runtime in a separate thread for async communication
|
||||
let rt = Runtime::new().expect("Unable to create Runtime");
|
||||
let _enter = rt.enter();
|
||||
|
||||
std::thread::spawn(move || {
|
||||
rt.block_on(async {
|
||||
loop {
|
||||
tokio::time::sleep(std::time::Duration::from_secs(3600)).await;
|
||||
}
|
||||
})
|
||||
});
|
||||
|
||||
let native_options = eframe::NativeOptions {
|
||||
viewport: egui::ViewportBuilder::default()
|
||||
.with_inner_size([1280.0, 720.0]),
|
||||
..Default::default()
|
||||
};
|
||||
|
||||
eframe::run_native(
|
||||
"RMon",
|
||||
native_options,
|
||||
Box::new(|cc| Box::new(RMonApp::new(cc))),
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,76 @@
|
||||
use rmon_common::signal::{SignalId, SignalInfo, Sample};
|
||||
use std::collections::{HashMap, VecDeque};
|
||||
use std::sync::{Arc, Mutex};
|
||||
|
||||
pub struct SignalHistory {
|
||||
pub info: SignalInfo,
|
||||
pub samples: VecDeque<Sample>,
|
||||
pub max_samples: usize,
|
||||
}
|
||||
|
||||
impl SignalHistory {
|
||||
pub fn new(info: SignalInfo) -> Self {
|
||||
Self {
|
||||
info,
|
||||
samples: VecDeque::new(),
|
||||
max_samples: 1000, // Default for display
|
||||
}
|
||||
}
|
||||
|
||||
pub fn push(&mut self, sample: Sample) {
|
||||
self.samples.push_back(sample);
|
||||
if self.samples.len() > self.max_samples {
|
||||
self.samples.pop_front();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub struct SessionState {
|
||||
pub connected: bool,
|
||||
pub signals: HashMap<SignalId, Arc<Mutex<SignalHistory>>>,
|
||||
}
|
||||
|
||||
impl SessionState {
|
||||
pub fn new() -> Self {
|
||||
Self {
|
||||
connected: false,
|
||||
signals: HashMap::new(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use rmon_common::signal::SourceType;
|
||||
|
||||
#[test]
|
||||
fn test_signal_history_push() {
|
||||
let info = SignalInfo {
|
||||
id: "test://1".to_string(),
|
||||
label: "L".to_string(),
|
||||
unit: "U".to_string(),
|
||||
scale: 1.0,
|
||||
offset: 0.0,
|
||||
sampling_period: None,
|
||||
source_type: SourceType::Csv,
|
||||
path: vec![],
|
||||
};
|
||||
let mut history = SignalHistory::new(info);
|
||||
history.max_samples = 2;
|
||||
|
||||
history.push(Sample { timestamp: 1, value: 1.0 });
|
||||
history.push(Sample { timestamp: 2, value: 2.0 });
|
||||
history.push(Sample { timestamp: 3, value: 3.0 });
|
||||
|
||||
assert_eq!(history.samples.len(), 2);
|
||||
assert_eq!(history.samples[0].timestamp, 2);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_session_state_init() {
|
||||
let state = SessionState::new();
|
||||
assert!(!state.connected);
|
||||
assert!(state.signals.is_empty());
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
// Placeholder for UI components
|
||||
Reference in New Issue
Block a user