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,
};
pub async fn run(
targets: Vec<String>,
state: AppState,
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, &targets, &state, 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>>,
targets: &[String],
state: &AppState,
probe_type: &str,
interval_ms: u64,
) -> Result<()> {
loop {
terminal.draw(|frame| {
render(frame, targets, state, 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,
targets: &[String],
state: &AppState,
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);
render_header(frame, root[0], probe_type, interval_ms, targets.len());
if !targets.is_empty() {
let guard = state.lock().unwrap();
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());
let color = health_color(stats.loss_pct, &stats);
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);
let sparkline_h = inner.height.saturating_sub(2).max(1);
let inner_layout = Layout::vertical([
Constraint::Length(1), Constraint::Length(sparkline_h), Constraint::Length(1), ])
.split(inner);
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]);
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]);
}
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]);
}
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
}
}
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())
}