netpulse-cli 0.1.1

A zero-config, single-binary network quality monitor with percentile stats, jitter, and MTR-style traceroute
Documentation
// src/tui/mod.rs — Live Terminal UI
//
// Built with ratatui + crossterm. Features:
// - One panel per monitored target
// - Sparkline of recent RTT values (Unicode block characters)
// - Real-time percentile stats (p50/p95/p99), jitter, burst loss
// - Color-coded health: green=healthy, yellow=some loss, red=degraded
// - 'q' or Esc to quit cleanly

pub mod trace_tui;
pub mod widgets;

use crate::state::{AppState, TargetState};
use crate::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, Rect},
    style::{Color, Modifier, Style},
    text::{Line, Span},
    widgets::{Block, Borders, Paragraph, Sparkline},
    Frame, Terminal,
};
use std::{
    io::{self, Stdout},
    time::Duration,
};

// ─── Public Entry Point ───────────────────────────────────────────────────────

/// Run the full TUI. Blocks until 'q', Esc, or Ctrl-C.
/// Restores the terminal on exit, even if an error occurs.
pub async fn run(
    targets: Vec<String>,
    state: AppState,
    probe_type: &str,
    interval_ms: u64,
) -> Result<()> {
    // Terminal setup
    enable_raw_mode()?;
    let mut stdout = io::stdout();
    execute!(stdout, EnterAlternateScreen)?;
    let backend = CrosstermBackend::new(stdout);
    let mut terminal = Terminal::new(backend)?;

    // Run the event loop — restore terminal on both success and error
    let result = event_loop(&mut terminal, &targets, &state, probe_type, interval_ms).await;

    // Terminal teardown (always runs)
    let _ = disable_raw_mode();
    let _ = execute!(terminal.backend_mut(), LeaveAlternateScreen);
    let _ = terminal.show_cursor();

    result
}

// ─── Event Loop ───────────────────────────────────────────────────────────────

async fn event_loop(
    terminal: &mut Terminal<CrosstermBackend<Stdout>>,
    targets: &[String],
    state: &AppState,
    probe_type: &str,
    interval_ms: u64,
) -> Result<()> {
    loop {
        // Draw a frame from current state
        terminal.draw(|frame| {
            render(frame, targets, state, probe_type, interval_ms);
        })?;

        // Non-blocking keyboard event check
        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,
                    _ => {}
                }
            }
        }

        // Yield to Tokio runtime so prober tasks can run, then redraw at ~10fps
        tokio::time::sleep(Duration::from_millis(100)).await;
    }
    Ok(())
}

// ─── Rendering ────────────────────────────────────────────────────────────────

fn render(
    frame: &mut Frame,
    targets: &[String],
    state: &AppState,
    probe_type: &str,
    interval_ms: u64,
) {
    let area = frame.area();

    // Root layout: header | target panels | footer
    let root = Layout::vertical([
        Constraint::Length(1),
        Constraint::Fill(1),
        Constraint::Length(1),
    ])
    .split(area);

    render_header(frame, root[0], probe_type, interval_ms, targets.len());

    if !targets.is_empty() {
        let guard = state.lock().unwrap();

        // Divide the middle section equally among all targets
        let ratios: Vec<Constraint> = (0..targets.len())
            .map(|_| Constraint::Ratio(1, targets.len() as u32))
            .collect();
        let panels = Layout::vertical(ratios).split(root[1]);

        for (i, target) in targets.iter().enumerate() {
            if let Some(ts) = guard.get(target) {
                render_target_panel(frame, panels[i], target, ts);
            }
        }
    }

    render_footer(frame, root[2]);
}

fn render_header(
    frame: &mut Frame,
    area: Rect,
    probe_type: &str,
    interval_ms: u64,
    n_targets: usize,
) {
    let header = Paragraph::new(Line::from(vec![
        Span::styled(
            " ◈ netpulse".to_string(),
            Style::default()
                .fg(Color::Cyan)
                .add_modifier(Modifier::BOLD),
        ),
        Span::raw(format!(
            "  ·  {}  ·  {}ms interval  ·  {} target{}",
            probe_type,
            interval_ms,
            n_targets,
            if n_targets == 1 { "" } else { "s" }
        )),
    ]));
    frame.render_widget(header, area);
}

fn render_footer(frame: &mut Frame, area: Rect) {
    let footer = Paragraph::new(" q quit  ·  Esc quit  ·  sparkline: recent RTT history")
        .style(Style::default().fg(Color::DarkGray));
    frame.render_widget(footer, area);
}

fn render_target_panel(frame: &mut Frame, area: Rect, name: &str, ts: &TargetState) {
    let snapshot = ts.buffer.snapshot();
    let stats = StatsEngine::compute(name, snapshot.clone());

    // Color the entire panel based on health
    let color = health_color(stats.loss_pct, &stats);

    // Outer bordered block
    let block = Block::default()
        .title(Line::from(vec![Span::styled(
            format!(" {} ", name),
            Style::default().fg(color).add_modifier(Modifier::BOLD),
        )]))
        .borders(Borders::ALL)
        .border_style(Style::default().fg(color));

    let inner = block.inner(area);
    frame.render_widget(block, area);

    // Inner layout: [status line] [sparkline] [stats line]
    // Sparkline gets whatever vertical space remains after the two fixed rows.
    let sparkline_h = inner.height.saturating_sub(2).max(1);
    let inner_layout = Layout::vertical([
        Constraint::Length(1),           // status: seq / last RTT / loss%
        Constraint::Length(sparkline_h), // sparkline
        Constraint::Length(1),           // stats: percentiles + jitter
    ])
    .split(inner);

    // ── Status line ─────────────────────────────────────────────────────────
    let last_str = match ts.last_rtt_us {
        Some(rtt) => {
            let ms = rtt as f64 / 1000.0;
            let rtt_color = rtt_color(rtt, stats.rtt_p99_us);
            Span::styled(format!("{:.2}ms", ms), Style::default().fg(rtt_color))
        }
        None if ts.seq > 0 => Span::styled("TIMEOUT", Style::default().fg(Color::Red)),
        None => Span::styled("waiting…", Style::default().fg(Color::DarkGray)),
    };

    let status = Line::from(vec![
        Span::raw(format!(" seq={:<6}  last: ", ts.seq)),
        last_str,
        Span::raw(format!("  loss: {:.1}%", stats.loss_pct)),
    ]);
    frame.render_widget(Paragraph::new(status), inner_layout[0]);

    // ── Sparkline ────────────────────────────────────────────────────────────
    let sparkline_data: Vec<u64> = snapshot.iter().filter_map(|p| p.rtt_us).collect();
    if !sparkline_data.is_empty() {
        let sparkline = Sparkline::default()
            .data(&sparkline_data)
            .style(Style::default().fg(color));
        frame.render_widget(sparkline, inner_layout[1]);
    }

    // ── Stats line ───────────────────────────────────────────────────────────
    let stats_line = Line::from(vec![
        Span::raw(" min:"),
        Span::styled(fmt_us(stats.rtt_min_us), Style::default().fg(Color::Gray)),
        Span::raw("  p50:"),
        Span::styled(fmt_us(stats.rtt_p50_us), Style::default().fg(Color::White)),
        Span::raw("  p95:"),
        Span::styled(
            fmt_us(stats.rtt_p95_us),
            Style::default().fg(if stats.rtt_p95_us > stats.rtt_p50_us.map(|p| p * 2) {
                Color::Yellow
            } else {
                Color::White
            }),
        ),
        Span::raw("  p99:"),
        Span::styled(fmt_us(stats.rtt_p99_us), Style::default().fg(Color::White)),
        Span::raw("  jitter:"),
        Span::styled(
            stats
                .jitter_us
                .map(|j| format!("{:.2}ms", j / 1000.0))
                .unwrap_or_else(|| "n/a".to_string()),
            Style::default().fg(Color::Gray),
        ),
        Span::raw("  burst:"),
        Span::styled(
            stats.max_burst_loss.to_string(),
            Style::default().fg(if stats.max_burst_loss > 0 {
                Color::Yellow
            } else {
                Color::Gray
            }),
        ),
    ]);
    frame.render_widget(Paragraph::new(stats_line), inner_layout[2]);
}

// ─── Helpers ──────────────────────────────────────────────────────────────────

/// Panel border + sparkline color based on loss rate.
fn health_color(loss_pct: f64, stats: &crate::stats::engine::StatsSnapshot) -> Color {
    if loss_pct >= 10.0 {
        Color::Red
    } else if loss_pct > 0.0 {
        Color::Yellow
    } else if stats.sample_count == 0 {
        Color::DarkGray
    } else {
        Color::Green
    }
}

/// Color for the last-RTT value: green/yellow/red relative to p50.
fn rtt_color(rtt_us: u64, p99: Option<u64>) -> Color {
    match p99 {
        Some(p) if rtt_us > p => Color::Red,
        _ => Color::Green,
    }
}

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