use std::{ collections::{BTreeMap, VecDeque}, io::{self, Stdout}, time::{Duration, Instant}, }; use crossterm::{ cursor::Show, event::KeyCode, execute, terminal::{disable_raw_mode, enable_raw_mode, EnterAlternateScreen, LeaveAlternateScreen}, }; use irtt_client::managed::{ManagedTargetEndReason, ManagedTargetOutcome, TargetInstance}; use irtt_client::{ClientEvent, NegotiationResult, SignedDuration}; use irtt_stats::{Snapshot, StatsCollector, TimeStats}; use ratatui::{ backend::CrosstermBackend, layout::{Constraint, Layout, Rect}, style::{Color, Modifier, Style}, symbols, text::{Line, Span, Text}, widgets::{Axis, Block, Chart, Dataset, GraphType, Paragraph, Row, Table, Wrap}, Frame, Terminal, }; use crate::{ cmd::{ format::{ format_count, format_duration, format_ns_f64, format_optional_count, format_optional_ns_i128, format_percent, format_percent_ratio, ABSENT, }, tui::args::TuiArgs, }, shared::client::{expected_probe_count, GroupPacingArg, ManagedRunSetup}, }; const HISTORY_LIMIT: usize = 100_000; const RECENT_EVENT_LIMIT: usize = 80; const MIN_WIDTH: u16 = 56; const MIN_HEIGHT: u16 = 18; const DEFAULT_GRAPH_WINDOW: Duration = Duration::from_secs(60); const MIN_GRAPH_WINDOW: Duration = Duration::from_secs(5); const MAX_GRAPH_WINDOW: Duration = Duration::from_secs(60 * 60); pub(super) struct TuiTerminal { terminal: Terminal>, } impl TuiTerminal { pub(super) fn enter() -> io::Result { enable_raw_mode()?; let mut stdout = io::stdout(); if let Err(err) = execute!(stdout, EnterAlternateScreen) { let _ = disable_raw_mode(); return Err(err); } let backend = CrosstermBackend::new(stdout); match Terminal::new(backend) { Ok(mut terminal) => { if let Err(err) = terminal.clear() { let _ = disable_raw_mode(); let _ = execute!(terminal.backend_mut(), LeaveAlternateScreen, Show); let _ = terminal.show_cursor(); return Err(err); } Ok(Self { terminal }) } Err(err) => { let _ = disable_raw_mode(); let mut stdout = io::stdout(); let _ = execute!(stdout, LeaveAlternateScreen, Show); Err(err) } } } pub(super) fn draw(&mut self, state: &TuiState) -> io::Result<()> { self.terminal .draw(|frame| draw_dashboard(frame, state)) .map(|_| ()) } } impl Drop for TuiTerminal { fn drop(&mut self) { let _ = disable_raw_mode(); let _ = execute!(self.terminal.backend_mut(), LeaveAlternateScreen, Show); let _ = self.terminal.show_cursor(); } } #[derive(Debug)] pub(super) struct TuiState { status: TuiStatus, started_at: Instant, config: TuiConfig, target_index: BTreeMap, targets: Vec, recent_events: VecDeque, last_warning: Option, dropped_events: u64, graph_metric: GraphMetric, graph_viewport: GraphViewport, selected_target: usize, details_open: bool, details_scroll: (u16, u16), pub(super) paused: bool, } impl TuiState { pub(super) fn new(config: TuiConfig) -> Self { Self::with_target_labels(config, ["target".to_owned()]) } pub(super) fn with_target_labels( config: TuiConfig, labels: impl IntoIterator, ) -> Self { let stats_config = stats_config(config.duration.is_none()); let targets = labels .into_iter() .map(|label| TuiTargetState::new(label, stats_config)) .collect::>(); let target_index = targets .iter() .enumerate() .map(|(idx, target)| (target.label.clone(), idx)) .collect(); Self { status: TuiStatus::Opening, started_at: Instant::now(), config, target_index, targets, recent_events: VecDeque::with_capacity(RECENT_EVENT_LIMIT), last_warning: None, dropped_events: 0, graph_metric: GraphMetric::EffectiveRtt, graph_viewport: GraphViewport::default(), selected_target: 0, details_open: false, details_scroll: (0, 0), paused: false, } } pub(super) fn process_target_event(&mut self, target: &TargetInstance, event: &ClientEvent) { let label = target.id.as_str(); let idx = if let Some(idx) = self.target_index.get(label).copied() { idx } else { let idx = self.targets.len(); let mut target = TuiTargetState::new( label.to_owned(), stats_config(self.config.duration.is_none()), ); target.status = TargetStatus::Unknown; self.targets.push(target); self.target_index.insert(label.to_owned(), idx); idx }; self.process_event_for_target(idx, event); } pub(super) fn process_target_outcome(&mut self, outcome: &ManagedTargetOutcome) { let label = outcome.target.id.as_str(); let idx = if let Some(idx) = self.target_index.get(label).copied() { idx } else { let idx = self.targets.len(); self.targets.push(TuiTargetState::new( label.to_owned(), stats_config(self.config.duration.is_none()), )); self.target_index.insert(label.to_owned(), idx); idx }; let (status, recent, primary_warning) = match &outcome.end_reason { ManagedTargetEndReason::TestComplete => { (TargetStatus::Closed, "test completed".to_owned(), None) } ManagedTargetEndReason::PeerClosed => ( TargetStatus::Closed, "session closed by peer".to_owned(), None, ), ManagedTargetEndReason::NoTestComplete => ( TargetStatus::NoTest, "no-test negotiation completed".to_owned(), None, ), ManagedTargetEndReason::Stopped => { (TargetStatus::Stopped, "target stopped".to_owned(), None) } ManagedTargetEndReason::Removed => { (TargetStatus::Removed, "target removed".to_owned(), None) } ManagedTargetEndReason::Replaced => { (TargetStatus::Replaced, "target replaced".to_owned(), None) } ManagedTargetEndReason::Failed(failure) => { let message = format!("{}: {}", failure.kind, failure.message); ( TargetStatus::Failed, format!("target failed: {message}"), Some(message), ) } }; let cleanup_warning = outcome.cleanup_failure.as_ref().map(|failure| { format!( "cleanup failed ({} {}): {}", failure.phase, failure.kind, failure.message ) }); let warning = match (primary_warning, cleanup_warning.as_ref()) { (Some(primary), Some(cleanup)) => Some(format!("{primary}; {cleanup}")), (Some(primary), None) => Some(primary), (None, Some(cleanup)) => Some(cleanup.clone()), (None, None) => None, }; if let Some(target) = self.targets.get_mut(idx) { target.remote = outcome.remote.map(|remote| remote.to_string()); target.status = status; if let Some(warning) = &warning { target.last_warning = Some(warning.clone()); } } if let Some(warning) = warning { self.last_warning = Some(format!("{label}: {warning}")); } self.push_event(format!("{label}: {recent}")); if let Some(cleanup_warning) = cleanup_warning { self.push_event(format!("{label}: {cleanup_warning}")); } if self .targets .iter() .all(|target| target.status.is_terminal()) { self.status = if self.targets.iter().any(|target| target.status.is_success()) { TuiStatus::Complete } else if self .targets .iter() .any(|target| target.status == TargetStatus::Failed) { TuiStatus::Error } else { TuiStatus::Complete }; } } fn process_event_for_target(&mut self, target_idx: usize, event: &ClientEvent) { let recent; let mut global_status = None; let mut global_warning = None; let label = { let Some(target) = self.targets.get_mut(target_idx) else { return; }; target.stats.process(event); match event { ClientEvent::SessionStarted(irtt_client::SessionStarted { remote, token, negotiation: negotiated, .. }) => { target.remote = Some(remote.to_string()); target.session = Some(format!("{token:#x}")); target.negotiated = Some(negotiated.clone()); target.status = TargetStatus::Active; global_status = Some(TuiStatus::Running); recent = Some(format!("session started token={token:#x}")); } ClientEvent::NoTestCompleted(irtt_client::NoTestCompleted { remote, negotiation: negotiated, .. }) => { target.remote = Some(remote.to_string()); target.negotiated = Some(negotiated.clone()); target.status = TargetStatus::NoTest; global_status = Some(TuiStatus::Complete); recent = Some("no-test negotiation completed".to_owned()); } ClientEvent::SessionClosed { token, .. } => { target.session = Some(format!("{token:#x}")); target.status = TargetStatus::Closed; global_status = Some(TuiStatus::Complete); recent = Some(format!("session closed token={token:#x}")); } ClientEvent::EchoSent { seq, bytes, .. } => { recent = Some(format!("sent seq={} bytes={bytes}", seq)); } ClientEvent::EchoReply { seq, received_at, rtt, one_way, server_timing, .. } => { let client_to_server_ns = one_way .and_then(|sample| sample.client_to_server) .map(SignedDuration::as_nanos); let server_to_client_ns = one_way .and_then(|sample| sample.server_to_client) .map(SignedDuration::as_nanos); let server_processing_ns = server_timing .and_then(|timing| timing.processing) .map(duration_ns); target.push_graph_sample(GraphSample { timestamp: received_at.mono, seq: *seq, effective_ns: rtt.effective.as_nanos(), raw_ns: duration_ns(rtt.raw), adjusted_ns: rtt.adjusted.map(SignedDuration::as_nanos), client_to_server_ns, server_to_client_ns, server_processing_ns, }); recent = Some(format!( "reply seq={} effective={}", seq, format_optional_ns_i128(Some(rtt.effective.as_nanos())) )); } ClientEvent::EchoLoss { seq, .. } => { recent = Some(format!("loss seq={}", seq)); } ClientEvent::DuplicateReply { seq, remote, .. } => { recent = Some(format!("duplicate seq={} from {remote}", seq)); } ClientEvent::LateReply { seq, highest_seen, rtt, .. } => { let timing = rtt .map(|sample| { format!( " effective={}", format_optional_ns_i128(Some(sample.effective.as_nanos())) ) }) .unwrap_or_default(); recent = Some(format!( "late seq={} highest_seen={}{}", seq, highest_seen, timing )); } ClientEvent::Warning { kind, message, .. } => { let warning = format!("{kind:?}: {message}"); target.last_warning = Some(warning.clone()); global_warning = Some(warning.clone()); recent = Some(format!("warning {warning}")); } } target.label.clone() }; if let Some(status) = global_status { self.status = if status == TuiStatus::Complete && !self .targets .iter() .all(|target| target.status.is_terminal()) { TuiStatus::Running } else { status }; } if let Some(warning) = global_warning { self.last_warning = Some(format!("{label}: {warning}")); } if let Some(recent) = recent { self.push_event(format!("{label}: {recent}")); } } pub(super) fn set_status(&mut self, status: TuiStatus) { self.status = status; } pub(super) fn mark_dropped_managed_events(&mut self, dropped_events: u64) { self.mark_dropped_events(dropped_events, "managed run"); } fn mark_dropped_events(&mut self, dropped_events: u64, source: &str) { if dropped_events == 0 { return; } self.dropped_events = dropped_events; let event_word = if dropped_events == 1 { "event" } else { "events" }; let warning = format!("dropped {dropped_events} {source} {event_word}; statistics may be incomplete"); self.last_warning = Some(warning.clone()); self.push_event(format!("warning {warning}")); } pub(super) fn set_error(&mut self, message: String) { self.status = TuiStatus::Error; self.last_warning = Some(message.clone()); if let Some(target) = self.targets.first_mut() { target.status = TargetStatus::Failed; target.last_warning = Some(message.clone()); } self.push_event(format!("error {message}")); } pub(super) fn set_run_error(&mut self, message: String) { self.status = TuiStatus::Error; self.last_warning = Some(message.clone()); self.push_event(format!("error {message}")); } pub(super) fn clear_visible_history(&mut self) { for target in &mut self.targets { target.graph_history.clear(); } self.graph_viewport.follow_live(); self.push_event("visible graph history reset".to_owned()); } pub(super) fn toggle_pause(&mut self) { self.paused = !self.paused; } pub(super) fn handle_key(&mut self, key: KeyCode, area: Rect) -> bool { match key { KeyCode::Char('d' | 'g') | KeyCode::Esc if key != KeyCode::Esc || self.details_open => { self.details_open = !self.details_open; self.details_scroll = (0, 0); } KeyCode::Tab | KeyCode::BackTab => { let count = self.targets.len(); if count != 0 { self.selected_target = if key == KeyCode::Tab { (self.selected_target + 1) % count } else { (self.selected_target + count - 1) % count }; self.details_scroll = (0, 0); } } KeyCode::Char('p') => self.toggle_pause(), KeyCode::Char('r') => self.clear_visible_history(), _ if self.details_open => { let text = details_text(self); let body = dashboard_layout(area, self)[2]; let inner = Block::bordered().inner(body); let (max_y, max_x) = details_scroll_limits(&text, body); let (y, x) = self.details_scroll; let (y, x) = (y.min(max_y), x.min(max_x)); self.details_scroll = match key { KeyCode::Up => (y.saturating_sub(1), x), KeyCode::Down => (y.saturating_add(1).min(max_y), x), KeyCode::PageUp => (y.saturating_sub(inner.height.max(1)), x), KeyCode::PageDown => (y.saturating_add(inner.height.max(1)).min(max_y), x), KeyCode::Home => (0, 0), KeyCode::End => (max_y, x), KeyCode::Left => (y, x.saturating_sub(8)), KeyCode::Right => (y, x.saturating_add(8).min(max_x)), _ => return false, }; } _ => { let oldest = self.oldest_graph_sample_time(); let newest = self.newest_graph_sample_time(); match key { KeyCode::Char('m') => self.graph_metric = self.graph_metric.next(), KeyCode::Left => self.graph_viewport.pan_backward(oldest, newest), KeyCode::Right => self.graph_viewport.pan_forward(newest), KeyCode::PageUp => self.graph_viewport.page_backward(oldest, newest), KeyCode::PageDown => self.graph_viewport.page_forward(newest), KeyCode::Home => self.graph_viewport.jump_oldest(oldest, newest), KeyCode::End => self.graph_viewport.follow_live(), KeyCode::Char('+' | '=') => self.graph_viewport.zoom_in(newest), KeyCode::Char('-') => self.graph_viewport.zoom_out(newest), KeyCode::Char('0') => self.graph_viewport.reset_window(newest), _ => return false, } } } true } fn push_event(&mut self, event: String) { push_bounded(&mut self.recent_events, event, RECENT_EVENT_LIMIT); } fn selected_target(&self) -> Option<&TuiTargetState> { self.targets.get(self.selected_target) } fn selected_snapshot(&self) -> Snapshot { self.selected_target() .map(|target| target.stats.snapshot()) .unwrap_or_else(|| StatsCollector::new(stats_config(true)).snapshot()) } fn oldest_graph_sample_time(&self) -> Option { self.targets .iter() .filter_map(|target| target.graph_history.front().map(|sample| sample.timestamp)) .min() } fn newest_graph_sample_time(&self) -> Option { self.targets .iter() .filter_map(|target| target.graph_history.back().map(|sample| sample.timestamp)) .max() } } #[derive(Debug)] pub(super) struct TuiTargetState { label: String, remote: Option, session: Option, status: TargetStatus, negotiated: Option, graph_history: VecDeque, last_sample: Option, last_warning: Option, stats: StatsCollector, } impl TuiTargetState { fn new(label: String, stats_config: irtt_stats::StatsConfig) -> Self { Self { label, remote: None, session: None, status: TargetStatus::Opening, negotiated: None, graph_history: VecDeque::new(), last_sample: None, last_warning: None, stats: StatsCollector::new(stats_config), } } fn push_graph_sample(&mut self, sample: GraphSample) { self.last_sample = Some(sample); push_bounded(&mut self.graph_history, sample, HISTORY_LIMIT); } } #[derive(Debug, Clone, Copy, PartialEq, Eq)] pub(super) enum TargetStatus { Opening, Active, Closed, Failed, NoTest, Stopped, Removed, Replaced, Unknown, } impl TargetStatus { fn label(self) -> &'static str { match self { Self::Opening => "opening", Self::Active => "active", Self::Closed => "closed", Self::Failed => "failed", Self::NoTest => "no-test", Self::Stopped => "stopped", Self::Removed => "removed", Self::Replaced => "replaced", Self::Unknown => "unknown", } } fn is_terminal(self) -> bool { matches!( self, Self::Closed | Self::Failed | Self::NoTest | Self::Stopped | Self::Removed | Self::Replaced ) } fn is_success(self) -> bool { matches!(self, Self::Closed | Self::NoTest) } } impl GroupPacingArg { fn label(self) -> &'static str { match self { Self::Staggered => "staggered", Self::Burst => "burst", } } } /// Statistics policy for the TUI. /// /// The TUI treats late replies as diagnostics, so it counts a matched late /// reply without letting it contribute timing samples. That is an explicit /// statistics policy rather than something the TUI expresses by degrading the /// event it forwards. fn stats_config(continuous: bool) -> irtt_stats::StatsConfig { let base = if continuous { irtt_stats::StatsConfig::continuous() } else { irtt_stats::StatsConfig::finite() }; irtt_stats::StatsConfig { late_replies: irtt_stats::LateReplyMode::CountOnly, ..base } } impl Default for TuiState { fn default() -> Self { Self::new(TuiConfig::default()) } } #[derive(Debug, Clone)] pub(super) struct TuiConfig { interval: Duration, duration: Option, timeout: Duration, target_probes: Option, pacing: GroupPacingArg, } impl TuiConfig { pub(super) fn from_args(args: &TuiArgs, setup: &ManagedRunSetup) -> Self { Self { interval: args.interval, duration: (!args.is_continuous()).then_some(args.duration), timeout: setup.client.probe_timeout, target_probes: (!args.is_continuous()) .then(|| expected_probe_count(args.duration, args.interval)), pacing: args.pacing, } } } impl Default for TuiConfig { fn default() -> Self { Self { interval: Duration::from_secs(1), duration: Some(Duration::from_secs(10)), timeout: Duration::from_secs(2), target_probes: Some(10), pacing: GroupPacingArg::Staggered, } } } #[derive(Debug, Clone, Copy, PartialEq, Eq)] pub(super) enum TuiStatus { Opening, Running, Interrupted, Closing, Complete, Error, } impl TuiStatus { fn label(self) -> &'static str { match self { Self::Opening => "opening", Self::Running => "running", Self::Interrupted => "interrupted", Self::Closing => "closing", Self::Complete => "complete", Self::Error => "error", } } } #[derive(Debug, Clone, Copy, PartialEq, Eq)] struct GraphViewport { mode: GraphViewportMode, window: Duration, } impl Default for GraphViewport { fn default() -> Self { Self { mode: GraphViewportMode::Follow, window: DEFAULT_GRAPH_WINDOW, } } } impl GraphViewport { fn range(self, now: Instant, newest_sample: Option) -> GraphViewportRange { let end = match self.mode { GraphViewportMode::Follow => newest_sample.unwrap_or(now).max(now), GraphViewportMode::Historical { end } => end, }; GraphViewportRange { start: end.checked_sub(self.window).unwrap_or(end), end, window: self.window, is_live: matches!(self.mode, GraphViewportMode::Follow), } } fn follow_live(&mut self) { self.mode = GraphViewportMode::Follow; } fn pan_backward(&mut self, oldest: Option, newest: Option) { self.pan_by(self.window / 4, true, oldest, newest); } fn pan_forward(&mut self, newest: Option) { self.pan_by(self.window / 4, false, None, newest); } fn page_backward(&mut self, oldest: Option, newest: Option) { self.pan_by(self.window, true, oldest, newest); } fn page_forward(&mut self, newest: Option) { self.pan_by(self.window, false, None, newest); } fn jump_oldest(&mut self, oldest: Option, newest: Option) { let Some(oldest) = oldest else { return; }; let live_end = newest.unwrap_or(oldest); self.mode = GraphViewportMode::Historical { end: (oldest + self.window).min(live_end), }; } fn zoom_in(&mut self, newest: Option) { self.set_window(self.window * 2 / 3, newest); } fn zoom_out(&mut self, newest: Option) { self.set_window(self.window * 3 / 2, newest); } fn reset_window(&mut self, newest: Option) { self.set_window(DEFAULT_GRAPH_WINDOW, newest); } fn set_window(&mut self, window: Duration, newest: Option) { self.window = window.clamp(MIN_GRAPH_WINDOW, MAX_GRAPH_WINDOW); if let GraphViewportMode::Historical { end } = &mut self.mode { if let Some(newest) = newest { *end = (*end).min(newest); } } } fn pan_by( &mut self, amount: Duration, backward: bool, oldest: Option, newest: Option, ) { let Some(newest) = newest else { return; }; let current_end = match self.mode { GraphViewportMode::Follow => newest, GraphViewportMode::Historical { end } => end, }; let mut end = if backward { current_end.checked_sub(amount).unwrap_or(current_end) } else { current_end + amount }; if let Some(oldest) = oldest { end = end.max(oldest + self.window); } end = end.min(newest); self.mode = GraphViewportMode::Historical { end }; } } #[derive(Debug, Clone, Copy, PartialEq, Eq)] enum GraphViewportMode { Follow, Historical { end: Instant }, } #[derive(Debug, Clone, Copy, PartialEq, Eq)] struct GraphViewportRange { start: Instant, end: Instant, window: Duration, is_live: bool, } #[derive(Debug, Clone, Copy, PartialEq, Eq)] enum GraphMetric { EffectiveRtt, RawRtt, AdjustedRtt, ClientToServer, ServerToClient, ServerProcessing, } impl GraphMetric { fn next(self) -> Self { match self { Self::EffectiveRtt => Self::RawRtt, Self::RawRtt => Self::AdjustedRtt, Self::AdjustedRtt => Self::ClientToServer, Self::ClientToServer => Self::ServerToClient, Self::ServerToClient => Self::ServerProcessing, Self::ServerProcessing => Self::EffectiveRtt, } } fn label(self) -> &'static str { match self { Self::EffectiveRtt => "effective RTT", Self::RawRtt => "raw RTT", Self::AdjustedRtt => "adjusted RTT", Self::ClientToServer => "client to server", Self::ServerToClient => "server to client", Self::ServerProcessing => "server processing", } } fn empty_message(self) -> &'static str { match self { Self::EffectiveRtt | Self::RawRtt => "waiting for primary replies", Self::AdjustedRtt => "waiting for adjusted RTT samples", Self::ClientToServer | Self::ServerToClient => "waiting for one-way delay samples", Self::ServerProcessing => "waiting for server processing samples", } } fn value_ns(self, sample: &GraphSample) -> Option { match self { Self::EffectiveRtt => Some(sample.effective_ns), Self::RawRtt => Some(sample.raw_ns), Self::AdjustedRtt => sample.adjusted_ns, Self::ClientToServer => sample.client_to_server_ns, Self::ServerToClient => sample.server_to_client_ns, Self::ServerProcessing => sample.server_processing_ns, } } fn axis_kind(self) -> ChartAxisKind { match self { Self::RawRtt | Self::ServerProcessing => ChartAxisKind::NonNegative, Self::EffectiveRtt | Self::AdjustedRtt | Self::ClientToServer | Self::ServerToClient => ChartAxisKind::Signed, } } } #[derive(Debug, Clone, Copy, PartialEq, Eq)] pub(super) struct GraphSample { timestamp: Instant, seq: u32, effective_ns: i128, raw_ns: i128, adjusted_ns: Option, client_to_server_ns: Option, server_to_client_ns: Option, server_processing_ns: Option, } fn push_bounded(items: &mut VecDeque, item: T, limit: usize) { if items.len() == limit { items.pop_front(); } else if items.len() == items.capacity() && items.capacity() >= limit / 2 { // Growth is naturally exponential; once we're within reach of `limit`, // reserve exactly up to it instead of letting the next doubling step // overshoot it for long-running, heavily populated buffers. items.reserve_exact(limit - items.len()); } items.push_back(item); } pub(super) fn draw_dashboard(frame: &mut Frame<'_>, state: &TuiState) { let area = frame.area(); if area.width < MIN_WIDTH || area.height < MIN_HEIGHT { frame.render_widget( Paragraph::new("terminal too small\nresize, or press q / Ctrl-C to quit gracefully") .block(Block::bordered().title("irtt-rs")) .wrap(Wrap { trim: true }), area, ); return; } let rows = dashboard_layout(area, state); frame.render_widget(header(state), rows[0]); frame.render_widget(target_table(state, rows[1]), rows[1]); if state.details_open { let text = details_text(state); let (max_y, max_x) = details_scroll_limits(&text, rows[2]); frame.render_widget( Paragraph::new(text) .scroll(( state.details_scroll.0.min(max_y), state.details_scroll.1.min(max_x), )) .block(Block::bordered().title("target details | arrows scroll | d graph")), rows[2], ); } else { render_graph_area(frame, rows[2], state); } frame.render_widget(status_line(state), rows[3]); } fn dashboard_layout(area: Rect, state: &TuiState) -> [Rect; 4] { let footer = 3 + u16::from(state.last_warning.is_some()); let target_rows = state.targets.len().min(6) as u16; let target_height = (target_rows + 3).min(area.height.saturating_sub(3 + footer + 7)); Layout::vertical([ Constraint::Length(3), Constraint::Length(target_height), Constraint::Min(7), Constraint::Length(footer), ]) .areas(area) } fn header(state: &TuiState) -> Paragraph<'static> { let selected = state.selected_target(); let last = selected.and_then(|target| target.last_sample); let incomplete = if state.dropped_events > 0 { format!(" incomplete:dropped={}", state.dropped_events) } else { String::new() }; Paragraph::new(vec![ Line::from(format!( "irtt-rs | {}{}{} | {} / {}", state.status.label(), if state.paused { " display paused" } else { "" }, incomplete, format_span(state.started_at.elapsed()), state .config .duration .map(format_span) .unwrap_or_else(|| "continuous".to_owned()), )), Line::from(format!( "{} | {}", selected .map(|target| target.label.as_str()) .unwrap_or("target"), selected .and_then(|target| target.remote.as_deref()) .unwrap_or(ABSENT), )), Line::from(format!( "one-way c2s {} / s2c {}", format_optional_ns_i128(last.and_then(|sample| sample.client_to_server_ns)), format_optional_ns_i128(last.and_then(|sample| sample.server_to_client_ns)), )), ]) } fn target_table(state: &TuiState, area: Rect) -> Table<'static> { let visible = usize::from(area.height.saturating_sub(3)).max(1); // Derive the page from focus and terminal height; no separate scroll state. let start = state.selected_target / visible * visible; let rows = state .targets .iter() .enumerate() .skip(start) .take(visible) .map(|(idx, target)| { let snapshot = target.stats.snapshot(); Row::new(vec![ format!( "{}{}", if idx == state.selected_target { ">" } else { " " }, target.label ), if target.status == TargetStatus::Active && target.last_sample.is_none() { "waiting".to_owned() } else { target.status.label().to_owned() }, format_optional_ns_i128(target.last_sample.map(|sample| sample.effective_ns)), if snapshot.packets.packets_sent == 0 { ABSENT.to_owned() } else { format_percent(snapshot.loss.packet_loss_percent) }, if snapshot.ipdv.round_trip.count == 0 { ABSENT.to_owned() } else { format_ns_f64(snapshot.ipdv.round_trip.stddev_ns()) }, target .last_sample .map(|sample| format_span(sample.timestamp.elapsed())) .unwrap_or_else(|| ABSENT.to_owned()), ]) .style(target_style(idx)) }); Table::new( rows, [ Constraint::Fill(1), Constraint::Length(8), Constraint::Length(9), Constraint::Length(7), Constraint::Length(9), Constraint::Length(6), ], ) .header( Row::new(["target", "state", "RTT", "loss %", "jitter σ", "age"]) .style(Style::default().add_modifier(Modifier::BOLD)), ) .block(Block::bordered().title(format!( "targets {}/{} | Tab select", state.selected_target + 1, state.targets.len() ))) } fn details_text(state: &TuiState) -> Text<'static> { let snapshot = state.selected_snapshot(); let target = state.selected_target(); let last = target.and_then(|target| target.last_sample); let packets = snapshot.packets; let mut lines = vec![ Line::from(format!( "target: {}", target.map(|target| target.label.as_str()).unwrap_or(ABSENT) )), Line::from(format!( "remote: {} session: {}", target .and_then(|target| target.remote.as_deref()) .unwrap_or(ABSENT), target .and_then(|target| target.session.as_deref()) .unwrap_or(ABSENT) )), Line::from(format!( "interval: {} timeout: {} pacing: {}", format_span(state.config.interval), format_span(state.config.timeout), state.config.pacing.label() )), Line::from(format!( "negotiated: {}", target .and_then(|target| target.negotiated.as_ref()) .map(format_negotiated) .unwrap_or_else(|| ABSENT.to_owned()) )), Line::from(format!( "target warning: {}", target .and_then(|target| target.last_warning.as_deref()) .unwrap_or(ABSENT) )), Line::from(format!( "run warning: {}", state.last_warning.as_deref().unwrap_or(ABSENT) )), Line::from(""), Line::from(format!( "sent {} received {} unique {}", packets.packets_sent, packets.packets_received, packets.unique_replies )), Line::from(format!( "lost {} ({}) duplicates {} late {} warnings {}", snapshot.loss.lost_packets, format_percent(snapshot.loss.packet_loss_percent), packets.duplicates, packets.late_packets, snapshot.events.warning_events )), Line::from(format!( "bytes sent {} received {}", packets.bytes_sent, packets.bytes_received )), Line::from(format!( "server received {} window {}", format_optional_count(packets.server_packets_received), format_optional_hex(packets.server_received_window) )), Line::from(format!( "progress: {}", state .config .target_probes .map(|count| format!( "{}/{} ({})", packets.packets_sent, count, format_percent_ratio(packets.packets_sent, count) )) .unwrap_or_else(|| "continuous".to_owned()) )), Line::from(""), Line::from(format!( "latest seq: {}", last.map(|sample| sample.seq.to_string()) .unwrap_or_else(|| ABSENT.to_owned()) )), ]; for metric in [ GraphMetric::EffectiveRtt, GraphMetric::RawRtt, GraphMetric::AdjustedRtt, GraphMetric::ClientToServer, GraphMetric::ServerToClient, GraphMetric::ServerProcessing, ] { lines.push(Line::from(format!( "{}: {}", metric.label(), format_optional_ns_i128(last.and_then(|sample| metric.value_ns(&sample))) ))); } lines.extend([ Line::from(""), Line::from(format!( "{:<18} {:>5} {:>9} {:>9} {:>9} {:>9}", "metric", "n", "min", "mean", "max", "stddev" )), ]); for (label, stats) in [ ("effective RTT", &snapshot.rtt.primary), ("raw RTT", &snapshot.rtt.raw), ("adjusted RTT", &snapshot.rtt.adjusted), ("IPDV/jitter", &snapshot.ipdv.round_trip), ("send IPDV", &snapshot.ipdv.send), ("receive IPDV", &snapshot.ipdv.receive), ("send delay", &snapshot.one_way_delay.send_delay), ("receive delay", &snapshot.one_way_delay.receive_delay), ("server process", &snapshot.server_processing.processing), ("send call", &snapshot.send_call), ("timer error", &snapshot.timer_error), ] { push_time_line(&mut lines, label, stats); } lines.extend([ Line::from(""), Line::from("recent events (all targets, newest first)"), ]); lines.extend( state .recent_events .iter() .rev() .map(|event| Line::from(event.clone())), ); Text::from(lines) } fn details_scroll_limits(text: &Text<'_>, area: Rect) -> (u16, u16) { let inner = Block::bordered().inner(area); ( text.height() .saturating_sub(usize::from(inner.height)) .min(usize::from(u16::MAX)) as u16, text.width() .saturating_sub(usize::from(inner.width)) .min(usize::from(u16::MAX)) as u16, ) } fn status_line(state: &TuiState) -> Paragraph<'static> { let mut lines = if state.details_open { vec![ Line::from("q quit | p pause | d graph | Tab target"), Line::from("↑/↓ scroll | ←/→ columns | PgUp/PgDn page"), Line::from("Home top | End bottom | r clear graph history"), ] } else { vec![ Line::from("q quit | p pause | d details | Tab target | m metric"), Line::from("←/→ pan | PgUp/PgDn page | Home oldest | End live"), Line::from("+/- zoom | 0 reset window | r clear graph history"), ] }; if let Some(warning) = &state.last_warning { lines.push(Line::styled( warning.clone(), Style::default().fg(Color::LightRed), )); } Paragraph::new(lines) } fn render_graph_area(frame: &mut Frame<'_>, area: Rect, state: &TuiState) { let viewport = state .graph_viewport .range(Instant::now(), state.newest_graph_sample_time()); let metric = state.graph_metric; let series = state .targets .iter() .enumerate() .filter_map(|(idx, target)| target_metric_series(target, idx, viewport, metric)) .collect::>(); let block = Block::bordered().title(format!( "{} | {} | window {}", metric.label(), graph_viewport_status(state), format_span(viewport.window) )); if series.is_empty() { frame.render_widget(Paragraph::new(metric.empty_message()).block(block), area); return; } let (min_y, max_y) = chart_y_bounds(&series, metric.axis_kind()); frame.render_widget( Chart::new(chart_datasets(&series)) .block(block) .x_axis( Axis::default() .bounds(viewport_x_bounds(viewport)) .labels(viewport_x_axis_labels(viewport)) .style(Style::default().fg(Color::Gray)), ) .y_axis( Axis::default() .bounds([min_y, max_y]) .labels(y_axis_labels(min_y, max_y, y_axis_label_count(area.height))) .style(Style::default().fg(Color::Gray)), ), area, ); } #[derive(Debug, Clone, PartialEq)] struct ChartSeries { name: String, style: Style, data: Vec<(f64, f64)>, } #[derive(Debug, Clone, Copy, PartialEq, Eq)] enum ChartAxisKind { NonNegative, Signed, } fn target_metric_series( target: &TuiTargetState, target_idx: usize, viewport: GraphViewportRange, metric: GraphMetric, ) -> Option { let data = target .graph_history .iter() .filter(|sample| sample.timestamp >= viewport.start && sample.timestamp <= viewport.end) .filter_map(|sample| { metric .value_ns(sample) .map(|ns| (sample_x(sample, viewport), ns as f64 / 1_000_000.0)) }) .collect::>(); (!data.is_empty()).then_some(ChartSeries { name: target.label.clone(), style: target_style(target_idx), data, }) } fn sample_x(sample: &GraphSample, viewport: GraphViewportRange) -> f64 { sample .timestamp .saturating_duration_since(viewport.start) .as_secs_f64() } fn chart_datasets(series: &[ChartSeries]) -> Vec> { series .iter() .map(|series| { Dataset::default() .name(series.name.as_str()) .marker(symbols::Marker::Braille) .graph_type(GraphType::Line) .style(series.style) .data(&series.data) }) .collect() } fn target_style(idx: usize) -> Style { const COLORS: [Color; 8] = [ Color::Cyan, Color::Yellow, Color::Green, Color::Magenta, Color::LightBlue, Color::LightRed, Color::LightGreen, Color::White, ]; Style::default().fg(COLORS[idx % COLORS.len()]) } fn chart_y_bounds(series: &[ChartSeries], axis_kind: ChartAxisKind) -> (f64, f64) { let mut values = series.iter().flat_map(|series| { series .data .iter() .map(|(_, value)| match axis_kind { ChartAxisKind::NonNegative => (*value).max(0.0), ChartAxisKind::Signed => *value, }) .filter(|value| value.is_finite()) }); let Some(first) = values.next() else { return default_y_bounds(axis_kind); }; let (mut min_y, mut max_y) = (first, first); for value in values { min_y = min_y.min(value); max_y = max_y.max(value); } match axis_kind { ChartAxisKind::NonNegative => padded_non_negative_chart_y_bounds(min_y, max_y), ChartAxisKind::Signed => padded_signed_chart_y_bounds(min_y, max_y), } } fn default_y_bounds(axis_kind: ChartAxisKind) -> (f64, f64) { match axis_kind { ChartAxisKind::NonNegative => (0.0, 10.0), ChartAxisKind::Signed => (-1.0, 1.0), } } fn padded_non_negative_chart_y_bounds(min_y: f64, max_y: f64) -> (f64, f64) { let pad = chart_y_padding(min_y, max_y); let lower = (min_y - pad).max(0.0); let upper = max_y + pad; if lower < upper { (lower, upper) } else { (0.0, pad.max(1.0)) } } fn padded_signed_chart_y_bounds(mut min_y: f64, mut max_y: f64) -> (f64, f64) { let pad = chart_y_padding(min_y, max_y); min_y -= pad; max_y += pad; if min_y >= max_y { (min_y - pad, max_y + pad) } else { (min_y, max_y) } } fn chart_y_padding(min_y: f64, max_y: f64) -> f64 { const MIN_PADDING_MS: f64 = 0.1; let span = max_y - min_y; if span <= f64::EPSILON { (max_y.abs() * 0.1).max(MIN_PADDING_MS) } else { (span * 0.1).max(MIN_PADDING_MS) } } fn viewport_x_bounds(viewport: GraphViewportRange) -> [f64; 2] { [0.0, viewport.window.as_secs_f64().max(1.0)] } fn viewport_x_axis_labels(viewport: GraphViewportRange) -> Vec> { vec![ Span::raw(format!("-{}", format_span(viewport.window))), Span::raw(if viewport.is_live { "live" } else { "end" }), ] } fn y_axis_label_count(height: u16) -> usize { let inner = height.saturating_sub(2); if inner >= 14 { 7 } else if inner >= 9 { 5 } else if inner >= 5 { 3 } else { 2 } } fn y_axis_labels(min_y: f64, max_y: f64, label_count: usize) -> Vec> { let label_count = label_count.max(2); let step = (max_y - min_y) / (label_count - 1) as f64; (0..label_count) .map(|idx| Span::raw(format_axis_time_ms(min_y + step * idx as f64))) .collect() } fn format_axis_time_ms(value_ms: f64) -> String { let value_ms = if value_ms.abs() < 0.000_5 { 0.0 } else { value_ms }; let sign = if value_ms < 0.0 { "-" } else { "" }; let abs_ms = value_ms.abs(); if abs_ms < 1.0 { let us = abs_ms * 1_000.0; if us < 10.0 { format!("{sign}{us:.1}µs") } else { format!("{sign}{us:.0}µs") } } else if abs_ms < 1_000.0 { if abs_ms < 10.0 { format!("{sign}{abs_ms:.2}ms") } else if abs_ms < 100.0 { format!("{sign}{abs_ms:.1}ms") } else { format!("{sign}{abs_ms:.0}ms") } } else { let secs = abs_ms / 1_000.0; if secs < 10.0 { format!("{sign}{secs:.2}s") } else if secs < 100.0 { format!("{sign}{secs:.1}s") } else { format!("{sign}{secs:.0}s") } } } fn graph_viewport_status(state: &TuiState) -> String { match state.graph_viewport.mode { GraphViewportMode::Follow => "live".to_owned(), GraphViewportMode::Historical { end } => { let now = Instant::now(); let live_end = state.newest_graph_sample_time().unwrap_or(now).max(now); format!( "history -{}", format_span(live_end.saturating_duration_since(end)) ) } } } fn push_time_line(lines: &mut Vec>, label: &str, stats: &TimeStats) { if stats.count == 0 { lines.push(Line::from(format!( "{label:<18} {:>5} {:>9} {:>9} {:>9} {:>9}", 0, "-", "-", "-", "-" ))); return; } lines.push(Line::from(format!( "{label:<18} {:>5} {:>9} {:>9} {:>9} {:>9}", format_count(stats.count), format_optional_ns_i128(stats.min_ns), format_ns_f64(stats.mean_ns), format_optional_ns_i128(stats.max_ns), format_ns_f64(stats.stddev_ns()) ))); } fn format_negotiated(negotiated: &NegotiationResult) -> String { let params = &negotiated.accepted; let duration = params.duration.map_or_else( || "-".to_owned(), |duration| format_optional_ns_i128(Some(duration.as_nanos() as i128)), ); let changes = if negotiated.changes.is_empty() { "none".to_owned() } else { negotiated.changes.len().to_string() }; format!( "duration={} interval={} length={} clock={:?} timestamps={:?} stats={:?} changes={}", duration, format_optional_ns_i128(Some(params.interval.as_nanos() as i128)), params.length, params.clock, params.stamp_at, params.received_stats, changes ) } /// Format a wall-clock span shown in the TUI's headers, config lines, and /// graph window labels. /// /// Spans are read at a glance rather than compared numerically, so they are /// deliberately coarser than the shared scalar policy: a zero span is `0s`, /// whole seconds carry one decimal, and a span of a minute or more is written /// as minutes and seconds. Below one second there is nothing to gain from a /// second spelling, so the shared scalar vocabulary is used directly. fn format_span(value: Duration) -> String { if value.is_zero() { return "0s".to_owned(); } let nanos = value.as_nanos(); if nanos < 1_000_000_000 { return format_duration(value); } let secs = value.as_secs(); if nanos < 60_000_000_000 { format!("{:.1}s", nanos as f64 / 1_000_000_000.0) } else { format!("{}m{:02}s", secs / 60, secs % 60) } } fn format_optional_hex(value: Option) -> String { value .map(|value| format!("0x{value:x}")) .unwrap_or_else(|| ABSENT.to_owned()) } fn duration_ns(value: Duration) -> i128 { i128::try_from(value.as_nanos()).unwrap_or(i128::MAX) } #[cfg(test)] mod tests { use super::*; use irtt_client::{ClientTimestamp, OneWayDelaySample, PacketMeta, RttSample, ServerTiming}; use ratatui::backend::TestBackend; use std::time::SystemTime; fn measure(state: &mut TuiState, label: &str, seq: u32, at: Instant, effective_ns: i128) { let target = TargetInstance { id: label.into(), generation: 1, }; let received_at = ClientTimestamp { mono: at, wall: SystemTime::UNIX_EPOCH + Duration::from_secs(1), }; let sent_at = ClientTimestamp { mono: at - Duration::from_millis(10), wall: received_at.wall - Duration::from_millis(10), }; let remote = "127.0.0.1:2112".parse().unwrap(); state.process_target_event( &target, &ClientEvent::EchoSent { seq, remote, scheduled_at: None, sent_at, bytes: 64, send_call: Duration::ZERO, timer_error: None, }, ); state.process_target_event( &target, &ClientEvent::EchoReply { seq, remote, sent_at, received_at, rtt: RttSample { raw: Duration::from_millis(10), adjusted: Some(SignedDuration::from_nanos(effective_ns)), effective: SignedDuration::from_nanos(effective_ns), }, server_timing: Some(ServerTiming { receive_wall_ns: None, receive_mono_ns: None, send_wall_ns: None, send_mono_ns: None, midpoint_wall_ns: None, midpoint_mono_ns: None, processing: Some(Duration::from_nanos((10_000_000 - effective_ns) as u64)), }), one_way: Some(OneWayDelaySample { client_to_server: Some(SignedDuration::from_nanos(-3_000_000)), server_to_client: Some(SignedDuration::from_nanos(effective_ns + 3_000_000)), }), received_stats: None, bytes: 64, packet_meta: PacketMeta::default(), }, ); } fn screen(state: &TuiState, width: u16, height: u16) -> String { let mut terminal = Terminal::new(TestBackend::new(width, height)).unwrap(); terminal.draw(|frame| draw_dashboard(frame, state)).unwrap(); terminal .backend() .buffer() .content() .chunks(usize::from(width)) .map(|row| row.iter().map(|cell| cell.symbol()).collect::()) .collect::>() .join("\n") } #[test] fn target_focus_and_details_remain_usable_across_sizes() { let mut state = TuiState::with_target_labels( TuiConfig::default(), (0..8).map(|i| format!("target{i}")), ); let now = Instant::now(); measure( &mut state, "target7", 0, now - Duration::from_secs(5), 8_000_000, ); measure(&mut state, "target7", 1, now, 5_000_000); for (width, height) in [(56, 18), (80, 24), (120, 40)] { let area = Rect::new(0, 0, width, height); state.handle_key(KeyCode::Home, area); state.handle_key(KeyCode::BackTab, area); let text = screen(&state, width, height); assert!(text.contains(">target7"), "{text}"); assert!( text.contains("jitter σ") && text.contains("loss %"), "{text}" ); assert!( text.contains(&format_optional_ns_i128(Some(-3_000_000))) && text.contains(&format_optional_ns_i128(Some(5_000_000))), "{text}" ); state.handle_key(KeyCode::Char('d'), area); assert!(screen(&state, width, height).contains("target: target7")); state.handle_key(KeyCode::End, area); assert!(screen(&state, width, height).contains("target7: reply")); state.handle_key(KeyCode::Tab, area); // Selecting another target resets detail scrolling and exposes its own data. assert!(screen(&state, width, height).contains("target: target0")); state.handle_key(KeyCode::Esc, area); } assert!(screen(&state, 40, 10).contains("terminal too small")); } #[test] fn history_reset_keeps_latest_summary_and_statistics_while_paused() { let mut state = TuiState::default(); let area = Rect::new(0, 0, 100, 40); state.handle_key(KeyCode::Char('p'), area); measure(&mut state, "target", 0, Instant::now(), 8_000_000); state.handle_key(KeyCode::Char('r'), area); let text = screen(&state, 100, 40); assert!( text.contains(&format_optional_ns_i128(Some(8_000_000))) && text.contains("display paused") ); assert!(text.contains("waiting for primary replies")); state.handle_key(KeyCode::Char('d'), area); let text = screen(&state, 100, 40); assert!(text.contains("sent 1 received 1 unique 1")); assert!(text.contains(&format!( "effective RTT: {}", format_optional_ns_i128(Some(8_000_000)) ))); state.handle_key(KeyCode::Esc, area); measure(&mut state, "target", 1, Instant::now(), 5_000_000); assert!(!screen(&state, 100, 40).contains("waiting for primary replies")); } #[test] fn graph_controls_preserve_signed_metrics_and_history_range() { let mut state = TuiState::with_target_labels(TuiConfig::default(), ["a".to_owned(), "b".to_owned()]); let area = Rect::new(0, 0, 100, 40); let now = Instant::now(); for label in ["a", "b"] { measure( &mut state, label, 0, now - Duration::from_secs(120), -2_000_000, ); measure( &mut state, label, 1, now - Duration::from_secs(60), -2_000_000, ); measure(&mut state, label, 2, now, -2_000_000); } state.handle_key(KeyCode::Home, area); let viewport = state .graph_viewport .range(now, state.newest_graph_sample_time()); assert_eq!(viewport.start, now - Duration::from_secs(120)); assert_eq!(viewport.end, now - Duration::from_secs(60)); for metric in [ GraphMetric::EffectiveRtt, GraphMetric::RawRtt, GraphMetric::AdjustedRtt, GraphMetric::ClientToServer, GraphMetric::ServerToClient, GraphMetric::ServerProcessing, ] { assert_eq!(state.graph_metric, metric); let series = state .targets .iter() .enumerate() .map(|(i, target)| target_metric_series(target, i, viewport, metric).unwrap()) .collect::>(); // Both endpoints belong to the viewport, for both targets. assert!(series.iter().all(|series| series.data.len() == 2)); if matches!( metric, GraphMetric::EffectiveRtt | GraphMetric::AdjustedRtt | GraphMetric::ClientToServer ) { assert!(series .iter() .all(|series| series.data.iter().all(|(_, y)| *y < 0.0))); assert!(chart_y_bounds(&series, metric.axis_kind()).0 < 0.0); } state.handle_key(KeyCode::Char('m'), area); } state.handle_key(KeyCode::PageDown, area); assert_eq!(state.graph_viewport.range(now, Some(now)).end, now); state.handle_key(KeyCode::Left, area); assert_eq!( state.graph_viewport.range(now, Some(now)).end, now - Duration::from_secs(15) ); state.handle_key(KeyCode::Right, area); state.handle_key(KeyCode::PageUp, area); assert_eq!( state.graph_viewport.range(now, Some(now)).end, now - Duration::from_secs(60) ); state.handle_key(KeyCode::Char('+'), area); assert_eq!(state.graph_viewport.window, Duration::from_secs(40)); state.handle_key(KeyCode::Char('-'), area); assert_eq!(state.graph_viewport.window, DEFAULT_GRAPH_WINDOW); state.handle_key(KeyCode::Char('0'), area); assert!(!state.graph_viewport.range(now, Some(now)).is_live); state.handle_key(KeyCode::End, area); assert!(state.graph_viewport.range(now, Some(now)).is_live); } }