From df01cf614da7aa67f856da1ff9a5d239dc87ba23 Mon Sep 17 00:00:00 2001 From: Trevor Merritt Date: Wed, 23 Apr 2025 13:21:56 -0400 Subject: [PATCH] displays a preset list of hosts and their response state --- src/bin/pp.rs | 1 + src/bin/rat.rs | 9 ++ src/lib.rs | 2 +- src/tui/mod.rs | 2 + src/tui/ratatui_app.rs | 196 +++++++++++++++++++++++++++++++++ src/tui/target_state_widget.rs | 11 ++ 6 files changed, 220 insertions(+), 1 deletion(-) create mode 100644 src/bin/rat.rs create mode 100644 src/tui/ratatui_app.rs create mode 100644 src/tui/target_state_widget.rs diff --git a/src/bin/pp.rs b/src/bin/pp.rs index 8c6fe3d..79c08e5 100644 --- a/src/bin/pp.rs +++ b/src/bin/pp.rs @@ -17,6 +17,7 @@ use std::{env, error::Error, ffi::OsString, process}; use color_eyre::owo_colors::OwoColorize; use crossterm::style::Stylize; use log::debug; + const SECONDS_IN_MINUTE: u32 = 60; const SECONDS_IN_HOUR: u32 = SECONDS_IN_MINUTE * 60; const SECONDS_IN_DAY: u32 = SECONDS_IN_HOUR * 24; diff --git a/src/bin/rat.rs b/src/bin/rat.rs new file mode 100644 index 0000000..a83bdeb --- /dev/null +++ b/src/bin/rat.rs @@ -0,0 +1,9 @@ + +use pp::tui::ratatui_app::RatatuiApp; +fn main() -> color_eyre::Result<()> { + color_eyre::install()?; + let terminal = ratatui::init(); + let result = RatatuiApp::new().run(terminal); + ratatui::restore(); + result +} \ No newline at end of file diff --git a/src/lib.rs b/src/lib.rs index 05567e8..355c4ba 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -2,7 +2,7 @@ pub mod manager; pub mod ping_request; pub mod ping_result; pub mod target_state; -mod tui; +pub mod tui; pub const SECONDS_BETWEEN_DISPLAY: u32 = 1; pub const SECONDS_BETWEEN_PING: u32 = 2; diff --git a/src/tui/mod.rs b/src/tui/mod.rs index e69de29..dd1c881 100644 --- a/src/tui/mod.rs +++ b/src/tui/mod.rs @@ -0,0 +1,2 @@ +pub mod target_state_widget; +pub mod ratatui_app; \ No newline at end of file diff --git a/src/tui/ratatui_app.rs b/src/tui/ratatui_app.rs new file mode 100644 index 0000000..61748e3 --- /dev/null +++ b/src/tui/ratatui_app.rs @@ -0,0 +1,196 @@ +use color_eyre::Result; +use std::collections::BTreeMap; +use std::net::Ipv4Addr; +use std::sync::mpsc; +use std::sync::mpsc::Receiver; +use std::time::{Duration, SystemTime}; +use crossterm::event; +use crossterm::event::{Event, KeyCode, KeyEvent, KeyEventKind, KeyModifiers}; +use ratatui::{DefaultTerminal, Frame}; +use ratatui::prelude::*; +use ratatui::widgets::{Block, Paragraph}; +use crate::manager::Manager; +use crate::ping_result::PingResult; +use crate::target_state::TargetState; + + +const SECONDS_IN_MINUTE: u32 = 60; +const SECONDS_IN_HOUR: u32 = SECONDS_IN_MINUTE * 60; +const SECONDS_IN_DAY: u32 = SECONDS_IN_HOUR * 24; + + +pub fn duration_to_string(to_convert: Duration) -> String { + let mut total_seconds = to_convert.as_secs() as u32; + let mut working_string = String::new(); + + if total_seconds > 86400 { + // days + let num_days = (total_seconds / SECONDS_IN_DAY) as u32; + working_string = format!("{} days", num_days); + total_seconds = total_seconds - (num_days * SECONDS_IN_DAY); + } + + if total_seconds > 3600 { + // hours + let num_hours = (total_seconds / SECONDS_IN_HOUR) as u32; + if num_hours > 0 { + working_string = format!("{} {} hours", working_string, num_hours); + total_seconds = total_seconds - (num_hours * SECONDS_IN_HOUR); + } + } + if total_seconds > 60 { + let num_minutes = (total_seconds / SECONDS_IN_MINUTE) as u32; + if num_minutes > 0 { + working_string = format!("{} {} minutes", working_string, num_minutes); + total_seconds = total_seconds - (num_minutes * SECONDS_IN_MINUTE); + } + // minutes + } + + + working_string = format!("{} {} seconds", working_string, total_seconds); + + working_string +} + + +#[derive(Default)] +pub struct RatatuiApp { + running: bool, + state: BTreeMap, +} + +impl RatatuiApp { + pub fn setup_default_hosts(&mut self) { + self.state.insert( + "Test Host 1".to_string(), + TargetState { + target: Ipv4Addr::new(127, 0, 0, 1), + name: "Localhost".to_string(), + ..TargetState::default() + }, + ); + + self.state.insert( + "Test Host 2".to_string(), + TargetState { target: Ipv4Addr::new(8, 8, 8, 8), name: "Google".to_string(), ..TargetState::default() }, + ); + } + + pub fn run(mut self, mut terminal: DefaultTerminal) -> Result<()> { + self.running = true; + // start the 'manager' thread that spawns its ping threads as needed + let (sender, receiver) = mpsc::channel::(); + Manager::spawn_manager_thread(self.targets_as_vec(), sender); + while self.running { + // check for any waiting ping results... + self.consume_waiting_results(&receiver); + + terminal.draw(|frame| self.render(frame)).expect("Unable to draw to screen"); + self.handle_crossterm_events()?; + } + Ok(()) + } + + fn consume_waiting_results(&mut self, receiver: &Receiver) { + let mut local_state = self.state.clone(); + if let Ok(new_message) = receiver.recv_timeout(Duration::from_millis(10)) { + // find the right TargetState + for (name, mut state) in local_state.clone() { + if state.target == new_message.target { + let last_alive_change = if new_message.success == state.alive { + state.last_alive_change + } else { + SystemTime::now() + }; + + let new_state = TargetState { + name: state.name.clone(), + target: state.target, + alive: new_message.success, + last_rtt: new_message.rtt, + last_alive_change, + }; + local_state.insert(name.clone(), new_state); + } + } + } + self.state = local_state; + } + + fn targets_as_vec(&self) -> Vec { + let mut working = vec![]; + for (_, current) in &self.state { + working.push(current.target); + } + working + } + + fn render(&mut self, frame: &mut Frame) { + let title = Line::from("PP Sample App") + .bold() + .blue() + .centered(); + let mut working = String::new(); + for (title, current) in &self.state { + let color_name = if current.alive { + current.name.clone().green() + } else { + current.name.clone().red() + }; + working = format!("{}\n{} ({}) - {} / {} / {}", + working, + current.name, + current.target, + current.alive, + current.last_rtt, + duration_to_string( + SystemTime::now() + .duration_since(current.last_alive_change) + .unwrap() + ) + ); + } + + frame.render_widget( + Paragraph::new(working) + .block(Block::bordered().title(title)) + .centered(), + frame.area(), + ); + } + + fn handle_crossterm_events(&mut self) -> Result<()> { + if event::poll(Duration::from_millis(100))? { + match event::read()? { + // it's important to check KeyEventKind::Press to avoid handling key release events + Event::Key(key) if key.kind == KeyEventKind::Press => self.on_key_event(key), + Event::Mouse(_) => {} + Event::Resize(_, _) => {} + _ => {} + } + } + Ok(()) + } + + fn quit(&mut self) { self.running = false; } + + + /// Handles the key events and updates the state of [`App`]. + fn on_key_event(&mut self, key: KeyEvent) { + match (key.modifiers, key.code) { + (_, KeyCode::Esc | KeyCode::Char('q')) + | (KeyModifiers::CONTROL, KeyCode::Char('c') | + KeyCode::Char('C')) => { self.quit() } + // Add other key handlers here. + _ => {} + } + } + + + pub fn new() -> Self { + let mut working = Self::default(); + working.setup_default_hosts(); + working + } +} \ No newline at end of file diff --git a/src/tui/target_state_widget.rs b/src/tui/target_state_widget.rs new file mode 100644 index 0000000..fcf2458 --- /dev/null +++ b/src/tui/target_state_widget.rs @@ -0,0 +1,11 @@ +use ratatui::buffer::Cell; +use ratatui::prelude::*; + +pub struct TargetStateWidget; + +impl Widget for TargetStateWidget { + fn render(self, area: Rect, buf: &mut Buffer) + { + + } +}