From 3b86716ce19496e1b84d286538ae71b2c290cb3c Mon Sep 17 00:00:00 2001 From: Luca Abate <147305693+LucaBTE@users.noreply.github.com> Date: Sat, 5 Sep 2026 14:52:31 +0200 Subject: [PATCH] Refactor Netracer into independent Ratatui components and async services --- .github/workflows/ci.yml | 36 ++ Cargo.toml | 7 +- examples/custom_tab.rs | 29 ++ src/app.rs | 465 ++++------------------ src/app/tests.rs | 49 +++ src/component.rs | 28 ++ src/lib.rs | 6 + src/main.rs | 18 +- src/{network.rs => network/interfaces.rs} | 0 src/network/mod.rs | 3 + src/network/ping.rs | 82 ++++ src/network/ping/tests.rs | 54 +++ src/network/target.rs | 57 +++ src/ping.rs | 108 ----- src/tabs/mod.rs | 12 + src/tabs/overview/mod.rs | 29 ++ src/tabs/overview/ui.rs | 19 + src/tabs/ping/history.rs | 82 ++++ src/tabs/ping/input.rs | 79 ++++ src/tabs/ping/mod.rs | 175 ++++++++ src/tabs/ping/model.rs | 96 +++++ src/tabs/ping/tests.rs | 82 ++++ src/tabs/ping/ui.rs | 99 +++++ src/terminal.rs | 38 ++ src/ui.rs | 238 ++--------- 25 files changed, 1177 insertions(+), 714 deletions(-) create mode 100644 .github/workflows/ci.yml create mode 100644 examples/custom_tab.rs create mode 100644 src/app/tests.rs create mode 100644 src/component.rs create mode 100644 src/lib.rs rename src/{network.rs => network/interfaces.rs} (100%) create mode 100644 src/network/mod.rs create mode 100644 src/network/ping.rs create mode 100644 src/network/ping/tests.rs create mode 100644 src/network/target.rs delete mode 100644 src/ping.rs create mode 100644 src/tabs/mod.rs create mode 100644 src/tabs/overview/mod.rs create mode 100644 src/tabs/overview/ui.rs create mode 100644 src/tabs/ping/history.rs create mode 100644 src/tabs/ping/input.rs create mode 100644 src/tabs/ping/mod.rs create mode 100644 src/tabs/ping/model.rs create mode 100644 src/tabs/ping/tests.rs create mode 100644 src/tabs/ping/ui.rs create mode 100644 src/terminal.rs diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000..86d14d5 --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,36 @@ +name: Rust +on: + push: + branches: ["refactor/modular-components"] + pull_request: + workflow_dispatch: + +permissions: + contents: read + +jobs: + check: + runs-on: ubuntu-latest + timeout-minutes: 10 + steps: + - uses: actions/checkout@v4 + - name: Install stable Rust + run: rustup update stable --no-self-update && rustup default stable && rustup component add rustfmt clippy + - name: Format and resolve dependencies + run: cargo fmt --all && cargo generate-lockfile + - name: Check all targets + run: cargo check --locked --all-targets + - name: Test + run: cargo test --locked --all-targets + - name: Clippy + run: cargo clippy --locked --all-targets -- -D warnings + - name: Save reproducible sources + if: always() + uses: actions/upload-artifact@v4 + with: + name: refactor-validation + path: | + Cargo.lock + src/ + examples/ + retention-days: 3 diff --git a/Cargo.toml b/Cargo.toml index 2e6075c..731c1aa 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -4,7 +4,12 @@ version = "0.1.0" edition = "2024" [dependencies] -crossterm = "0.29.0" +crossterm = { version = "0.29.0", features = ["event-stream"] } +directories = "6.0" +futures-util = "0.3" hostname = "0.4.2" netdev = "0.46.2" ratatui = "0.30.2" +tempfile = "3" +tokio = { version = "1", features = ["macros", "rt-multi-thread", "process", "time", "io-util", "sync"] } +tui-input = { version = "0.15.4", default-features = false, features = ["crossterm"] } diff --git a/examples/custom_tab.rs b/examples/custom_tab.rs new file mode 100644 index 0000000..0d85d35 --- /dev/null +++ b/examples/custom_tab.rs @@ -0,0 +1,29 @@ +//! Run with: cargo run --example custom_tab +//! A compiled extension: the core event loop and existing tabs do not change. +use std::io; +use netracer::{app::App, component::Component, tabs, terminal::TerminalSession}; +use ratatui::{Frame, layout::Rect, widgets::{Block, Borders, Paragraph}}; + +struct NotesTab; + +impl Component for NotesTab { + fn id(&self) -> &'static str { "notes-example" } + fn title(&self) -> &'static str { "Notes" } + fn help(&self) -> &'static str { "Q/Esc: Quit" } + fn render(&mut self, frame: &mut Frame, area: Rect) { + frame.render_widget( + Paragraph::new("A third tab registered without changing App or Ping.") + .block(Block::default().title(" Extension example ").borders(Borders::ALL)), + area, + ); + } +} + +#[tokio::main] +async fn main() -> io::Result<()> { + let mut components = tabs::builtins(); + components.push(Box::new(NotesTab)); + let mut app = App::new(components)?; + let mut terminal = TerminalSession::new()?; + app.run(terminal.terminal()).await +} diff --git a/src/app.rs b/src/app.rs index 00ac4e3..40636c1 100644 --- a/src/app.rs +++ b/src/app.rs @@ -1,426 +1,117 @@ -use std::{ - env, fs, io, - path::PathBuf, - sync::mpsc::{self, Receiver, Sender}, - thread, - time::{Duration, Instant}, -}; +use std::{collections::HashSet, io, time::Duration}; -use crossterm::event::{ - self, DisableMouseCapture, EnableMouseCapture, Event, KeyCode, KeyEventKind, KeyModifiers, - MouseButton, MouseEventKind, -}; -use ratatui::{DefaultTerminal, layout::Rect, widgets::TableState}; +use crossterm::event::{Event, EventStream, KeyCode, KeyEventKind, KeyModifiers, MouseButton, MouseEventKind}; +use futures_util::StreamExt; +use ratatui::{DefaultTerminal, layout::Rect}; +use tokio::time::{self, MissedTickBehavior}; -use crate::{ - network::NetworkInfo, - ping::{self, PingResult}, - ui, -}; - -pub struct PingEntry { - pub target: String, - pub status: String, - pub latency: Option, - pub sent: u32, - pub received: u32, - pub sum: f64, - pub min: f64, - pub max: f64, - pub last_run: Option, -} - -impl PingEntry { - fn new(target: String) -> Self { - Self { - target, - status: "Ready".into(), - latency: None, - sent: 0, - received: 0, - sum: 0.0, - min: f64::INFINITY, - max: 0.0, - last_run: None, - } - } - - pub fn loss(&self) -> f64 { - if self.sent == 0 { - return 0.0; - } - - 100.0 * (self.sent - self.received) as f64 / self.sent as f64 - } - - pub fn statistics(&self) -> String { - let latency = if self.received == 0 { - "Min / Avg / Max: N/A".into() - } else { - format!( - "Min / Avg / Max: {:.2} / {:.2} / {:.2} ms", - self.min, - self.sum / self.received as f64, - self.max, - ) - }; - - format!( - "{}\nCompleted probes: {} Replies: {} Loss: {:.1}%\n{}\n{}", - self.target, - self.sent, - self.received, - self.loss(), - latency, - self.status, - ) - } -} +use crate::{component::Component, ui}; pub struct App { + components: Vec>, + active: usize, running: bool, - started_at: Instant, - - pub network: NetworkInfo, - pub tab: usize, - pub editing: bool, - pub input: String, - pub message: String, - pub entries: Vec, - pub table_state: TableState, - - pub input_area: Rect, - pub table_area: Rect, - - busy: bool, - sender: Sender<(String, PingResult)>, - receiver: Receiver<(String, PingResult)>, + tab_areas: Vec, } impl App { - pub fn new() -> Self { - let (sender, receiver) = mpsc::channel(); - - let (entries, message) = match load_history() { - Ok(entries) => (entries, String::new()), - Err(error) => (Vec::new(), format!("Cannot load recent targets: {error}")), - }; - - let mut table_state = TableState::default(); - - if !entries.is_empty() { - table_state.select(Some(0)); + pub fn new(components: Vec>) -> io::Result { + if components.is_empty() { + return Err(io::Error::other("Register at least one component")); } - - Self { - running: true, - started_at: Instant::now(), - network: NetworkInfo::discover(), - tab: 1, - editing: false, - input: String::new(), - message, - entries, - table_state, - input_area: Rect::default(), - table_area: Rect::default(), - busy: false, - sender, - receiver, + let mut ids = HashSet::new(); + if components.iter().any(|component| !ids.insert(component.id())) { + return Err(io::Error::other("Component IDs must be unique")); } + let active = components.iter().position(|component| component.id() == "ping").unwrap_or(0); + Ok(Self { components, active, running: true, tab_areas: Vec::new() }) } - pub fn uptime(&self) -> Duration { - self.started_at.elapsed() + pub async fn run(&mut self, terminal: &mut DefaultTerminal) -> io::Result<()> { + let result = self.run_loop(terminal).await; + for component in &mut self.components { + component.shutdown().await; + } + result } - pub fn run(&mut self, terminal: &mut DefaultTerminal) -> io::Result<()> { - crossterm::execute!(io::stdout(), EnableMouseCapture)?; - - let result = self.run_loop(terminal); - let cleanup = crossterm::execute!(io::stdout(), DisableMouseCapture); + async fn run_loop(&mut self, terminal: &mut DefaultTerminal) -> io::Result<()> { + let mut events = EventStream::new(); + let mut tick = time::interval(Duration::from_millis(100)); + tick.set_missed_tick_behavior(MissedTickBehavior::Skip); - result.and(cleanup) - } - - fn run_loop(&mut self, terminal: &mut DefaultTerminal) -> io::Result<()> { while self.running { - self.receive_results(); - - terminal.draw(|frame| ui::render(frame, self))?; - - if event::poll(Duration::from_millis(50))? { - self.handle_event(event::read()?); + for component in &mut self.components { + component.update(); } - } - - Ok(()) - } - - fn handle_event(&mut self, event: Event) { - match event { - Event::Key(key) if key.kind == KeyEventKind::Press => { - if key.modifiers.contains(KeyModifiers::CONTROL) && key.code == KeyCode::Char('c') { - self.running = false; - return; - } - - if self.editing { - match key.code { - KeyCode::Esc => self.editing = false, - KeyCode::Enter => self.submit_input(), - KeyCode::Backspace => { - self.input.pop(); - } - KeyCode::Char(character) - if character.is_ascii() - && !character.is_ascii_control() - && self.input.len() < 253 => - { - self.input.push(character); - } - _ => {} - } - - return; - } - - match key.code { - KeyCode::Char('q') | KeyCode::Esc => self.running = false, - KeyCode::Tab => self.tab = (self.tab + 1) % 2, - KeyCode::Char('1') => self.tab = 0, - KeyCode::Char('2') => self.tab = 1, - KeyCode::Char('/') if self.tab == 1 => self.editing = true, - KeyCode::Up if self.tab == 1 => self.move_selection(-1), - KeyCode::Down if self.tab == 1 => self.move_selection(1), - KeyCode::Enter if self.tab == 1 => self.ping_selected(), - KeyCode::Delete if self.tab == 1 => self.delete_selected(), - _ => {} + terminal.draw(|frame| { + self.tab_areas = ui::render(frame, &mut self.components, self.active); + })?; + tokio::select! { + _ = tick.tick() => {} + event = events.next() => match event { + Some(Ok(event)) => self.handle_event(&event), + Some(Err(error)) => return Err(error), + None => break, } } - Event::Mouse(mouse) if self.tab == 1 => match mouse.kind { - MouseEventKind::Down(MouseButton::Left) => { - if contains(self.input_area, mouse.column, mouse.row) { - self.editing = true; - } else if contains(self.table_area, mouse.column, mouse.row) { - // Skip the top border and table header. - let first_row = self.table_area.y + 2; - - if mouse.row >= first_row - && mouse.row < self.table_area.bottom().saturating_sub(1) - { - let index = - self.table_state.offset() + usize::from(mouse.row - first_row); - - if index < self.entries.len() { - self.editing = false; - self.table_state.select(Some(index)); - self.ping_selected(); - } - } - } - } - MouseEventKind::ScrollUp => self.move_selection(-1), - MouseEventKind::ScrollDown => self.move_selection(1), - _ => {} - }, - _ => {} } + Ok(()) } - fn move_selection(&mut self, direction: isize) { - if self.entries.is_empty() { - return; + fn activate(&mut self, index: usize) { + if index < self.components.len() { + for component in &mut self.components { + component.reset_layout(); + } + self.active = index; } - - let current = self.table_state.selected().unwrap_or(0); - let next = current - .saturating_add_signed(direction) - .min(self.entries.len() - 1); - - self.table_state.select(Some(next)); } - fn submit_input(&mut self) { - if self.busy { - self.message = "Wait for the current ping to finish.".into(); - return; - } - - let target = match ping::validate_target(&self.input) { - Ok(target) => target, - Err(error) => { - self.message = error; + fn handle_event(&mut self, event: &Event) { + if let Event::Key(key) = event { + if key.kind == KeyEventKind::Release { return; } - }; - - let index = match self.entries.iter().position(|entry| entry.target == target) { - Some(index) => index, - None => { - self.entries.push(PingEntry::new(target)); - self.entries.len() - 1 - } - }; - - self.editing = false; - self.input.clear(); - self.table_state.select(Some(index)); - self.ping_selected(); - } - - fn ping_selected(&mut self) { - if self.busy { - self.message = "Wait for the current ping to finish.".into(); - return; - } - - let Some(index) = self.table_state.selected() else { - self.message = "Press / to enter a destination.".into(); - return; - }; - - // Move the most recently used destination to the top. - let entry = self.entries.remove(index); - self.entries.insert(0, entry); - self.table_state.select(Some(0)); - - self.message.clear(); - self.save_history(); - - let entry = &mut self.entries[0]; - entry.status = "Running...".into(); - entry.latency = None; - entry.last_run = Some(Instant::now()); - - let target = entry.target.clone(); - let sender = self.sender.clone(); - - match thread::Builder::new() - .name("ping-worker".into()) - .spawn(move || { - let result = ping::ping(&target); - let _ = sender.send((target, result)); - }) { - Ok(_) => self.busy = true, - Err(error) => { - self.entries[0].status = format!("Cannot start worker: {error}"); + if key.modifiers.contains(KeyModifiers::CONTROL) + && key.code == KeyCode::Char('c') + { + self.running = false; + return; } } - } - - fn receive_results(&mut self) { - while let Ok((target, result)) = self.receiver.try_recv() { - self.busy = false; - - let Some(entry) = self.entries.iter_mut().find(|entry| entry.target == target) else { - continue; - }; - - match result { - Ok(Some(latency)) => { - entry.sent += 1; - entry.received += 1; - entry.sum += latency; - entry.min = entry.min.min(latency); - entry.max = entry.max.max(latency); - entry.latency = Some(latency); - entry.status = "Reply received".into(); - } - Ok(None) => { - entry.sent += 1; - entry.status = "No reply".into(); - } - Err(error) => { - entry.status = format!("Error: {error}"); - } + if matches!(event, Event::Resize(..)) { + self.tab_areas.clear(); + for component in &mut self.components { + component.reset_layout(); } } - } - - fn delete_selected(&mut self) { - if self.busy { - self.message = "Wait for the current ping before deleting a target.".into(); + if let Event::Mouse(mouse) = event + && mouse.kind == MouseEventKind::Down(MouseButton::Left) + && let Some(index) = self.tab_areas.iter().position(|area| { + area.contains((mouse.column, mouse.row).into()) + }) + { + self.activate(index); return; } - - if let Some(index) = self.table_state.selected() { - self.entries.remove(index); - - let selected = if self.entries.is_empty() { - None - } else { - Some(index.min(self.entries.len() - 1)) - }; - - self.table_state.select(selected); - self.save_history(); - } - } - - fn save_history(&mut self) { - if let Err(error) = save_history(&self.entries) { - self.message = format!("Cannot save recent targets: {error}"); + // Text fields get first refusal: q and digits remain text while editing. + if self.components[self.active].handle_event(event) { + return; } - } -} - -fn contains(area: Rect, x: u16, y: u16) -> bool { - x >= area.x && x < area.right() && y >= area.y && y < area.bottom() -} - -fn history_path() -> io::Result { - let base = env::var_os("XDG_STATE_HOME") - .map(PathBuf::from) - .filter(|path| path.is_absolute()) - .or_else(|| { - env::var_os("HOME") - .map(PathBuf::from) - .map(|home| home.join(".local/state")) - }) - .ok_or_else(|| io::Error::other("Cannot locate the user state directory"))?; - - Ok(base.join("netracer/recent-targets.txt")) -} - -fn load_history() -> io::Result> { - let content = match fs::read_to_string(history_path()?) { - Ok(content) => content, - Err(error) if error.kind() == io::ErrorKind::NotFound => String::new(), - Err(error) => return Err(error), - }; - - let mut entries = Vec::new(); - - for line in content.lines() { - if let Ok(target) = ping::validate_target(line) - && !entries - .iter() - .any(|entry: &PingEntry| entry.target == target) + if let Event::Key(key) = event + && key.kind == KeyEventKind::Press { - entries.push(PingEntry::new(target)); + match key.code { + KeyCode::Char('q') | KeyCode::Esc => self.running = false, + KeyCode::Tab => self.activate((self.active + 1) % self.components.len()), + KeyCode::BackTab => self.activate((self.active + self.components.len() - 1) % self.components.len()), + KeyCode::Char(digit @ '1'..='9') => self.activate((digit as u8 - b'1') as usize), + _ => {} + } } } - - Ok(entries) } -fn save_history(entries: &[PingEntry]) -> io::Result<()> { - let path = history_path()?; - let parent = path - .parent() - .ok_or_else(|| io::Error::other("Invalid history path"))?; - - fs::create_dir_all(parent)?; - - let content = entries - .iter() - .map(|entry| entry.target.as_str()) - .collect::>() - .join("\n"); - - let temporary = path.with_extension(format!("{}.tmp", std::process::id())); - - fs::write(&temporary, content)?; - fs::rename(temporary, path) -} +#[cfg(test)] +mod tests; diff --git a/src/app/tests.rs b/src/app/tests.rs new file mode 100644 index 0000000..4746089 --- /dev/null +++ b/src/app/tests.rs @@ -0,0 +1,49 @@ +use super::*; +use crossterm::event::KeyEvent; +use ratatui::{Frame, backend::TestBackend, Terminal}; +use std::{cell::Cell, rc::Rc}; + +struct ExtraTab { updates: Rc> } + +impl Component for ExtraTab { + fn id(&self) -> &'static str { "extra" } + fn title(&self) -> &'static str { "Extra" } + fn help(&self) -> &'static str { "Type q without quitting" } + fn handle_event(&mut self, event: &Event) -> bool { + matches!(event, Event::Key(key) if key.code == KeyCode::Char('q')) + } + fn update(&mut self) { self.updates.set(self.updates.get() + 1); } + fn render(&mut self, _frame: &mut Frame, _area: Rect) {} +} + +#[test] +fn accepts_extension_without_a_tab_enum_and_respects_consumed_input() { + let updates = Rc::new(Cell::new(0)); + let mut app = App::new(vec![Box::new(ExtraTab { updates: updates.clone() })]).unwrap(); + app.handle_event(&Event::Key(KeyEvent::new(KeyCode::Char('q'), KeyModifiers::NONE))); + assert!(app.running); + app.handle_event(&Event::Key(KeyEvent::new(KeyCode::Char('9'), KeyModifiers::NONE))); + assert_eq!(app.active, 0); + app.components[0].update(); + assert_eq!(updates.get(), 1); + app.handle_event(&Event::Key(KeyEvent::new(KeyCode::Char('c'), KeyModifiers::CONTROL))); + assert!(!app.running); +} + +#[test] +fn rejects_empty_and_duplicate_registration() { + assert!(App::new(Vec::new()).is_err()); + let make = || Box::new(ExtraTab { updates: Rc::new(Cell::new(0)) }) as Box; + assert!(App::new(vec![make(), make()]).is_err()); +} + +#[test] +fn tiny_terminal_has_no_mouse_targets() { + let component = Box::new(ExtraTab { updates: Rc::new(Cell::new(0)) }); + let mut app = App::new(vec![component]).unwrap(); + let mut terminal = Terminal::new(TestBackend::new(20, 5)).unwrap(); + terminal.draw(|frame| { + app.tab_areas = ui::render(frame, &mut app.components, 0); + }).unwrap(); + assert!(app.tab_areas.is_empty()); +} diff --git a/src/component.rs b/src/component.rs new file mode 100644 index 0000000..6074f1a --- /dev/null +++ b/src/component.rs @@ -0,0 +1,28 @@ +use crossterm::event::Event; +use futures_util::future::LocalBoxFuture; +use ratatui::{Frame, layout::Rect}; + +/// A tab compiled into the application. No dependency on App or other tabs. +pub trait Component { + fn id(&self) -> &'static str; + fn title(&self) -> &'static str; + fn help(&self) -> &'static str; + + /// Return true when the component consumes the input. + fn handle_event(&mut self, _event: &Event) -> bool { + false + } + + /// Called for every component, including hidden tabs. + fn update(&mut self) {} + + fn render(&mut self, frame: &mut Frame, area: Rect); + + /// Clear mouse hit areas when hidden or resized. + fn reset_layout(&mut self) {} + + /// Await background cleanup before restoring the terminal. + fn shutdown(&mut self) -> LocalBoxFuture<'_, ()> { + Box::pin(async {}) + } +} diff --git a/src/lib.rs b/src/lib.rs new file mode 100644 index 0000000..d65c0e1 --- /dev/null +++ b/src/lib.rs @@ -0,0 +1,6 @@ +pub mod app; +pub mod component; +pub mod network; +pub mod tabs; +pub mod terminal; +mod ui; diff --git a/src/main.rs b/src/main.rs index aaffe47..2d501c6 100644 --- a/src/main.rs +++ b/src/main.rs @@ -1,16 +1,10 @@ -mod app; -mod network; -mod ping; -mod ui; - use std::io; -use app::App; - -fn main() -> io::Result<()> { - //app state - let mut app = App::new(); +use netracer::{app::App, tabs, terminal::TerminalSession}; - //Ratatui prepares terminal and pass app control - ratatui::run(|terminal| app.run(terminal)) +#[tokio::main] +async fn main() -> io::Result<()> { + let mut app = App::new(tabs::builtins())?; + let mut terminal = TerminalSession::new()?; + app.run(terminal.terminal()).await } diff --git a/src/network.rs b/src/network/interfaces.rs similarity index 100% rename from src/network.rs rename to src/network/interfaces.rs diff --git a/src/network/mod.rs b/src/network/mod.rs new file mode 100644 index 0000000..37c513b --- /dev/null +++ b/src/network/mod.rs @@ -0,0 +1,3 @@ +pub mod interfaces; +pub mod ping; +pub mod target; diff --git a/src/network/ping.rs b/src/network/ping.rs new file mode 100644 index 0000000..da0c7a3 --- /dev/null +++ b/src/network/ping.rs @@ -0,0 +1,82 @@ +use std::{process::Stdio, time::Duration}; + +use tokio::{io::AsyncReadExt, process::Command, time::timeout}; +use super::target::Target; + +#[derive(Clone, Debug, PartialEq)] +pub enum PingOutcome { + Reply { latency_ms: Option }, + NoReply, + Error(String), +} + +/// Linux iputils backend. No terminal state or filesystem access. +pub async fn ping(target: &Target) -> PingOutcome { + if !cfg!(target_os = "linux") { + return PingOutcome::Error("The ping backend currently requires Linux iputils.".into()); + } + let mut command = Command::new("ping"); + command.env("LC_ALL", "C") + .args(["-n", "-c", "1", "-W", "1", "-w", "2", target.as_str()]); + execute(command, Duration::from_secs(5)).await +} + +async fn execute(mut command: Command, deadline: Duration) -> PingOutcome { + let child = command.stdin(Stdio::null()).stdout(Stdio::piped()) + .stderr(Stdio::piped()).kill_on_drop(true).spawn(); + let mut child = match child { + Ok(child) => child, + Err(error) => return PingOutcome::Error(format!("Cannot start ping: {error}")), + }; + let mut stdout = child.stdout.take().expect("piped stdout"); + let mut stderr = child.stderr.take().expect("piped stderr"); + let mut output = Vec::new(); + let mut errors = Vec::new(); + + // Drain both pipes while waiting; reap the process explicitly on timeout. + let result = timeout(deadline, async { + tokio::try_join!( + child.wait(), + stdout.read_to_end(&mut output), + stderr.read_to_end(&mut errors), + ) + }).await; + match result { + Ok(Ok((status, _, _))) => parse_output(status.code(), &output, &errors), + Ok(Err(error)) => { + let _ = child.kill().await; + PingOutcome::Error(format!("Cannot read ping output: {error}")) + } + Err(_) => { + let _ = child.kill().await; + PingOutcome::Error("Ping exceeded the execution deadline.".into()) + } + } +} + +fn parse_output(code: Option, stdout: &[u8], stderr: &[u8]) -> PingOutcome { + match code { + Some(0) => { + let text = String::from_utf8_lossy(stdout); + let latency_ms = text.lines() + .find(|line| line.contains("min/avg/max")) + .and_then(|line| line.split_once('=')) + .and_then(|(_, values)| values.trim().split('/').nth(1)) + .and_then(|value| value.parse::().ok()) + .filter(|value| value.is_finite() && *value >= 0.0); + // A parsing failure does not turn a received reply into a lost packet. + PingOutcome::Reply { latency_ms } + } + Some(1) => PingOutcome::NoReply, + _ => { + let message: String = String::from_utf8_lossy(stderr).chars() + .filter(|character| !character.is_control()).take(240).collect(); + PingOutcome::Error(if message.is_empty() { + format!("Ping failed with exit code {code:?}") + } else { message }) + } + } +} + +#[cfg(test)] +mod tests; diff --git a/src/network/ping/tests.rs b/src/network/ping/tests.rs new file mode 100644 index 0000000..89b5165 --- /dev/null +++ b/src/network/ping/tests.rs @@ -0,0 +1,54 @@ +use super::*; + +#[test] +fn parses_linux_summary_and_preserves_reply_on_unknown_format() { + assert_eq!( + parse_output(Some(0), b"rtt min/avg/max/mdev = 0.012/0.012/0.012/0.000 ms\n", b""), + PingOutcome::Reply { latency_ms: Some(0.012) }, + ); + assert_eq!(parse_output(Some(0), b"unknown format", b""), PingOutcome::Reply { latency_ms: None }); + assert_eq!(parse_output(Some(1), b"", b""), PingOutcome::NoReply); + assert!(matches!(parse_output(Some(2), b"", b"Name resolution failed"), PingOutcome::Error(_))); +} + +#[cfg(unix)] +#[tokio::test] +async fn enforces_deadline_and_reaps_child() { + let dir = tempfile::tempdir().unwrap(); + let pid_file = dir.path().join("pid"); + let mut command = Command::new("sh"); + command.args(["-c", "echo $$ > \"$1\"; exec sleep 30", "probe"]).arg(&pid_file); + let outcome = execute(command, Duration::from_millis(500)).await; + assert!(matches!(outcome, PingOutcome::Error(message) if message.contains("deadline"))); + let pid = std::fs::read_to_string(pid_file).unwrap(); + let status = std::process::Command::new("kill") + .args(["-0", pid.trim()]).stderr(Stdio::null()).status().unwrap(); + assert!(!status.success(), "timed-out child should already be reaped"); +} + +#[cfg(unix)] +#[tokio::test] +async fn dropping_worker_kills_its_child() { + let dir = tempfile::tempdir().unwrap(); + let pid_file = dir.path().join("pid"); + let path = pid_file.clone(); + let worker = tokio::spawn(async move { + let mut command = Command::new("sh"); + command.args(["-c", "echo $$ > \"$1\"; exec sleep 30", "probe"]).arg(path); + execute(command, Duration::from_secs(40)).await + }); + for _ in 0..100 { + if pid_file.exists() { break; } + tokio::time::sleep(Duration::from_millis(10)).await; + } + let pid = std::fs::read_to_string(pid_file).unwrap(); + worker.abort(); + let _ = worker.await; + for _ in 0..100 { + let alive = std::process::Command::new("kill") + .args(["-0", pid.trim()]).stderr(Stdio::null()).status().unwrap().success(); + if !alive { return; } + tokio::time::sleep(Duration::from_millis(10)).await; + } + panic!("cancelled worker left its child running"); +} diff --git a/src/network/target.rs b/src/network/target.rs new file mode 100644 index 0000000..8cd04fa --- /dev/null +++ b/src/network/target.rs @@ -0,0 +1,57 @@ +use std::{fmt, net::IpAddr}; + +/// Validated command argument. Callers cannot construct unchecked targets. +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct Target(String); + +impl Target { + pub fn parse(input: &str) -> Result { + let value = input.trim(); + if let Ok(ip) = value.parse::() { + return Ok(Self(ip.to_string())); + } + let hostname = value.strip_suffix('.').unwrap_or(value); + let invalid_numeric_ip = hostname.contains('.') + && hostname.bytes().all(|byte| byte.is_ascii_digit() || byte == b'.'); + let valid = !hostname.is_empty() + && hostname.len() <= 253 + && !invalid_numeric_ip + && hostname.split('.').all(|label| { + !label.is_empty() && label.len() <= 63 + && !label.starts_with('-') && !label.ends_with('-') + && label.bytes().all(|byte| byte.is_ascii_alphanumeric() || byte == b'-') + }); + if !valid { + return Err("Enter an IPv4/IPv6 address or ASCII hostname, without a URL or port.".into()); + } + // Retain a single root dot: an absolute DNS name has distinct resolution semantics. + Ok(Self(value.to_ascii_lowercase())) + } + + pub fn as_str(&self) -> &str { &self.0 } +} + +impl fmt::Display for Target { + fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { + formatter.write_str(&self.0) + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn normalizes_addresses_and_hostname_case() { + assert_eq!(Target::parse(" ::0001 ").unwrap().as_str(), "::1"); + assert_eq!(Target::parse("Example.COM.").unwrap().as_str(), "example.com."); + assert!(Target::parse("localhost").is_ok()); + } + + #[test] + fn rejects_options_urls_and_malformed_addresses() { + for value in ["", "-c", "https://example.com", "host:80", "a..b", "host..", "999.2.3.4", "a b", "$(id)"] { + assert!(Target::parse(value).is_err(), "{value}"); + } + } +} diff --git a/src/ping.rs b/src/ping.rs deleted file mode 100644 index 315fc8c..0000000 --- a/src/ping.rs +++ /dev/null @@ -1,108 +0,0 @@ -use std::{ - net::IpAddr, - process::{Command, Stdio}, - thread, - time::{Duration, Instant}, -}; - -pub type PingResult = Result, String>; - -pub fn validate_target(input: &str) -> Result { - let target = input.trim(); - - if target.parse::().is_ok() { - return Ok(target.to_string()); - } - - let hostname = target.trim_end_matches('.'); - - let valid = !hostname.is_empty() - && hostname.len() <= 253 - && hostname.split('.').all(|label| { - !label.is_empty() - && label.len() <= 63 - && !label.starts_with('-') - && !label.ends_with('-') - && label - .bytes() - .all(|byte| byte.is_ascii_alphanumeric() || byte == b'-') - }); - - if !valid { - return Err("Enter an IP address or hostname, without a URL or port.".into()); - } - - Ok(hostname.to_ascii_lowercase()) -} - -pub fn ping(target: &str) -> PingResult { - // These arguments target Linux iputils ping. - let mut child = Command::new("ping") - .env("LC_ALL", "C") - .args(["-n", "-c", "1", "-W", "1", "-w", "2", target]) - .stdout(Stdio::piped()) - .stderr(Stdio::piped()) - .spawn() - .map_err(|error| format!("Cannot start ping: {error}"))?; - - let started = Instant::now(); - - // Bound the entire operation, including hostname resolution. - loop { - match child.try_wait() { - Ok(Some(_)) => break, - Ok(None) if started.elapsed() < Duration::from_secs(5) => { - thread::sleep(Duration::from_millis(25)); - } - Ok(None) => { - let _ = child.kill(); - let _ = child.wait(); - return Err("Ping exceeded the 5-second execution limit.".into()); - } - Err(error) => { - let _ = child.kill(); - let _ = child.wait(); - return Err(format!("Cannot monitor ping: {error}")); - } - } - } - - let output = child - .wait_with_output() - .map_err(|error| format!("Cannot read ping output: {error}"))?; - - match output.status.code() { - Some(0) => { - let stdout = String::from_utf8_lossy(&output.stdout); - - // Read the average from the min/avg/max/mdev summary. - let latency = stdout - .lines() - .find(|line| line.contains("min/avg/max")) - .and_then(|line| line.split_once('=')) - .and_then(|(_, values)| values.trim().split('/').nth(1)) - .and_then(|value| value.parse::().ok()) - .filter(|value| value.is_finite() && *value >= 0.0); - - latency - .map(Some) - .ok_or_else(|| "Reply received, but latency could not be parsed.".into()) - } - Some(1) => Ok(None), - _ => { - let stderr = String::from_utf8_lossy(&output.stderr); - - let message: String = stderr - .chars() - .filter(|character| !character.is_control()) - .take(240) - .collect(); - - Err(if message.is_empty() { - format!("Ping failed: {}", output.status) - } else { - message - }) - } - } -} diff --git a/src/tabs/mod.rs b/src/tabs/mod.rs new file mode 100644 index 0000000..99a4693 --- /dev/null +++ b/src/tabs/mod.rs @@ -0,0 +1,12 @@ +pub mod overview; +pub mod ping; + +use crate::component::Component; + +/// The only registration point for built-in tabs and source-level extensions. +pub fn builtins() -> Vec> { + vec![ + Box::new(overview::OverviewTab::new()), + Box::new(ping::PingTab::new()), + ] +} diff --git a/src/tabs/overview/mod.rs b/src/tabs/overview/mod.rs new file mode 100644 index 0000000..89c33b3 --- /dev/null +++ b/src/tabs/overview/mod.rs @@ -0,0 +1,29 @@ +mod ui; + +use std::time::Instant; +use ratatui::{Frame, layout::Rect}; +use crate::{component::Component, network::interfaces::NetworkInfo}; + +pub struct OverviewTab { + network: NetworkInfo, + started_at: Instant, +} + +impl OverviewTab { + pub fn new() -> Self { + Self { network: NetworkInfo::discover(), started_at: Instant::now() } + } +} + +impl Default for OverviewTab { + fn default() -> Self { Self::new() } +} + +impl Component for OverviewTab { + fn id(&self) -> &'static str { "overview" } + fn title(&self) -> &'static str { "Overview" } + fn help(&self) -> &'static str { "Q/Esc: Quit" } + fn render(&mut self, frame: &mut Frame, area: Rect) { + ui::render(frame, area, &self.network, self.started_at.elapsed()); + } +} diff --git a/src/tabs/overview/ui.rs b/src/tabs/overview/ui.rs new file mode 100644 index 0000000..88d335c --- /dev/null +++ b/src/tabs/overview/ui.rs @@ -0,0 +1,19 @@ +use std::time::Duration; +use ratatui::{Frame, layout::Rect, widgets::{Block, Borders, Paragraph}}; +use crate::network::interfaces::NetworkInfo; + +pub(super) fn render(frame: &mut Frame, area: Rect, network: &NetworkInfo, uptime: Duration) { + let text = format!( + "HOSTNAME {}\nINTERFACE {}\nIPv4 {}\nGATEWAY {}\nNETWORK {}\n\nUPTIME {}s", + network.hostname, + network.interface.as_deref().unwrap_or("N/A"), + network.ipv4.as_deref().unwrap_or("N/A"), + network.gateway.as_deref().unwrap_or("N/A"), + if network.has_link() { "CONFIGURED" } else { "UNAVAILABLE" }, + uptime.as_secs(), + ); + frame.render_widget( + Paragraph::new(text).block(Block::default().title(" Overview // startup snapshot ").borders(Borders::ALL)), + area, + ); +} diff --git a/src/tabs/ping/history.rs b/src/tabs/ping/history.rs new file mode 100644 index 0000000..c5cafda --- /dev/null +++ b/src/tabs/ping/history.rs @@ -0,0 +1,82 @@ +use std::{fs, io::{self, Read, Write}, path::PathBuf}; +use directories::ProjectDirs; +use tempfile::NamedTempFile; +use crate::network::target::Target; + +const MAX_HISTORY_BYTES: u64 = 1024 * 1024; + +pub(super) struct History { + path: PathBuf, +} + +impl History { + pub fn discover() -> io::Result { + let dirs = ProjectDirs::from("", "", "netracer") + .ok_or_else(|| io::Error::other("Cannot locate the user state directory"))?; + // Preserve feature/ping's Linux path and plain-text file format. + let base = dirs.state_dir().unwrap_or_else(|| dirs.data_local_dir()); + Ok(Self { path: base.join("recent-targets.txt") }) + } + + pub fn load(&self) -> io::Result> { + let file = match fs::File::open(&self.path) { + Ok(file) => file, + Err(error) if error.kind() == io::ErrorKind::NotFound => return Ok(Vec::new()), + Err(error) => return Err(error), + }; + let mut content = String::new(); + file.take(MAX_HISTORY_BYTES + 1).read_to_string(&mut content)?; + if content.len() as u64 > MAX_HISTORY_BYTES { + return Err(io::Error::other("History exceeds the 1 MiB limit")); + } + let mut targets = Vec::new(); + for line in content.lines() { + if let Ok(target) = Target::parse(line) + && !targets.contains(&target) + { + targets.push(target); + } + } + Ok(targets) + } + + pub fn save<'a>(&self, targets: impl Iterator) -> io::Result<()> { + let parent = self.path.parent().ok_or_else(|| io::Error::other("Invalid history path"))?; + fs::create_dir_all(parent)?; + let mut file = NamedTempFile::new_in(parent)?; + let mut size = 0; + for target in targets { + size += target.as_str().len() as u64 + 1; + if size > MAX_HISTORY_BYTES { + return Err(io::Error::other("History exceeds the 1 MiB limit")); + } + writeln!(file, "{target}")?; + } + file.as_file().sync_all()?; + file.persist(&self.path).map_err(|error| error.error)?; + Ok(()) + } + + #[cfg(test)] + pub fn at(path: PathBuf) -> Self { Self { path } } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn loads_old_format_and_persists_removal() { + let dir = tempfile::tempdir().unwrap(); + let history = History::at(dir.path().join("recent-targets.txt")); + fs::write(&history.path, "EXAMPLE.com\nexample.com\nhttps://bad\n::1\n").unwrap(); + let mut targets = history.load().unwrap(); + assert_eq!(targets.len(), 2); + targets.remove(0); + history.save(targets.iter()).unwrap(); + assert_eq!(history.load().unwrap(), targets); + targets.clear(); + history.save(targets.iter()).unwrap(); + assert!(history.load().unwrap().is_empty()); + } +} diff --git a/src/tabs/ping/input.rs b/src/tabs/ping/input.rs new file mode 100644 index 0000000..1b07091 --- /dev/null +++ b/src/tabs/ping/input.rs @@ -0,0 +1,79 @@ +use crossterm::event::{Event, KeyCode, KeyEventKind, KeyModifiers, MouseButton, MouseEventKind}; +use tui_input::{InputRequest, backend::crossterm::EventHandler}; +use super::PingTab; + +const MAX_INPUT_BYTES: usize = 254; + +pub(super) fn handle(tab: &mut PingTab, event: &Event) -> bool { + match event { + Event::Key(key) if key.kind != KeyEventKind::Release => { + if tab.editing { + match key.code { + KeyCode::Esc => tab.editing = false, + KeyCode::Enter if key.kind == KeyEventKind::Press => tab.submit(), + KeyCode::Enter => {} + KeyCode::Tab | KeyCode::BackTab => return false, + KeyCode::Char(character) + if !key.modifiers.intersects(KeyModifiers::CONTROL | KeyModifiers::ALT) => + { + if character.is_ascii() && !character.is_ascii_control() + && tab.input.value().len() < MAX_INPUT_BYTES + { + tab.input.handle(InputRequest::InsertChar(character)); + } + } + _ => { tab.input.handle_event(event); } + } + return true; + } + // Repeats are useful for navigation, but must not repeat destructive actions. + match key.code { + KeyCode::Up => tab.move_selection(-1), + KeyCode::Down => tab.move_selection(1), + KeyCode::Char('/') if key.kind == KeyEventKind::Press => tab.editing = true, + KeyCode::Enter if key.kind == KeyEventKind::Press => tab.start_selected(), + KeyCode::Delete if key.kind == KeyEventKind::Press => tab.delete_selected(), + _ => return false, + } + true + } + Event::Paste(text) if tab.editing => { + let remaining = MAX_INPUT_BYTES.saturating_sub(tab.input.value().len()); + if text.len() > remaining || !text.is_ascii() || text.chars().any(char::is_control) { + tab.message = "Paste a single ASCII hostname or IP address (max 254 characters).".into(); + } else { + for character in text.chars() { tab.input.handle(InputRequest::InsertChar(character)); } + } + true + } + Event::Mouse(mouse) => { + let position = (mouse.column, mouse.row).into(); + match mouse.kind { + MouseEventKind::Down(MouseButton::Left) if tab.input_area.contains(position) => { + tab.editing = true; + true + } + MouseEventKind::Down(MouseButton::Left) if tab.table_area.contains(position) => { + let first_row = tab.table_area.y + 2; + if mouse.row >= first_row && mouse.row < tab.table_area.bottom().saturating_sub(1) { + let index = tab.table_state.offset() + usize::from(mouse.row - first_row); + if index < tab.entries.len() { + tab.editing = false; + tab.table_state.select(Some(index)); + tab.start_selected(); + } + } + true + } + MouseEventKind::ScrollUp if tab.table_area.contains(position) => { + tab.move_selection(-1); true + } + MouseEventKind::ScrollDown if tab.table_area.contains(position) => { + tab.move_selection(1); true + } + _ => false, + } + } + _ => false, + } +} diff --git a/src/tabs/ping/mod.rs b/src/tabs/ping/mod.rs new file mode 100644 index 0000000..394b29b --- /dev/null +++ b/src/tabs/ping/mod.rs @@ -0,0 +1,175 @@ +mod history; +mod input; +mod model; +mod ui; + +use crossterm::event::Event; +use futures_util::future::LocalBoxFuture; +use ratatui::{Frame, layout::Rect, widgets::TableState}; +use tokio::task::JoinSet; +use tui_input::Input; + +use crate::{component::Component, network::{ping::{self, PingOutcome}, target::Target}}; +use history::History; +use model::{PingEntry, ProbeState}; + +pub struct PingTab { + input: Input, + editing: bool, + entries: Vec, + table_state: TableState, + message: String, + history: Option, + history_writable: bool, + tasks: JoinSet<(Target, PingOutcome)>, + input_area: Rect, + table_area: Rect, +} + +impl PingTab { + pub fn new() -> Self { + Self::from_history(History::discover()) + } + + fn from_history(history: std::io::Result) -> Self { + let (history, entries, message, writable) = match history { + Ok(history) => match history.load() { + Ok(targets) => (Some(history), targets.into_iter().map(PingEntry::new).collect::>(), String::new(), true), + Err(error) => (Some(history), Vec::new(), format!("History not loaded; saving disabled: {error}"), false), + }, + Err(error) => (None, Vec::new(), format!("Recent targets will not be saved: {error}"), false), + }; + let mut table_state = TableState::default(); + if !entries.is_empty() { table_state.select(Some(0)); } + Self { + input: Input::default(), editing: false, entries, table_state, message, + history, history_writable: writable, tasks: JoinSet::new(), + input_area: Rect::default(), table_area: Rect::default(), + } + } + + fn busy(&self) -> bool { !self.tasks.is_empty() } + + fn submit(&mut self) { + if self.busy() { + self.message = "Wait for the current ping to finish.".into(); + return; + } + let target = match Target::parse(self.input.value()) { + Ok(target) => target, + Err(error) => { self.message = error; return; } + }; + let index = self.entries.iter().position(|entry| entry.target == target) + .unwrap_or_else(|| { + self.entries.push(PingEntry::new(target)); + self.entries.len() - 1 + }); + self.table_state.select(Some(index)); + self.editing = false; + self.input.reset(); + self.start_selected(); + } + + fn start_selected(&mut self) { + if self.busy() { + self.message = "Wait for the current ping to finish.".into(); + return; + } + let Some(index) = self.table_state.selected().filter(|index| *index < self.entries.len()) else { + self.message = "Press / to enter a destination.".into(); + return; + }; + let mut entry = self.entries.remove(index); + entry.start(); + let target = entry.target.clone(); + self.entries.insert(0, entry); + self.table_state.select(Some(0)); + self.message.clear(); + self.save_history(); + self.tasks.spawn(async move { + let outcome = ping::ping(&target).await; + (target, outcome) + }); + } + + fn move_selection(&mut self, direction: isize) { + if !self.entries.is_empty() { + let current = self.table_state.selected().unwrap_or(0); + self.table_state.select(Some( + current.saturating_add_signed(direction).min(self.entries.len() - 1) + )); + } + } + + fn delete_selected(&mut self) { + if self.busy() { + self.message = "Wait for the current ping before removing a target.".into(); + return; + } + if let Some(index) = self.table_state.selected().filter(|index| *index < self.entries.len()) { + self.entries.remove(index); + let next = if self.entries.is_empty() { None } else { Some(index.min(self.entries.len() - 1)) }; + self.table_state.select(next); + self.message.clear(); + self.save_history(); + } + } + + fn save_history(&mut self) { + if !self.history_writable { + self.message = "History unavailable: changes are kept only for this session.".into(); + return; + } + if let Some(history) = &self.history + && let Err(error) = history.save(self.entries.iter().map(|entry| &entry.target)) + { + self.message = format!("Cannot save recent targets: {error}"); + } + } +} + +impl Default for PingTab { + fn default() -> Self { Self::new() } +} + +impl Component for PingTab { + fn id(&self) -> &'static str { "ping" } + fn title(&self) -> &'static str { "Ping" } + fn help(&self) -> &'static str { + if self.editing { + "Enter: Ping Esc: Leave input Left/Right/Home/End: Cursor" + } else { + "/: Target Up/Down: Select Enter/Click: Ping Del: Remove Q/Esc: Quit" + } + } + fn handle_event(&mut self, event: &Event) -> bool { input::handle(self, event) } + fn update(&mut self) { + while let Some(result) = self.tasks.try_join_next() { + match result { + Ok((target, outcome)) => { + if let Some(entry) = self.entries.iter_mut().find(|entry| entry.target == target) { + entry.finish(outcome); + } + } + Err(error) => { + for entry in &mut self.entries { + if matches!(entry.state, ProbeState::Running) { + entry.finish(PingOutcome::Error(format!("Background task failed: {error}"))); + } + } + } + } + } + } + fn render(&mut self, frame: &mut Frame, area: Rect) { ui::render(frame, area, self); } + fn reset_layout(&mut self) { + self.input_area = Rect::default(); + self.table_area = Rect::default(); + } + fn shutdown(&mut self) -> LocalBoxFuture<'_, ()> { + Box::pin(async move { self.tasks.shutdown().await; }) + } +} + +#[cfg(test)] +mod tests; diff --git a/src/tabs/ping/model.rs b/src/tabs/ping/model.rs new file mode 100644 index 0000000..74d422f --- /dev/null +++ b/src/tabs/ping/model.rs @@ -0,0 +1,96 @@ +use std::time::Instant; +use crate::network::{ping::PingOutcome, target::Target}; + +#[derive(Debug, Default)] +pub(super) struct Statistics { + pub completed: u64, + pub received: u64, + pub samples: u64, + pub sum: f64, + pub min: Option, + pub max: Option, +} + +impl Statistics { + pub fn record(&mut self, outcome: &PingOutcome) { + match outcome { + PingOutcome::Reply { latency_ms } => { + self.completed += 1; + self.received += 1; + if let Some(latency) = latency_ms { + self.samples += 1; + self.sum += latency; + self.min = Some(self.min.map_or(*latency, |value| value.min(*latency))); + self.max = Some(self.max.map_or(*latency, |value| value.max(*latency))); + } + } + PingOutcome::NoReply => self.completed += 1, + PingOutcome::Error(_) => {} + } + } + + pub fn average(&self) -> Option { + (self.samples > 0).then(|| self.sum / self.samples as f64) + } + + pub fn loss(&self) -> Option { + (self.completed > 0).then(|| { + 100.0 * (self.completed - self.received) as f64 / self.completed as f64 + }) + } +} + +#[derive(Debug)] +pub(super) enum ProbeState { + Ready, + Running, + Finished(PingOutcome), +} + +#[derive(Debug)] +pub(super) struct PingEntry { + pub target: Target, + pub state: ProbeState, + pub statistics: Statistics, + pub last_run: Option, +} + +impl PingEntry { + pub fn new(target: Target) -> Self { + Self { target, state: ProbeState::Ready, statistics: Statistics::default(), last_run: None } + } + + pub fn start(&mut self) { + self.state = ProbeState::Running; + self.last_run = Some(Instant::now()); + } + + pub fn finish(&mut self, outcome: PingOutcome) { + self.statistics.record(&outcome); + self.state = ProbeState::Finished(outcome); + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn counts_missing_latency_as_reply_and_excludes_execution_errors() { + let mut stats = Statistics::default(); + assert_eq!(stats.loss(), None); + for outcome in [ + PingOutcome::Reply { latency_ms: Some(10.0) }, + PingOutcome::Reply { latency_ms: Some(30.0) }, + PingOutcome::Reply { latency_ms: None }, + PingOutcome::NoReply, + PingOutcome::Error("DNS failure".into()), + ] { stats.record(&outcome); } + assert_eq!(stats.completed, 4); + assert_eq!(stats.received, 3); + assert_eq!(stats.average(), Some(20.0)); + assert_eq!(stats.loss(), Some(25.0)); + assert_eq!(stats.min, Some(10.0)); + assert_eq!(stats.max, Some(30.0)); + } +} diff --git a/src/tabs/ping/tests.rs b/src/tabs/ping/tests.rs new file mode 100644 index 0000000..dd188e1 --- /dev/null +++ b/src/tabs/ping/tests.rs @@ -0,0 +1,82 @@ +use super::*; +use crossterm::event::{Event, KeyCode, KeyEvent, KeyModifiers}; +use ratatui::{Terminal, backend::TestBackend}; + +fn key(code: KeyCode) -> Event { Event::Key(KeyEvent::new(code, KeyModifiers::NONE)) } + +fn tab() -> (tempfile::TempDir, PingTab) { + let dir = tempfile::tempdir().unwrap(); + let history = History::at(dir.path().join("recent-targets.txt")); + (dir, PingTab::from_history(Ok(history))) +} + +#[test] +fn input_consumes_quit_and_delete_and_supports_cursor_editing() { + let (_dir, mut tab) = tab(); + assert!(tab.handle_event(&key(KeyCode::Char('/')))); + for character in "abq".chars() { + assert!(tab.handle_event(&key(KeyCode::Char(character)))); + } + tab.handle_event(&key(KeyCode::Left)); + tab.handle_event(&key(KeyCode::Delete)); + assert_eq!(tab.input.value(), "ab"); + tab.handle_event(&key(KeyCode::Esc)); + assert!(!tab.handle_event(&key(KeyCode::Char('q')))); +} + +#[test] +fn delete_removes_selected_target_from_disk_and_handles_empty_list() { + let (dir, mut tab) = tab(); + tab.entries = ["one.example", "two.example"].into_iter() + .map(|name| PingEntry::new(Target::parse(name).unwrap())).collect(); + tab.table_state.select(Some(1)); + tab.handle_event(&key(KeyCode::Delete)); + assert_eq!(tab.entries[0].target.as_str(), "one.example"); + assert_eq!(tab.table_state.selected(), Some(0)); + let history = History::at(dir.path().join("recent-targets.txt")); + assert_eq!(history.load().unwrap().len(), 1); + tab.handle_event(&key(KeyCode::Delete)); + assert_eq!(tab.table_state.selected(), None); + assert!(history.load().unwrap().is_empty()); +} + +#[tokio::test] +async fn completion_updates_state_without_rendering_and_delete_waits() { + let (_dir, mut tab) = tab(); + let target = Target::parse("localhost").unwrap(); + let mut entry = PingEntry::new(target.clone()); + entry.start(); + tab.entries.push(entry); + tab.table_state.select(Some(0)); + tab.tasks.spawn(async move { + tokio::time::sleep(std::time::Duration::from_millis(10)).await; + (target, PingOutcome::Reply { latency_ms: Some(2.0) }) + }); + tab.delete_selected(); + assert_eq!(tab.entries.len(), 1); + tokio::time::timeout(std::time::Duration::from_secs(2), async { + while tab.busy() { + tokio::time::sleep(std::time::Duration::from_millis(5)).await; + tab.update(); + } + }).await.unwrap(); + assert_eq!(tab.entries[0].statistics.received, 1); + tab.shutdown().await; +} + +#[test] +fn renders_and_scrolls_selected_target() { + let (_dir, mut tab) = tab(); + tab.entries = (0..30).map(|index| { + PingEntry::new(Target::parse(&format!("host{index}.example")).unwrap()) + }).collect(); + tab.table_state.select(Some(29)); + let mut terminal = Terminal::new(TestBackend::new(90, 30)).unwrap(); + terminal.draw(|frame| tab.render(frame, frame.area())).unwrap(); + assert!(tab.table_state.offset() > 0); + let screen = terminal.backend().buffer().content.iter() + .map(|cell| cell.symbol()).collect::(); + assert!(screen.contains("host29.example")); + tab.reset_layout(); + assert_eq!(tab.table_area, Rect::default()); +} diff --git a/src/tabs/ping/ui.rs b/src/tabs/ping/ui.rs new file mode 100644 index 0000000..7f7700e --- /dev/null +++ b/src/tabs/ping/ui.rs @@ -0,0 +1,99 @@ +use ratatui::{ + Frame, layout::{Constraint, Layout, Rect}, + style::{Color, Modifier, Style}, + widgets::{Block, Borders, Paragraph, Row, Table, Wrap}, +}; +use crate::network::ping::PingOutcome; +use super::{PingTab, model::{PingEntry, ProbeState}}; + +pub(super) fn render(frame: &mut Frame, area: Rect, tab: &mut PingTab) { + let [input, table, details, message] = Layout::vertical([ + Constraint::Length(3), Constraint::Min(3), + Constraint::Length(7), Constraint::Length(2), + ]).areas(area); + tab.input_area = input; + tab.table_area = table; + render_input(frame, input, tab); + render_table(frame, table, tab); + + let text = tab.table_state.selected().and_then(|index| tab.entries.get(index)) + .map(details_text).unwrap_or_else(|| "No recent targets. Press / to enter a destination.".into()); + frame.render_widget( + Paragraph::new(text).wrap(Wrap { trim: false }).block( + Block::default().title(" Selected target // session statistics ").borders(Borders::ALL) + ), details, + ); + frame.render_widget( + Paragraph::new(tab.message.as_str()).style(Style::default().fg(Color::Yellow)) + .wrap(Wrap { trim: false }), + message, + ); +} + +fn render_input(frame: &mut Frame, area: Rect, tab: &PingTab) { + let width = usize::from(area.width.saturating_sub(2)); + let scroll = tab.input.visual_scroll(width.saturating_sub(1)); + let placeholder = tab.input.value().is_empty() && !tab.editing; + let text = if placeholder { "Press / or click here to enter an IP address or hostname" } else { tab.input.value() }; + frame.render_widget( + Paragraph::new(text).scroll((0, scroll as u16)).block( + Block::default().title(" IP address or hostname ").borders(Borders::ALL) + .border_style(Style::default().fg(if tab.editing { Color::Cyan } else { Color::Reset })) + ), area, + ); + if tab.editing && width > 0 { + let x = tab.input.visual_cursor().saturating_sub(scroll).min(width - 1) as u16; + frame.set_cursor_position((area.x + 1 + x, area.y + 1)); + } +} + +fn render_table(frame: &mut Frame, area: Rect, tab: &mut PingTab) { + let rows = tab.entries.iter().map(|entry| { + let latency = match &entry.state { + ProbeState::Finished(PingOutcome::Reply { latency_ms }) => *latency_ms, + _ => None, + }; + Row::new(vec![ + entry.target.to_string(), status(&entry.state).into(), milliseconds(latency), + entry.last_run.map(|time| format!("{}s ago", time.elapsed().as_secs())).unwrap_or_else(|| "-".into()), + "[Ping]".into(), + ]) + }).collect::>(); + let table = Table::new(rows, [ + Constraint::Fill(1), Constraint::Length(15), Constraint::Length(12), + Constraint::Length(11), Constraint::Length(6), + ]) + .header(Row::new(["Target", "Status", "Latency", "Last run", "Action"]).style(Style::default().fg(Color::Cyan))) + .row_highlight_style(Style::default().bg(Color::DarkGray).add_modifier(Modifier::BOLD)) + .block(Block::default().title(" Recent targets ").borders(Borders::ALL)); + frame.render_stateful_widget(table, area, &mut tab.table_state); +} + +fn status(state: &ProbeState) -> &'static str { + match state { + ProbeState::Ready => "Ready", + ProbeState::Running => "Running...", + ProbeState::Finished(PingOutcome::Reply { .. }) => "Reply received", + ProbeState::Finished(PingOutcome::NoReply) => "No reply", + ProbeState::Finished(PingOutcome::Error(_)) => "Error", + } +} + +fn milliseconds(value: Option) -> String { + value.map(|value| format!("{value:.2} ms")).unwrap_or_else(|| "-".into()) +} + +fn details_text(entry: &PingEntry) -> String { + let stats = &entry.statistics; + let loss = stats.loss().map(|value| format!("{value:.1}%")).unwrap_or_else(|| "-".into()); + let detail = match &entry.state { + ProbeState::Finished(PingOutcome::Error(error)) => error.as_str(), + ProbeState::Finished(PingOutcome::Reply { latency_ms: None }) => "Reply received; latency unavailable.", + other => status(other), + }; + format!( + "{}\nCompleted probes: {} Replies: {} Loss: {}\nMin / Avg / Max: {} / {} / {}\n{}", + entry.target, stats.completed, stats.received, loss, + milliseconds(stats.min), milliseconds(stats.average()), milliseconds(stats.max), detail, + ) +} diff --git a/src/terminal.rs b/src/terminal.rs new file mode 100644 index 0000000..ad5861e --- /dev/null +++ b/src/terminal.rs @@ -0,0 +1,38 @@ +use std::io; + +use crossterm::{ + event::{DisableBracketedPaste, DisableMouseCapture, EnableBracketedPaste, EnableMouseCapture}, + execute, +}; +use ratatui::DefaultTerminal; + +/// Owns terminal modes and restores them on normal exit, errors and panics. +pub struct TerminalSession { + terminal: DefaultTerminal, +} + +impl TerminalSession { + pub fn new() -> io::Result { + // Ratatui installs its own panic hook; extend it to restore input modes too. + let terminal = ratatui::try_init()?; + let previous = std::panic::take_hook(); + std::panic::set_hook(Box::new(move |info| { + let _ = execute!(io::stdout(), DisableMouseCapture, DisableBracketedPaste); + previous(info); + })); + let session = Self { terminal }; + execute!(io::stdout(), EnableMouseCapture, EnableBracketedPaste)?; + Ok(session) + } + + pub fn terminal(&mut self) -> &mut DefaultTerminal { + &mut self.terminal + } +} + +impl Drop for TerminalSession { + fn drop(&mut self) { + let _ = execute!(io::stdout(), DisableMouseCapture, DisableBracketedPaste); + ratatui::restore(); + } +} diff --git a/src/ui.rs b/src/ui.rs index 58b912a..aec4e24 100644 --- a/src/ui.rs +++ b/src/ui.rs @@ -2,224 +2,50 @@ use ratatui::{ Frame, layout::{Constraint, Layout, Rect}, style::{Color, Modifier, Style}, - widgets::{Block, Borders, Paragraph, Row, Table, Tabs, Wrap}, + widgets::{Block, Borders, Paragraph, Wrap}, }; -use crate::app::App; - -pub fn render(frame: &mut Frame, app: &mut App) { - app.input_area = Rect::default(); - app.table_area = Rect::default(); +use crate::component::Component; +pub(crate) fn render(frame: &mut Frame, components: &mut [Box], active: usize) -> Vec { + for component in components.iter_mut() { + component.reset_layout(); + } if frame.area().width < 65 || frame.area().height < 22 { frame.render_widget( - Paragraph::new("Resize the terminal to at least 65 columns x 22 rows."), + Paragraph::new("Resize to at least 65 columns x 22 rows. Ctrl+C: Quit"), frame.area(), ); - return; + return Vec::new(); } - - let [tabs_area, body_area, footer_area] = Layout::vertical([ - Constraint::Length(3), - Constraint::Min(1), - Constraint::Length(3), - ]) - .areas(frame.area()); - - let tabs = Tabs::new(vec!["[1] Overview", "[2] Ping"]) - .select(app.tab) - .highlight_style( + let [header, body, footer] = Layout::vertical([ + Constraint::Length(3), Constraint::Min(1), Constraint::Length(4), + ]).areas(frame.area()); + + let block = Block::default().title(" NETracer // NETWORK DIAGNOSIS ").borders(Borders::ALL); + let inner = block.inner(header); + frame.render_widget(block, header); + + let constraints = vec![Constraint::Fill(1); components.len()]; + let tab_areas = Layout::horizontal(constraints).split(inner).to_vec(); + for (index, component) in components.iter().enumerate() { + let style = if index == active { + Style::default().fg(Color::Cyan).add_modifier(Modifier::BOLD) + } else { Style::default() - .fg(Color::Cyan) - .add_modifier(Modifier::BOLD), - ) - .block( - Block::default() - .title(" NETracer // NETWORK DIAGNOSIS ") - .borders(Borders::ALL), + }; + frame.render_widget( + Paragraph::new(format!("[{}] {}", index + 1, component.title())).style(style), + tab_areas[index], ); - - frame.render_widget(tabs, tabs_area); - - if app.tab == 0 { - render_overview(frame, app, body_area); - } else { - render_ping(frame, app, body_area); } - let help = if app.editing { - "Enter: Ping Esc: Cancel input Ctrl+C: Quit" - } else if app.tab == 1 { - "/: New target Up/Down: Select Enter/Click: Ping\nDel: Remove Tab: Switch tab Q: Quit" - } else { - "Tab: Switch tab 2: Ping Q: Quit" - }; - - frame.render_widget( - Paragraph::new(help).block(Block::default().borders(Borders::ALL)), - footer_area, - ); -} - -fn render_overview(frame: &mut Frame, app: &App, area: Rect) { - let interface = app.network.interface.as_deref().unwrap_or("N/A"); - let ipv4 = app.network.ipv4.as_deref().unwrap_or("N/A"); - let gateway = app.network.gateway.as_deref().unwrap_or("N/A"); - - let link = if app.network.has_link() { - "ACTIVE" - } else { - "UNAVAILABLE" - }; - - let text = format!( - "HOSTNAME {}\n\ - INTERFACE {}\n\ - IPv4 {}\n\ - GATEWAY {}\n\ - LINK {}\n\n\ - UPTIME {}s", - app.network.hostname, - interface, - ipv4, - gateway, - link, - app.uptime().as_secs(), - ); - - frame.render_widget( - Paragraph::new(text).block(Block::default().title(" Overview ").borders(Borders::ALL)), - area, - ); -} - -fn render_ping(frame: &mut Frame, app: &mut App, area: Rect) { - let [input_area, table_area, details_area, message_area] = Layout::vertical([ - Constraint::Length(3), - Constraint::Min(4), - Constraint::Length(7), - Constraint::Length(2), - ]) - .areas(area); - - app.input_area = input_area; - app.table_area = table_area; - - let input_style = if app.editing { - Style::default().fg(Color::Cyan) - } else { - Style::default() - }; - - let input_text = if app.input.is_empty() && !app.editing { - "Press / or click here to enter an IP address or hostname".to_string() - } else { - app.input.clone() - }; - - let inner_width = usize::from(input_area.width.saturating_sub(2)); - let scroll = if app.editing { - app.input - .len() - .saturating_sub(inner_width.saturating_sub(1)) - } else { - 0 - }; - - frame.render_widget( - Paragraph::new(input_text).scroll((0, scroll as u16)).block( - Block::default() - .title(" IP address or hostname ") - .borders(Borders::ALL) - .border_style(input_style), - ), - input_area, - ); - - if app.editing { - let cursor = app.input.len().saturating_sub(scroll) as u16; - frame.set_cursor_position((input_area.x + 1 + cursor, input_area.y + 1)); - } - - let rows: Vec> = app - .entries - .iter() - .map(|entry| { - let latency = entry - .latency - .map(|value| format!("{value:.2} ms")) - .unwrap_or_else(|| "-".into()); - - let last_run = entry - .last_run - .map(|time| format!("{}s ago", time.elapsed().as_secs())) - .unwrap_or_else(|| "-".into()); - - let status = if entry.status.starts_with("Error:") { - "Error".to_string() - } else { - entry.status.clone() - }; - - Row::new(vec![ - entry.target.clone(), - status, - latency, - last_run, - "[Ping]".into(), - ]) - }) - .collect(); - - let table = Table::new( - rows, - [ - Constraint::Fill(1), - Constraint::Length(15), - Constraint::Length(12), - Constraint::Length(11), - Constraint::Length(6), - ], - ) - .header( - Row::new(vec!["Target", "Status", "Latency", "Last run", "Action"]) - .style(Style::default().fg(Color::Cyan)), - ) - .row_highlight_style( - Style::default() - .bg(Color::DarkGray) - .add_modifier(Modifier::BOLD), - ) - .block( - Block::default() - .title(" Recent targets ") - .borders(Borders::ALL), - ); - - frame.render_stateful_widget(table, table_area, &mut app.table_state); - - let details = app - .table_state - .selected() - .and_then(|index| app.entries.get(index)) - .map(|entry| entry.statistics()) - .unwrap_or_else(|| { - "No recent targets.\nPress / to enter a destination and run your first ping.".into() - }); - - frame.render_widget( - Paragraph::new(details).wrap(Wrap { trim: false }).block( - Block::default() - .title(" Selected target // session statistics ") - .borders(Borders::ALL), - ), - details_area, - ); - + components[active].render(frame, body); + let help = format!("{}\nTab/Shift+Tab: Switch tab Ctrl+C: Quit", components[active].help()); frame.render_widget( - Paragraph::new(app.message.as_str()) - .style(Style::default().fg(Color::Yellow)) - .wrap(Wrap { trim: false }), - message_area, + Paragraph::new(help).wrap(Wrap { trim: false }) + .block(Block::default().borders(Borders::ALL)), + footer, ); + tab_areas }