netpulse-cli 0.1.1

A zero-config, single-binary network quality monitor with percentile stats, jitter, and MTR-style traceroute
Documentation
// src/tui/trace.rs — MTR-style trace table

use crate::{state::AppState, stats::engine::StatsEngine};
use anyhow::Result;
use crossterm::{
    event::{self, Event, KeyCode, KeyModifiers},
    execute,
    terminal::{disable_raw_mode, enable_raw_mode, EnterAlternateScreen, LeaveAlternateScreen},
};
use ratatui::{
    backend::CrosstermBackend,
    layout::{Constraint, Layout},
    style::{Color, Modifier, Style},
    text::{Line, Span},
    widgets::{Block, Borders, Paragraph, Row, Table},
    Frame, Terminal,
};
use std::{
    io::{self, Stdout},
    time::Duration,
};

pub async fn run(
    state: AppState,
    max_ttl: u8,
    target_name: &str,
    target_ip: &str,
    probe_type: &str,
    interval_ms: u64,
) -> Result<()> {
    enable_raw_mode()?;
    let mut stdout = io::stdout();
    execute!(stdout, EnterAlternateScreen)?;
    let backend = CrosstermBackend::new(stdout);
    let mut terminal = Terminal::new(backend)?;

    let result = event_loop(
        &mut terminal,
        &state,
        max_ttl,
        target_name,
        target_ip,
        probe_type,
        interval_ms,
    )
    .await;

    let _ = disable_raw_mode();
    let _ = execute!(terminal.backend_mut(), LeaveAlternateScreen);
    let _ = terminal.show_cursor();

    result
}

async fn event_loop(
    terminal: &mut Terminal<CrosstermBackend<Stdout>>,
    state: &AppState,
    max_ttl: u8,
    target_name: &str,
    target_ip: &str,
    probe_type: &str,
    interval_ms: u64,
) -> Result<()> {
    loop {
        terminal.draw(|frame| {
            render(
                frame,
                state,
                max_ttl,
                target_name,
                target_ip,
                probe_type,
                interval_ms,
            );
        })?;

        if event::poll(Duration::ZERO)? {
            if let Event::Key(key) = event::read()? {
                match (key.code, key.modifiers) {
                    (KeyCode::Char('q'), _)
                    | (KeyCode::Char('Q'), _)
                    | (KeyCode::Esc, _)
                    | (KeyCode::Char('c'), KeyModifiers::CONTROL) => break,
                    _ => {}
                }
            }
        }

        tokio::time::sleep(Duration::from_millis(100)).await;
    }
    Ok(())
}

fn render(
    frame: &mut Frame,
    state: &AppState,
    max_ttl: u8,
    target_name: &str,
    target_ip: &str,
    probe_type: &str,
    interval_ms: u64,
) {
    let area = frame.area();
    let root = Layout::vertical([
        Constraint::Length(1),
        Constraint::Fill(1),
        Constraint::Length(1),
    ])
    .split(area);

    // Header
    let header = Paragraph::new(Line::from(vec![
        Span::styled(
            format!(" ◈ netpulse trace to {} ({})", target_name, target_ip),
            Style::default()
                .fg(Color::Cyan)
                .add_modifier(Modifier::BOLD),
        ),
        Span::raw(format!(
            "  ·  {}  ·  {}ms interval",
            probe_type, interval_ms
        )),
    ]));
    frame.render_widget(header, root[0]);

    // Table
    let mut rows = Vec::new();
    let guard = state.lock().unwrap();

    for ttl in 1..=max_ttl {
        let hop_key = ttl.to_string();
        if let Some(ts) = guard.get(&hop_key) {
            let snap = ts.buffer.snapshot();
            let stats = StatsEngine::compute(&hop_key, snap);

            let ip_str = ts.last_ip.as_deref().unwrap_or("???");
            let loss_str = format!("{:>5.1}%", stats.loss_pct);
            let sent_str = format!("{:>5}", ts.seq);

            let last_str = fmt_us_aligned(ts.last_rtt_us);
            let avg_str = fmt_us_aligned(stats.rtt_p50_us); // we use p50 as avg
            let best_str = fmt_us_aligned(stats.rtt_min_us);
            let wrst_str = fmt_us_aligned(stats.rtt_p99_us);
            let jitt_str = fmt_us_aligned(stats.jitter_us.map(|j| j as u64));

            let color = if stats.loss_pct >= 10.0 {
                Color::Red
            } else if stats.loss_pct > 0.0 {
                Color::Yellow
            } else {
                Color::Reset
            };

            let cells = vec![
                format!("{:>2}", ttl),
                format!("{:<15}", ip_str),
                loss_str,
                sent_str,
                last_str,
                avg_str,
                best_str,
                wrst_str,
                jitt_str,
            ];

            rows.push(Row::new(cells).style(Style::default().fg(color)));
        }
    }

    let widths = [
        Constraint::Length(3),  // TTL
        Constraint::Length(17), // IP
        Constraint::Length(7),  // Loss%
        Constraint::Length(7),  // Snt
        Constraint::Length(8),  // Last
        Constraint::Length(8),  // Avg
        Constraint::Length(8),  // Best
        Constraint::Length(8),  // Wrst
        Constraint::Length(8),  // Jitter
    ];

    let table = Table::new(rows, widths)
        .header(
            Row::new(vec![
                "Hop", "Host", "Loss%", "Snt", "Last", "Avg", "Best", "Wrst", "Jitt",
            ])
            .style(
                Style::default()
                    .fg(Color::DarkGray)
                    .add_modifier(Modifier::BOLD),
            )
            .bottom_margin(0),
        )
        .block(
            Block::default()
                .borders(Borders::ALL)
                .title(Line::from(vec![Span::raw(" Hops ")])),
        )
        .column_spacing(2);

    frame.render_widget(table, root[1]);

    // Footer
    let footer = Paragraph::new(" q quit  ·  Esc quit").style(Style::default().fg(Color::DarkGray));
    frame.render_widget(footer, root[2]);
}

fn fmt_us_aligned(val: Option<u64>) -> String {
    val.map(|v| format!("{:>6.1}", v as f64 / 1000.0))
        .unwrap_or_else(|| format!("{:>6}", "n/a"))
}