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);
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]);
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); 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), Constraint::Length(17), Constraint::Length(7), Constraint::Length(7), Constraint::Length(8), Constraint::Length(8), Constraint::Length(8), Constraint::Length(8), Constraint::Length(8), ];
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]);
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"))
}