use color_eyre::Result; use std::collections::BTreeMap; use std::fs::File; use std::net::Ipv4Addr; use std::path::PathBuf; use std::str::FromStr; 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 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, color_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(option: Option) -> Self { let targets = if let Some(file) = option { let mut working = BTreeMap::new(); if !&file.exists() { RatatuiApp::get_default_targets() } else { let real_file = File::open(file); let mut rdr = csv::Reader::from_reader(real_file.unwrap()); for result in rdr.records() { let record = result.unwrap(); working.insert(record[1].to_string(), TargetState { name: record[1].to_string(), target: Ipv4Addr::from_str(&record[0]).unwrap(), alive: false, last_alive_change: SystemTime::now(), last_rtt: 0 }); } working } } else { RatatuiApp::get_default_targets() }; let mut working = Self::default(); working.state = targets; working } fn get_default_targets() -> BTreeMap { let mut working = BTreeMap::new(); working.insert("1111 DNS".to_string(), TargetState { name: "1111 DNS".to_string(), target: Ipv4Addr::new(1,1,1,1), ..TargetState::default() }); working.insert("Google DNS".to_string(), TargetState { name: "Google DNS".to_string(), target: Ipv4Addr::new(8,8,8,8), ..TargetState::default() }); working.insert("Test Site 1".to_string(), TargetState { name: "Test Site 1".to_string(), target: Ipv4Addr::new(216,234,202,122), ..TargetState::default() }); working } }