use std::{ collections::{BTreeMap, VecDeque}, io::{self, Stdout}, time::{Duration, Instant}, }; use crossterm::{ cursor::Show, execute, terminal::{disable_raw_mode, enable_raw_mode, EnterAlternateScreen, LeaveAlternateScreen}, }; use irtt_client::managed::{ManagedTargetEndReason, ManagedTargetOutcome, TargetInstance}; use irtt_client::{ClientEvent, NegotiatedParams, SignedDuration}; use irtt_stats::{Snapshot, StatsCollector, TimeStats}; use ratatui::{ backend::CrosstermBackend, layout::{Constraint, Direction, Layout, Rect}, style::{Color, Modifier, Style}, symbols, text::{Line, Span}, widgets::{Axis, Block, Borders, Chart, Dataset, GraphType, Paragraph, 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); const PAN_STEP_NUMERATOR: u32 = 1; const PAN_STEP_DENOMINATOR: u32 = 4; 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, view: TuiView, pub(super) paused: bool, pub(super) quit_requested: 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(), view: TuiView::Graph, paused: false, quit_requested: 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(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 { remote, token, 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 { remote, 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}", format_seq(*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, }); target.last_sample = Some(LastSample { seq: *seq, raw_ns: duration_ns(rtt.raw), adjusted_ns: rtt.adjusted.map(SignedDuration::as_nanos), effective_ns: rtt.effective.as_nanos(), client_to_server_ns, server_to_client_ns, server_processing_ns, }); recent = Some(format!( "reply seq={} effective={}", format_seq(*seq), format_optional_ns_i128(Some(rtt.effective.as_nanos())) )); } ClientEvent::EchoLoss { seq, .. } => { recent = Some(format!("loss seq={}", format_seq(*seq))); } ClientEvent::DuplicateReply { seq, remote, .. } => { recent = Some(format!("duplicate seq={} from {remote}", format_seq(*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={}{}", format_seq(*seq), format_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.is_multi_target() && !self .targets .iter() .all(|target| target.status.is_terminal()) { TuiStatus::Running } else { status }; } if let Some(warning) = global_warning { self.last_warning = Some(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 cycle_graph_metric(&mut self) { self.graph_metric = self.graph_metric.next(); } pub(super) fn toggle_view(&mut self) { self.view = self.view.next(); } pub(super) fn pan_graph_left(&mut self) { let oldest = self.oldest_graph_sample_time(); let newest = self.newest_graph_sample_time(); self.graph_viewport.pan_backward(oldest, newest); } pub(super) fn pan_graph_right(&mut self) { let newest = self.newest_graph_sample_time(); self.graph_viewport.pan_forward(newest); } pub(super) fn pan_graph_page_left(&mut self) { let oldest = self.oldest_graph_sample_time(); let newest = self.newest_graph_sample_time(); self.graph_viewport.page_backward(oldest, newest); } pub(super) fn pan_graph_page_right(&mut self) { let newest = self.newest_graph_sample_time(); self.graph_viewport.page_forward(newest); } pub(super) fn jump_graph_oldest(&mut self) { let oldest = self.oldest_graph_sample_time(); let newest = self.newest_graph_sample_time(); self.graph_viewport.jump_oldest(oldest, newest); } pub(super) fn jump_graph_live(&mut self) { self.graph_viewport.follow_live(); } pub(super) fn zoom_graph_in(&mut self) { self.graph_viewport.zoom_in(self.newest_graph_sample_time()); } pub(super) fn zoom_graph_out(&mut self) { self.graph_viewport .zoom_out(self.newest_graph_sample_time()); } pub(super) fn reset_graph_window(&mut self) { self.graph_viewport .reset_window(self.newest_graph_sample_time()); } 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.first() } fn selected_snapshot(&self) -> Snapshot { self.selected_target() .map(|target| target.stats.snapshot()) .unwrap_or_else(|| StatsCollector::new(stats_config(true)).snapshot()) } fn is_multi_target(&self) -> bool { self.targets.len() > 1 } 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) { 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)] enum TuiView { Graph, Dashboard, } impl TuiView { fn next(self) -> Self { match self { Self::Graph => Self::Dashboard, Self::Dashboard => Self::Graph, } } } #[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( -duration_fraction(self.window, PAN_STEP_NUMERATOR, PAN_STEP_DENOMINATOR), oldest, newest, ); } fn pan_forward(&mut self, newest: Option) { self.pan_by( duration_fraction(self.window, PAN_STEP_NUMERATOR, PAN_STEP_DENOMINATOR), None, newest, ); } fn page_backward(&mut self, oldest: Option, newest: Option) { self.pan_by(-signed_duration(self.window), oldest, newest); } fn page_forward(&mut self, newest: Option) { self.pan_by(signed_duration(self.window), 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(duration_fraction(self.window, 2, 3).duration, newest); } fn zoom_out(&mut self, newest: Option) { self.set_window(duration_fraction(self.window, 3, 2).duration, 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, delta: SignedViewportDuration, 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 = delta.apply(current_end); 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)] struct SignedViewportDuration { duration: Duration, negative: bool, } impl SignedViewportDuration { fn apply(self, instant: Instant) -> Instant { if self.negative { instant.checked_sub(self.duration).unwrap_or(instant) } else { instant + self.duration } } } impl std::ops::Neg for SignedViewportDuration { type Output = Self; fn neg(self) -> Self::Output { Self { duration: self.duration, negative: !self.negative, } } } fn signed_duration(duration: Duration) -> SignedViewportDuration { SignedViewportDuration { duration, negative: false, } } fn duration_fraction( duration: Duration, numerator: u32, denominator: u32, ) -> SignedViewportDuration { let nanos = duration.as_nanos().saturating_mul(u128::from(numerator)) / u128::from(denominator.max(1)); let nanos = nanos.max(1).min(u128::from(u64::MAX)); SignedViewportDuration { duration: Duration::from_nanos(nanos as u64), negative: false, } } #[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 title(self) -> &'static str { match self { Self::EffectiveRtt => "effective RTT", Self::RawRtt => "raw RTT", Self::AdjustedRtt => "adjusted RTT", Self::ClientToServer => "client to server delay", Self::ServerToClient => "server to client delay", 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::EffectiveRtt | Self::RawRtt | Self::ServerProcessing => { ChartAxisKind::NonNegative } Self::AdjustedRtt | Self::ClientToServer | Self::ServerToClient => { ChartAxisKind::Signed } } } fn style(self) -> Style { match self { Self::EffectiveRtt | Self::RawRtt | Self::AdjustedRtt => { target_style(0).add_modifier(Modifier::BOLD) } Self::ClientToServer => Style::default().fg(Color::Magenta), Self::ServerToClient => Style::default().fg(Color::LightBlue), Self::ServerProcessing => Style::default().fg(Color::Green), } } } #[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, } #[derive(Debug, Clone, Copy, PartialEq, Eq)] pub(super) struct LastSample { seq: u32, raw_ns: i128, adjusted_ns: Option, effective_ns: i128, 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(too_small(), area); return; } let snapshot = state.selected_snapshot(); match state.view { TuiView::Graph => draw_graph_view(frame, area, state, &snapshot), TuiView::Dashboard => draw_dashboard_view(frame, area, state, &snapshot), } } pub(super) fn should_render(now: Instant, next_render: Instant, paused: bool, force: bool) -> bool { force || (!paused && now >= next_render) } fn draw_graph_view(frame: &mut Frame<'_>, area: Rect, state: &TuiState, snapshot: &Snapshot) { let header_height = if state.is_multi_target() { 7 } else { 3 }; let rows = Layout::default() .direction(Direction::Vertical) .constraints([ Constraint::Length(header_height), Constraint::Min(10), Constraint::Length(3), ]) .split(area); if state.is_multi_target() { frame.render_widget(target_table_panel(state, rows[0].height), rows[0]); } else { frame.render_widget(graph_summary_panel(state, snapshot), rows[0]); } render_graph_area(frame, rows[1], state); frame.render_widget(status_line(state), rows[2]); } fn draw_dashboard_view(frame: &mut Frame<'_>, area: Rect, state: &TuiState, snapshot: &Snapshot) { if area.width >= 110 && area.height >= 32 { draw_large(frame, area, state, snapshot); } else { draw_compact(frame, area, state, snapshot); } } fn draw_large(frame: &mut Frame<'_>, area: Rect, state: &TuiState, snapshot: &Snapshot) { let rows = Layout::default() .direction(Direction::Vertical) .constraints([ Constraint::Length(8), Constraint::Min(12), Constraint::Length(9), Constraint::Length(3), ]) .split(area); let top = Layout::default() .direction(Direction::Horizontal) .constraints([Constraint::Percentage(40), Constraint::Percentage(60)]) .split(rows[0]); let middle = Layout::default() .direction(Direction::Horizontal) .constraints([Constraint::Percentage(36), Constraint::Percentage(64)]) .split(rows[1]); let bottom = Layout::default() .direction(Direction::Horizontal) .constraints([Constraint::Percentage(55), Constraint::Percentage(45)]) .split(rows[2]); frame.render_widget(header(state, HeaderDensity::Large), top[0]); frame.render_widget(packet_panel(state, snapshot, top[1].height), top[1]); frame.render_widget(timing_panel(state, snapshot), middle[0]); render_graph_area(frame, middle[1], state); frame.render_widget(recent_events_panel(state, bottom[0].height), bottom[0]); frame.render_widget(sample_panel(state, snapshot), bottom[1]); frame.render_widget(status_line(state), rows[3]); } fn draw_compact(frame: &mut Frame<'_>, area: Rect, state: &TuiState, snapshot: &Snapshot) { let rows = Layout::default() .direction(Direction::Vertical) .constraints([ Constraint::Length(6), Constraint::Length(7), Constraint::Min(7), Constraint::Length(3), ]) .split(area); frame.render_widget(header(state, HeaderDensity::Compact), rows[0]); frame.render_widget(packet_panel(state, snapshot, rows[1].height), rows[1]); render_graph_area(frame, rows[2], state); frame.render_widget(status_line(state), rows[3]); } fn too_small() -> Paragraph<'static> { Paragraph::new(vec![ Line::from("terminal too small"), Line::from("resize, or press q / Ctrl-C to quit gracefully"), ]) .block(Block::default().title("irtt-rs").borders(Borders::ALL)) .wrap(Wrap { trim: true }) } #[derive(Debug, Clone, Copy, PartialEq, Eq)] enum HeaderDensity { Large, Compact, } fn header(state: &TuiState, density: HeaderDensity) -> Paragraph<'_> { let selected = state.selected_target(); let selected_label = selected .map(|target| target.label.as_str()) .unwrap_or("target"); let remote = selected .and_then(|target| target.remote.as_deref()) .unwrap_or("-"); let session = selected .and_then(|target| target.session.as_deref()) .unwrap_or("-"); let elapsed = format_span(state.started_at.elapsed()); let duration = format_optional_span(state.config.duration); let mode = if state.config.duration.is_some() { "finite" } else { "continuous" }; let negotiated = selected .and_then(|target| target.negotiated.as_ref()) .map(|params| format!("negotiated: {}", format_negotiated(params))) .unwrap_or_else(|| "negotiated: -".to_owned()); let target_count = state.targets.len(); let pacing = state.config.pacing.label(); let mut lines = vec![ Line::from(vec![ Span::styled("irtt-rs", Style::default().add_modifier(Modifier::BOLD)), Span::raw(format!( " status: {} targets: {target_count} pacing: {pacing}", state.status.label() )), ]), Line::from(if state.is_multi_target() { format!("first target: {selected_label} remote: {remote}") } else { format!("remote: {remote}") }), ]; match density { HeaderDensity::Large => { lines.extend([ Line::from(format!("session: {session}")), Line::from(format!( "mode: {mode} elapsed: {elapsed} duration: {duration}" )), Line::from(format!( "interval: {} timeout: {}", format_span(state.config.interval), format_span(state.config.timeout) )), Line::from(negotiated), ]); } HeaderDensity::Compact => { lines.extend([ Line::from(format!("session: {session}")), Line::from(format!( "{mode} elapsed: {elapsed} interval: {}", format_span(state.config.interval) )), ]); } } Paragraph::new(lines) .block(Block::default().title("session").borders(Borders::ALL)) .wrap(Wrap { trim: true }) } fn graph_summary_panel(state: &TuiState, snapshot: &Snapshot) -> Paragraph<'static> { let selected = state.selected_target(); let selected_label = selected .map(|target| target.label.as_str()) .unwrap_or("target"); let remote = selected .and_then(|target| target.remote.as_deref()) .unwrap_or("-"); let elapsed = format_span(state.started_at.elapsed()); let packets = snapshot.packets; let last = state .selected_target() .and_then(|target| target.last_sample) .map(|sample| format_optional_ns_i128(Some(sample.effective_ns))) .unwrap_or_else(|| "-".to_owned()); let target_context = if state.is_multi_target() { format!("first target {selected_label} remote {remote}") } else { format!("remote {remote}") }; Paragraph::new(Line::from(format!( "irtt-rs {} {target_context} elapsed {elapsed} sent {} replies {} last {last}", state.status.label(), format_count(packets.packets_sent), format_count(packets.unique_replies) ))) .block( Block::default() .title(if state.is_multi_target() { "session - first target" } else { "session" }) .borders(Borders::ALL), ) } fn packet_panel(state: &TuiState, snapshot: &Snapshot, panel_height: u16) -> Paragraph<'static> { if state.is_multi_target() { return target_table_panel(state, panel_height); } let packets = snapshot.packets; let loss = snapshot.loss; let progress = state .config .target_probes .map(|target| { format!( "{}/{} ({})", packets.packets_sent, target, format_percent_ratio(packets.packets_sent, target) ) }) .unwrap_or_else(|| "-".to_owned()); let elapsed = state.started_at.elapsed(); let recv_rate = format_rate(packets.unique_replies, elapsed); Paragraph::new(vec![ Line::from(format!( "sent {:>8} received {:>8} unique {:>8}", format_count(packets.packets_sent), format_count(packets.packets_received), format_count(packets.unique_replies) )), Line::from(format!( "lost {:>8} duplicates {:>6} late {:>10} warnings {:>6}", format_count(loss.lost_packets), format_count(packets.duplicates), format_count(packets.late_packets), format_count(snapshot.events.warning_events) )), Line::from(format!( "loss {:>8} reply rate {:>10} progress {progress}", format_percent(loss.packet_loss_percent), recv_rate )), Line::from(format!( "bytes sent {} received {}", format_count(packets.bytes_sent), format_count(packets.bytes_received) )), Line::from(format!( "server received {} window {}", format_optional_count(packets.server_packets_received), format_optional_hex(packets.server_received_window) )), ]) .block(Block::default().title("packets").borders(Borders::ALL)) .wrap(Wrap { trim: true }) } fn target_table_panel(state: &TuiState, panel_height: u16) -> Paragraph<'static> { let visible = usize::from(panel_height.saturating_sub(3)); let mut lines = vec![Line::from(format!( "{:<16} {:<8} {:>9} {:>5} {:>4} {:>4} {:>4}", "target", "status", "last", "loss", "dup", "late", "warn" ))]; for (idx, target) in state.targets.iter().enumerate().take(visible) { let snapshot = target.stats.snapshot(); let last = target .last_sample .map(|sample| format_optional_ns_i128(Some(sample.effective_ns))) .unwrap_or_else(|| "-".to_owned()); lines.push(Line::from(vec![ target_label_span(&target.label, idx, 16), Span::raw(format!( " {:<8} {:>9} {:>5} {:>4} {:>4} {:>4}", target.status.label(), last, format_count(snapshot.loss.lost_packets), format_count(snapshot.packets.duplicates), format_count(snapshot.packets.late_packets), format_count(snapshot.events.warning_events) )), ])); } Paragraph::new(lines) .block(Block::default().title("targets").borders(Borders::ALL)) .wrap(Wrap { trim: false }) } fn target_label_span(label: &str, target_idx: usize, width: usize) -> Span<'static> { Span::styled( format!("{: Paragraph<'static> { let mut lines = vec![Line::from(format!( "{:<18} {:>5} {:>9} {:>9} {:>9} {:>9}", "metric", "n", "min", "mean", "max", "stddev" ))]; push_time_line(&mut lines, "effective RTT", &snapshot.rtt.primary); push_time_line(&mut lines, "raw RTT", &snapshot.rtt.raw); push_time_line(&mut lines, "adjusted RTT", &snapshot.rtt.adjusted); push_time_line(&mut lines, "IPDV/jitter", &snapshot.ipdv.round_trip); push_time_line(&mut lines, "send IPDV", &snapshot.ipdv.send); push_time_line(&mut lines, "receive IPDV", &snapshot.ipdv.receive); push_time_line(&mut lines, "send delay", &snapshot.one_way_delay.send_delay); push_time_line( &mut lines, "receive delay", &snapshot.one_way_delay.receive_delay, ); push_time_line( &mut lines, "server process", &snapshot.server_processing.processing, ); push_time_line(&mut lines, "send call", &snapshot.send_call); push_time_line(&mut lines, "timer error", &snapshot.timer_error); Paragraph::new(lines) .block( Block::default() .title(if state.is_multi_target() { "timing - first target" } else { "timing" }) .borders(Borders::ALL), ) .wrap(Wrap { trim: false }) } fn render_graph_area(frame: &mut Frame<'_>, area: Rect, state: &TuiState) { let viewport = state .graph_viewport .range(Instant::now(), state.newest_graph_sample_time()); if state.is_multi_target() { render_multi_target_graph(frame, area, state, viewport); return; } let history = state .selected_target() .map(|target| &target.graph_history) .expect("TuiState always has at least one target"); let visible = visible_history_window(history, viewport); let series = graph_series(state.graph_metric, &visible, viewport); render_chart( frame, area, &visible, &series, ChartRenderConfig { metric: state.graph_metric, context: graph_context(state, viewport), viewport, }, ); } fn render_multi_target_graph( frame: &mut Frame<'_>, area: Rect, state: &TuiState, viewport: GraphViewportRange, ) { let metric = state.graph_metric; let series = state .targets .iter() .enumerate() .filter_map(|(idx, target)| target_metric_series(target, idx, viewport, metric)) .collect::>(); if series.is_empty() { frame.render_widget( Paragraph::new(metric.empty_message()) .block( Block::default() .title(graph_chart_title(metric, &graph_context(state, viewport))) .borders(Borders::ALL), ) .wrap(Wrap { trim: true }), area, ); return; } let (min_y, max_y) = chart_y_bounds(&series, metric.axis_kind()); let datasets = chart_datasets(&series); let chart = Chart::new(datasets) .block( Block::default() .title(graph_chart_title(metric, &graph_context(state, viewport))) .borders(Borders::ALL), ) .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)), ); frame.render_widget(chart, area); } fn render_chart( frame: &mut Frame<'_>, area: Rect, _visible: &[&GraphSample], series: &[ChartSeries], config: ChartRenderConfig, ) { if series.is_empty() { frame.render_widget( Paragraph::new(config.metric.empty_message()) .block( Block::default() .title(graph_chart_title(config.metric, &config.context)) .borders(Borders::ALL), ) .wrap(Wrap { trim: true }), area, ); return; } let (min_y, max_y) = chart_y_bounds(series, config.metric.axis_kind()); let datasets = chart_datasets(series); let chart = Chart::new(datasets) .block( Block::default() .title(graph_chart_title(config.metric, &config.context)) .borders(Borders::ALL), ) .x_axis( Axis::default() .bounds(viewport_x_bounds(config.viewport)) .labels(viewport_x_axis_labels(config.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)), ); frame.render_widget(chart, area); } #[derive(Debug, Clone)] struct ChartRenderConfig { metric: GraphMetric, context: String, viewport: GraphViewportRange, } #[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 visible_history_window( history: &VecDeque, viewport: GraphViewportRange, ) -> Vec<&GraphSample> { history .iter() .filter(|sample| sample.timestamp >= viewport.start && sample.timestamp <= viewport.end) .collect() } fn graph_series( metric: GraphMetric, visible: &[&GraphSample], viewport: GraphViewportRange, ) -> Vec { chart_series(metric, visible, viewport) .into_iter() .collect() } fn chart_series( metric: GraphMetric, visible: &[&GraphSample], viewport: GraphViewportRange, ) -> Option { let data: Vec<(f64, f64)> = visible .iter() .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: metric.label().to_owned(), style: metric.style(), data, }) } 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 graph_context(state: &TuiState, viewport: GraphViewportRange) -> String { format!( "{} | window {}", graph_viewport_status(state), format_span(viewport.window) ) } fn graph_chart_title(metric: GraphMetric, context: &str) -> String { format!("{} | {context}", metric.title()) } 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 recent_events_panel(state: &TuiState, panel_height: u16) -> Paragraph<'_> { let lines: Vec> = state .recent_events .iter() .rev() .take(recent_events_visible_count(panel_height)) .map(|event| Line::from(event.as_str())) .collect(); Paragraph::new(lines) .block( Block::default() .title("recent events") .borders(Borders::ALL), ) .wrap(Wrap { trim: true }) } fn recent_events_visible_count(panel_height: u16) -> usize { usize::from(panel_height.saturating_sub(2)) } fn sample_panel(state: &TuiState, snapshot: &Snapshot) -> Paragraph<'static> { let selected = state.selected_target(); let last = selected.and_then(|target| target.last_sample); let warning = selected .and_then(|target| target.last_warning.clone()) .or_else(|| state.last_warning.clone()) .unwrap_or_else(|| "-".to_owned()); Paragraph::new(vec![ Line::from(format!( "last seq: {}", last.map(|sample| format_seq(sample.seq)) .unwrap_or_else(|| "-".to_owned()) )), Line::from(format!( "raw / adjusted / effective: {} / {} / {}", last.map(|sample| format_optional_ns_i128(Some(sample.raw_ns))) .unwrap_or_else(|| "-".to_owned()), last.map(|sample| format_optional_ns_i128(sample.adjusted_ns)) .unwrap_or_else(|| "-".to_owned()), last.map(|sample| format_optional_ns_i128(Some(sample.effective_ns))) .unwrap_or_else(|| "-".to_owned()) )), Line::from(format!( "one-way c2s / s2c: {} / {}", last.map(|sample| format_optional_ns_i128(sample.client_to_server_ns)) .unwrap_or_else(|| "-".to_owned()), last.map(|sample| format_optional_ns_i128(sample.server_to_client_ns)) .unwrap_or_else(|| "-".to_owned()) )), Line::from(format!( "server processing: {}", last.map(|sample| format_optional_ns_i128(sample.server_processing_ns)) .unwrap_or_else(|| "-".to_owned()) )), Line::from(format!( "events sent={} replies={} losses={} warnings={}", format_count(snapshot.events.sent_events), format_count(snapshot.events.echo_replies), format_count(snapshot.events.loss_events), format_count(snapshot.events.warning_events) )), Line::from(format!("last warning: {warning}")), ]) .block( Block::default() .title(if state.is_multi_target() { "sample - first target" } else { "sample" }) .borders(Borders::ALL), ) .wrap(Wrap { trim: true }) } fn status_line(state: &TuiState) -> Paragraph<'_> { let paused = if state.paused { " display paused" } else { "" }; let quitting = if state.quit_requested { " quit requested" } else { "" }; let view_hint = match state.view { TuiView::Graph => "g dashboard", TuiView::Dashboard => "g graph", }; let incomplete = if state.dropped_events > 0 { format!(" incomplete:dropped={}", state.dropped_events) } else { String::new() }; let controls = if state.graph_viewport.mode == GraphViewportMode::Follow { format!( "q quit | r reset | p pause | {view_hint} | m metric | arrows pan | +/- zoom | 0 reset window" ) } else { format!( "q quit | End live | {view_hint} | m metric | arrows pan | PgUp/PgDn page | +/- zoom | 0 reset window" ) }; let lines = vec![Line::from(format!( "{}{}{}{} | {}", state.status.label(), paused, quitting, incomplete, controls ))]; Paragraph::new(lines).block(Block::default().borders(Borders::ALL)) } 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: &NegotiatedParams) -> String { let params = &negotiated.params; let duration = if params.duration_ns == 0 { "-".to_owned() } else { format_optional_ns_i128(Some(i128::from(params.duration_ns))) }; let restrictions = if negotiated.restrictions.is_empty() { "none".to_owned() } else { negotiated.restrictions.len().to_string() }; format!( "duration={} interval={} length={} clock={:?} timestamps={:?} stats={:?} restrictions={}", duration, format_optional_ns_i128(Some(i128::from(params.interval_ns))), params.length, params.clock, params.stamp_at, params.received_stats, restrictions ) } fn format_optional_span(value: Option) -> String { value.map(format_span).unwrap_or_else(|| ABSENT.to_owned()) } /// 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_rate(count: u64, elapsed: Duration) -> String { let secs = elapsed.as_secs_f64(); if secs <= f64::EPSILON { ABSENT.to_owned() } else { format!("{:.2}/s", count as f64 / secs) } } fn truncate(value: &str, max_chars: usize) -> String { if value.chars().count() <= max_chars { return value.to_owned(); } value .chars() .take(max_chars.saturating_sub(1)) .chain(std::iter::once('~')) .collect() } fn format_seq(value: u32) -> String { value.to_string() } 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::managed::{ ManagedTargetFailure, ManagedTargetFailureKind, ManagedTargetFailurePhase, TargetId, }; use irtt_client::{ ClientTimestamp, OneWayDelaySample, PacketMeta, RttSample, ServerTiming, WarningKind, }; use ratatui::backend::TestBackend; use std::{ net::{IpAddr, Ipv4Addr, SocketAddr}, sync::Arc, time::UNIX_EPOCH, }; fn remote() -> SocketAddr { SocketAddr::new(IpAddr::V4(Ipv4Addr::LOCALHOST), 2112) } fn ts(offset: Duration) -> ClientTimestamp { ClientTimestamp { wall: UNIX_EPOCH + offset, mono: Instant::now() + offset, } } fn rtt(effective_ns: i128) -> RttSample { RttSample { raw: Duration::from_micros(1_500), adjusted: Some(SignedDuration::from_nanos(effective_ns)), effective: SignedDuration::from_nanos(effective_ns), } } fn reply(seq: u32, effective_ns: i128) -> ClientEvent { ClientEvent::EchoReply { seq, remote: remote(), sent_at: ts(Duration::from_millis(seq as u64)), received_at: ts(Duration::from_millis(seq as u64) + Duration::from_micros(1500)), rtt: rtt(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_micros(100)), }), one_way: Some(OneWayDelaySample { client_to_server: Some(SignedDuration::from_nanos(-20_000)), server_to_client: Some(SignedDuration::from_nanos(30_000)), }), received_stats: None, bytes: 64, packet_meta: PacketMeta::default(), } } fn graph_sample(seq: u32, effective_ns: i128) -> GraphSample { GraphSample { timestamp: Instant::now() + Duration::from_secs(u64::from(seq)), seq, effective_ns, raw_ns: effective_ns + 1_000, adjusted_ns: Some(effective_ns), client_to_server_ns: None, server_to_client_ns: None, server_processing_ns: None, } } fn graph_sample_with_timing(seq: u32, effective_ns: i128) -> GraphSample { GraphSample { timestamp: Instant::now() + Duration::from_secs(u64::from(seq)), seq, effective_ns, raw_ns: effective_ns + 1_000, adjusted_ns: Some(effective_ns + 500), client_to_server_ns: Some(effective_ns / 3), server_to_client_ns: Some(effective_ns / 2), server_processing_ns: Some(100_000), } } fn series(data: Vec<(f64, f64)>) -> ChartSeries { ChartSeries { name: GraphMetric::EffectiveRtt.label().to_owned(), style: GraphMetric::EffectiveRtt.style(), data, } } fn viewport(start: Instant, end: Instant) -> GraphViewportRange { GraphViewportRange { start, end, window: end .saturating_duration_since(start) .max(Duration::from_secs(1)), is_live: true, } } fn viewport_for_visible(visible: &[&GraphSample]) -> GraphViewportRange { let start = visible.first().unwrap().timestamp; let end = visible.last().unwrap().timestamp; viewport(start, end) } fn primary_target(state: &TuiState) -> &TuiTargetState { &state.targets[0] } fn target_instance(label: &str) -> TargetInstance { TargetInstance { id: TargetId::from(label), generation: 1, } } fn target_outcome(label: &str, end_reason: ManagedTargetEndReason) -> ManagedTargetOutcome { ManagedTargetOutcome { target: target_instance(label), server_addr: Arc::from("127.0.0.1:2112"), remote: Some(remote()), end_reason, packets_sent: 0, replies_received: 0, duplicates: 0, late: 0, warning_events: 0, cleanup_failure: None, } } #[test] fn spans_stay_coarse_above_a_second_and_share_scalar_units_below_it() { assert_eq!(format_span(Duration::ZERO), "0s"); assert_eq!(format_span(Duration::from_nanos(750)), "750ns"); assert_eq!(format_span(Duration::from_micros(500)), "500.0µs"); assert_eq!(format_span(Duration::from_millis(25)), "25.0ms"); assert_eq!(format_span(Duration::from_millis(1_500)), "1.5s"); assert_eq!(format_span(Duration::from_secs(90)), "1m30s"); } #[test] fn formats_signed_durations_and_missing_values() { assert_eq!(format_optional_ns_i128(Some(-1_500_000)), "-1.5ms"); assert_eq!(format_optional_ns_i128(Some(750)), "750ns"); assert_eq!(format_optional_ns_i128(None), "-"); assert_eq!(format_optional_ns_i128(Some(1_500)), "1.5µs"); assert_eq!(format_optional_span(None), "-"); assert_eq!(format_duration(Duration::from_millis(25)), "25.0ms"); assert_eq!(format_percent_ratio(1, 4), "25.00%"); assert_eq!(format_optional_hex(Some(0x1f)), "0x1f"); } #[test] fn session_started_sets_remote_session_and_running_status() { let mut state = TuiState::default(); let target = target_instance("target"); let event = ClientEvent::SessionStarted { remote: remote(), token: 0xabc, negotiated: NegotiatedParams { params: irtt_proto::Params::default(), restrictions: Vec::new(), }, at: ts(Duration::ZERO), }; state.process_target_event(&target, &event); let target = primary_target(&state); assert_eq!(target.remote.as_deref(), Some("127.0.0.1:2112")); assert_eq!(target.session.as_deref(), Some("0xabc")); assert_eq!(state.status, TuiStatus::Running); assert!(target.negotiated.is_some()); } #[test] fn echo_reply_appends_bounded_primary_history() { let mut state = TuiState::default(); let target = target_instance("target"); for seq in 0..(HISTORY_LIMIT as u32 + 3) { let event = reply(seq, i128::from(seq) * 1_000); state.process_target_event(&target, &event); } let target = primary_target(&state); assert_eq!(target.graph_history.len(), HISTORY_LIMIT); assert_eq!(target.graph_history.front().unwrap().seq, 3); assert_eq!( target.graph_history.back().unwrap().effective_ns, i128::from(HISTORY_LIMIT as u32 + 2) * 1_000 ); assert_eq!( target.stats.snapshot().packets.unique_replies, HISTORY_LIMIT as u64 + 3 ); assert!( target.graph_history.capacity() < HISTORY_LIMIT + HISTORY_LIMIT / 10, "expected saturated history capacity to stay close to the limit, got {}", target.graph_history.capacity() ); } #[test] fn duplicate_and_late_replies_do_not_append_primary_history() { let mut state = TuiState::default(); let target = target_instance("target"); let duplicate = ClientEvent::DuplicateReply { seq: 7, remote: remote(), received_at: ts(Duration::from_secs(1)), bytes: 64, }; state.process_target_event(&target, &duplicate); let late = ClientEvent::LateReply { seq: 8, highest_seen: 9, remote: remote(), sent_at: Some(ts(Duration::from_secs(1))), received_at: ts(Duration::from_secs(2)), rtt: Some(rtt(2_000_000)), server_timing: None, one_way: None, received_stats: None, bytes: 64, packet_meta: PacketMeta::default(), }; state.process_target_event(&target, &late); let target = primary_target(&state); assert!(target.graph_history.is_empty()); let snapshot = target.stats.snapshot(); assert_eq!(snapshot.events.duplicate_replies, 1); // A matched late reply is a late unique reply. The TUI suppresses its // measurements through the late-reply policy, not by reclassifying it. assert_eq!(snapshot.events.late_unique_replies, 1); assert_eq!(snapshot.events.untracked_late_replies, 0); assert_eq!(snapshot.packets.unique_replies, 1); assert_eq!(snapshot.packets.duplicates, 1); assert_eq!(snapshot.packets.late_packets, 1); assert_eq!(snapshot.rtt.primary.count, 0); assert_eq!(snapshot.ipdv.round_trip.count, 0); assert!(state .recent_events .iter() .any(|event| event.contains("duplicate seq=7"))); assert!(state .recent_events .iter() .any(|event| event.contains("late seq=8"))); } #[test] fn untracked_late_replies_stay_untracked_in_the_tui() { let mut state = TuiState::default(); let target = target_instance("target"); let late = ClientEvent::LateReply { seq: 8, highest_seen: 9, remote: remote(), sent_at: None, received_at: ts(Duration::from_secs(2)), rtt: None, server_timing: None, one_way: None, received_stats: None, bytes: 64, packet_meta: PacketMeta::default(), }; state.process_target_event(&target, &late); let snapshot = primary_target(&state).stats.snapshot(); assert_eq!(snapshot.events.untracked_late_replies, 1); assert_eq!(snapshot.events.late_unique_replies, 0); assert_eq!(snapshot.packets.unique_replies, 0); assert_eq!(snapshot.packets.late_packets, 1); } #[test] fn target_scoped_echo_reply_updates_only_that_target() { let mut state = TuiState::with_target_labels(TuiConfig::default(), ["a".to_owned(), "b".to_owned()]); let target = target_instance("b"); let event = reply(11, 2_500_000); state.process_target_event(&target, &event); assert!(state.targets[0].graph_history.is_empty()); assert_eq!(state.targets[0].stats.snapshot().packets.unique_replies, 0); assert_eq!(state.targets[1].graph_history.len(), 1); assert_eq!( state.targets[1].graph_history.back().unwrap().effective_ns, 2_500_000 ); assert_eq!(state.targets[1].last_sample.unwrap().seq, 11); assert_eq!(state.targets[1].stats.snapshot().packets.unique_replies, 1); } #[test] fn target_scoped_duplicate_and_late_are_diagnostic_only() { let mut state = TuiState::with_target_labels(TuiConfig::default(), ["a".to_owned(), "b".to_owned()]); let a = target_instance("a"); let duplicate = ClientEvent::DuplicateReply { seq: 7, remote: remote(), received_at: ts(Duration::from_secs(1)), bytes: 64, }; state.process_target_event(&a, &duplicate); let b = target_instance("b"); let late = ClientEvent::LateReply { seq: 8, highest_seen: 9, remote: remote(), sent_at: Some(ts(Duration::from_secs(1))), received_at: ts(Duration::from_secs(2)), rtt: Some(rtt(2_000_000)), server_timing: None, one_way: None, received_stats: None, bytes: 64, packet_meta: PacketMeta::default(), }; state.process_target_event(&b, &late); assert!(state.targets[0].graph_history.is_empty()); assert!(state.targets[1].graph_history.is_empty()); assert_eq!(state.targets[0].stats.snapshot().packets.duplicates, 1); assert_eq!(state.targets[1].stats.snapshot().packets.late_packets, 1); assert_eq!(state.targets[1].stats.snapshot().rtt.primary.count, 0); } #[test] fn target_scoped_loss_warning_and_terminal_status_update_correct_target() { let mut state = TuiState::with_target_labels(TuiConfig::default(), ["a".to_owned(), "b".to_owned()]); let a = target_instance("a"); let loss = ClientEvent::EchoLoss { seq: 3, sent_at: ts(Duration::from_millis(3)), timeout_at: Instant::now(), }; state.process_target_event(&a, &loss); let b = target_instance("b"); let warning = ClientEvent::Warning { kind: WarningKind::WrongToken, message: "wrong token".to_owned(), at: ts(Duration::ZERO), }; state.process_target_event(&b, &warning); let no_test = ClientEvent::NoTestCompleted { remote: remote(), negotiated: NegotiatedParams { params: irtt_proto::Params::default(), restrictions: Vec::new(), }, at: ts(Duration::ZERO), }; state.process_target_event(&a, &no_test); assert_eq!(state.targets[0].stats.snapshot().events.loss_events, 1); assert_eq!(state.targets[0].status, TargetStatus::NoTest); assert_eq!(state.targets[1].stats.snapshot().events.warning_events, 1); assert_eq!(state.targets[1].status, TargetStatus::Opening); assert!(state.targets[1] .last_warning .as_deref() .is_some_and(|warning| warning.contains("WrongToken"))); } #[test] fn multi_target_global_status_waits_for_all_targets_terminal() { let mut state = TuiState::with_target_labels(TuiConfig::default(), ["a".to_owned(), "b".to_owned()]); let a = target_instance("a"); let no_test = ClientEvent::NoTestCompleted { remote: remote(), negotiated: NegotiatedParams { params: irtt_proto::Params::default(), restrictions: Vec::new(), }, at: ts(Duration::ZERO), }; state.process_target_event(&a, &no_test); assert_eq!(state.targets[0].status, TargetStatus::NoTest); assert_eq!(state.targets[1].status, TargetStatus::Opening); assert_eq!(state.status, TuiStatus::Running); let b = target_instance("b"); let closed = ClientEvent::SessionClosed { remote: remote(), token: 0xabc, at: ts(Duration::ZERO), }; state.process_target_event(&b, &closed); assert_eq!(state.targets[1].status, TargetStatus::Closed); assert_eq!(state.status, TuiStatus::Complete); } #[test] fn terminal_failure_marks_the_correct_target_failed() { let mut state = TuiState::with_target_labels(TuiConfig::default(), ["a".to_owned(), "b".to_owned()]); let failure = ManagedTargetFailure { phase: ManagedTargetFailurePhase::Opening, kind: ManagedTargetFailureKind::Timeout, message: "all open requests timed out".into(), }; state.process_target_outcome(&target_outcome( "b", ManagedTargetEndReason::Failed(failure), )); assert_eq!(state.targets[0].status, TargetStatus::Opening); assert_eq!(state.targets[1].status, TargetStatus::Failed); assert!(state.targets[1] .last_warning .as_deref() .is_some_and(|warning| warning.contains("timeout"))); } #[test] fn mixed_terminal_outcomes_preserve_success_and_show_failure() { let mut state = TuiState::with_target_labels(TuiConfig::default(), ["a".to_owned(), "b".to_owned()]); state.process_target_outcome(&target_outcome("a", ManagedTargetEndReason::PeerClosed)); state.process_target_outcome(&target_outcome( "b", ManagedTargetEndReason::Failed(ManagedTargetFailure { phase: ManagedTargetFailurePhase::Receiving, kind: ManagedTargetFailureKind::Protocol, message: "pending probe limit exceeded".into(), }), )); assert_eq!(state.targets[0].status, TargetStatus::Closed); assert_eq!(state.targets[1].status, TargetStatus::Failed); assert_eq!(state.status, TuiStatus::Complete); assert!(state .last_warning .as_deref() .is_some_and(|warning| warning.contains("pending probe limit"))); } #[test] fn cleanup_failure_warns_without_changing_successful_target_status() { let mut state = TuiState::with_target_labels(TuiConfig::default(), ["a".to_owned()]); let mut outcome = target_outcome("a", ManagedTargetEndReason::TestComplete); outcome.remote = None; outcome.cleanup_failure = Some(ManagedTargetFailure { phase: ManagedTargetFailurePhase::Closing, kind: ManagedTargetFailureKind::Socket, message: "close datagram failed".into(), }); state.process_target_outcome(&outcome); assert_eq!(state.targets[0].status, TargetStatus::Closed); assert_eq!(state.targets[0].remote, None); assert!(state.targets[0] .last_warning .as_deref() .is_some_and(|warning| warning.contains("cleanup failed (closing socket)"))); assert!(state .recent_events .iter() .any(|event| event.contains("cleanup failed (closing socket)"))); } #[test] fn primary_and_cleanup_failures_are_both_visible() { let mut state = TuiState::with_target_labels(TuiConfig::default(), ["a".to_owned()]); let mut outcome = target_outcome( "a", ManagedTargetEndReason::Failed(ManagedTargetFailure { phase: ManagedTargetFailurePhase::Receiving, kind: ManagedTargetFailureKind::Protocol, message: "primary failure".into(), }), ); outcome.cleanup_failure = Some(ManagedTargetFailure { phase: ManagedTargetFailurePhase::Closing, kind: ManagedTargetFailureKind::Socket, message: "cleanup failure".into(), }); state.process_target_outcome(&outcome); assert_eq!(state.targets[0].status, TargetStatus::Failed); assert!(state.targets[0] .last_warning .as_deref() .is_some_and(|warning| warning.contains("primary failure") && warning.contains("cleanup failure"))); assert!(state .recent_events .iter() .any(|event| event.contains("target failed: protocol: primary failure"))); assert!(state .recent_events .iter() .any(|event| event.contains("cleanup failed (closing socket): cleanup failure"))); } #[test] fn managed_outcomes_map_stopped_removed_and_replaced_statuses() { for (reason, expected) in [ (ManagedTargetEndReason::Stopped, TargetStatus::Stopped), (ManagedTargetEndReason::Removed, TargetStatus::Removed), (ManagedTargetEndReason::Replaced, TargetStatus::Replaced), ] { let mut state = TuiState::with_target_labels(TuiConfig::default(), ["a".to_owned()]); state.process_target_outcome(&target_outcome("a", reason)); assert_eq!(state.targets[0].status, expected); } } #[test] fn run_error_preserves_peer_closed_target_status() { let mut state = TuiState::with_target_labels(TuiConfig::default(), ["a".to_owned()]); state.process_target_outcome(&target_outcome("a", ManagedTargetEndReason::PeerClosed)); state.set_run_error( "continuous run ended because of peer closure (1 target session)".to_owned(), ); assert_eq!(state.status, TuiStatus::Error); assert_eq!(state.targets[0].status, TargetStatus::Closed); assert!(state .last_warning .as_deref() .is_some_and(|warning| warning.contains("peer closure"))); } #[test] fn all_failed_terminal_outcomes_set_error_status() { let mut state = TuiState::with_target_labels(TuiConfig::default(), ["a".to_owned(), "b".to_owned()]); for label in ["a", "b"] { state.process_target_outcome(&target_outcome( label, ManagedTargetEndReason::Failed(ManagedTargetFailure { phase: ManagedTargetFailurePhase::Opening, kind: ManagedTargetFailureKind::Protocol, message: "server returned a zero connection token".into(), }), )); } assert_eq!(state.status, TuiStatus::Error); assert!(state .targets .iter() .all(|target| target.status == TargetStatus::Failed)); } #[test] fn reset_clears_graph_history_for_all_targets() { let mut state = TuiState::with_target_labels(TuiConfig::default(), ["a".to_owned(), "b".to_owned()]); let a = target_instance("a"); let a_reply = reply(1, 1_000_000); state.process_target_event(&a, &a_reply); let b = target_instance("b"); let b_reply = reply(2, 2_000_000); state.process_target_event(&b, &b_reply); state.clear_visible_history(); assert!(state.targets[0].graph_history.is_empty()); assert!(state.targets[1].graph_history.is_empty()); assert_eq!( state.recent_events.back().map(String::as_str), Some("visible graph history reset") ); } #[test] fn warning_updates_recent_events_and_last_warning() { let mut state = TuiState::default(); let target = target_instance("target"); let event = ClientEvent::Warning { kind: WarningKind::WrongToken, message: "wrong token".to_owned(), at: ts(Duration::ZERO), }; state.process_target_event(&target, &event); assert_eq!( state.last_warning.as_deref(), Some("WrongToken: wrong token") ); assert!(state .recent_events .back() .unwrap() .contains("warning WrongToken: wrong token")); } #[test] fn dropped_events_mark_tui_statistics_incomplete() { let mut state = TuiState::default(); state.mark_dropped_managed_events(9); assert_eq!(state.dropped_events, 9); assert!(state .last_warning .as_deref() .is_some_and(|warning| warning.contains("statistics may be incomplete"))); assert!(state .recent_events .back() .is_some_and(|event| event.contains("dropped 9 managed run events"))); } #[test] fn dropped_single_client_events_are_visible() { let mut state = TuiState::default(); state.mark_dropped_managed_events(3); assert_eq!(state.dropped_events, 3); assert!(state .recent_events .back() .is_some_and(|event| event.contains("dropped 3 managed run events"))); } #[test] fn recent_event_buffer_stays_bounded() { let mut state = TuiState::default(); for seq in 0..(RECENT_EVENT_LIMIT as u32 + 5) { state.push_event(format!("event {seq}")); } assert_eq!(state.recent_events.len(), RECENT_EVENT_LIMIT); assert_eq!(state.recent_events.front().unwrap(), "event 5"); } #[test] fn recent_event_visible_count_tracks_panel_inner_height() { assert_eq!(recent_events_visible_count(0), 0); assert_eq!(recent_events_visible_count(2), 0); assert_eq!(recent_events_visible_count(9), 7); } #[test] fn visible_window_selects_samples_inside_viewport() { let history: VecDeque<_> = (0..300).map(|seq| graph_sample(seq, seq.into())).collect(); let start = history[100].timestamp; let end = history[120].timestamp; let visible = visible_history_window(&history, viewport(start, end)); assert_eq!(visible.first().unwrap().seq, 100); assert_eq!(visible.last().unwrap().seq, 120); } #[test] fn chart_bounds_use_only_viewport_local_series_data() { let start = Instant::now(); let history: VecDeque<_> = [ GraphSample { timestamp: start, seq: 1, effective_ns: 1_000_000_000, raw_ns: 1_000_000_000, adjusted_ns: Some(1_000_000_000), client_to_server_ns: None, server_to_client_ns: None, server_processing_ns: None, }, GraphSample { timestamp: start + Duration::from_secs(10), seq: 2, effective_ns: 10_000_000, raw_ns: 10_000_000, adjusted_ns: Some(10_000_000), client_to_server_ns: None, server_to_client_ns: None, server_processing_ns: None, }, ] .into(); let viewport = viewport( start + Duration::from_secs(5), start + Duration::from_secs(15), ); let visible = visible_history_window(&history, viewport); let plotted = graph_series(GraphMetric::EffectiveRtt, &visible, viewport); let (_min_y, max_y) = chart_y_bounds(&plotted, GraphMetric::EffectiveRtt.axis_kind()); assert!(max_y < 20.0, "max_y={max_y}"); } #[test] fn non_negative_chart_bounds_do_not_pad_below_zero() { let series = [series(vec![(0.0, 0.05), (1.0, 0.08), (2.0, 0.1)])]; let (min_y, max_y) = chart_y_bounds(&series, ChartAxisKind::NonNegative); assert_eq!(min_y, 0.0); assert!(max_y > 0.1, "max_y={max_y}"); } #[test] fn signed_chart_bounds_can_include_negative_values() { let series = [series(vec![(0.0, -3.0), (1.0, -1.5), (2.0, 0.5)])]; let (min_y, max_y) = chart_y_bounds(&series, ChartAxisKind::Signed); assert!(min_y < -3.0, "min_y={min_y}"); assert!(max_y > 0.5, "max_y={max_y}"); } #[test] fn chart_bounds_handle_empty_and_flat_series() { assert_eq!(chart_y_bounds(&[], ChartAxisKind::NonNegative), (0.0, 10.0)); assert_eq!(chart_y_bounds(&[], ChartAxisKind::Signed), (-1.0, 1.0)); let flat = [series(vec![(0.0, 12.0), (1.0, 12.0)])]; let (min_y, max_y) = chart_y_bounds(&flat, ChartAxisKind::Signed); assert!(min_y < 12.0, "min_y={min_y}"); assert!(max_y > 12.0, "max_y={max_y}"); } #[test] fn y_axis_tick_labels_use_label_count_minus_one_spacing() { let labels = y_axis_labels(-1.0, 1.0, 5); let rendered = labels .iter() .map(|span| span.content.as_ref()) .collect::>(); assert_eq!( rendered, vec!["-1.00ms", "-500µs", "0.0µs", "500µs", "1.00ms"] ); } #[test] fn optional_missing_one_way_series_are_omitted_not_zero_filled() { let visible_samples = [graph_sample(1, 2_000_000)]; let visible: Vec<_> = visible_samples.iter().collect(); let viewport = viewport_for_visible(&visible); let one_way = graph_series(GraphMetric::ClientToServer, &visible, viewport); let rtt = graph_series(GraphMetric::EffectiveRtt, &visible, viewport); assert!(one_way.is_empty()); assert_eq!(rtt.len(), 1); assert_eq!(rtt[0].name, "effective RTT"); } #[test] fn graph_metrics_use_readable_default_series() { let visible_samples = [graph_sample_with_timing(1, 3_000_000)]; let visible: Vec<_> = visible_samples.iter().collect(); let viewport = viewport_for_visible(&visible); assert_eq!( graph_series(GraphMetric::EffectiveRtt, &visible, viewport)[0].data, vec![(0.0, 3.0)] ); assert_eq!( graph_series(GraphMetric::ClientToServer, &visible, viewport)[0].data, vec![(0.0, 1.0)] ); } #[test] fn multi_target_metric_series_uses_selected_sample_field() { let mut target = TuiTargetState::new("alpha".to_owned(), stats_config(true)); let timestamp = Instant::now(); target.push_graph_sample(GraphSample { timestamp, seq: 1, effective_ns: 1_000_000, raw_ns: 2_000_000, adjusted_ns: Some(3_000_000), client_to_server_ns: None, server_to_client_ns: None, server_processing_ns: Some(4_000_000), }); let viewport = viewport(timestamp, timestamp + Duration::from_secs(1)); assert_eq!( target_metric_series(&target, 0, viewport, GraphMetric::EffectiveRtt) .unwrap() .data, vec![(0.0, 1.0)] ); assert_eq!( target_metric_series(&target, 0, viewport, GraphMetric::RawRtt) .unwrap() .data, vec![(0.0, 2.0)] ); assert_eq!( target_metric_series(&target, 0, viewport, GraphMetric::AdjustedRtt) .unwrap() .data, vec![(0.0, 3.0)] ); assert_eq!( target_metric_series(&target, 0, viewport, GraphMetric::ServerProcessing) .unwrap() .data, vec![(0.0, 4.0)] ); } #[test] fn multi_target_metric_series_skips_missing_optional_samples() { let mut target = TuiTargetState::new("alpha".to_owned(), stats_config(true)); let start = Instant::now(); target.push_graph_sample(GraphSample { timestamp: start, seq: 1, effective_ns: 1_000_000, raw_ns: 2_000_000, adjusted_ns: None, client_to_server_ns: None, server_to_client_ns: None, server_processing_ns: None, }); target.push_graph_sample(GraphSample { timestamp: start + Duration::from_secs(1), seq: 2, effective_ns: 2_000_000, raw_ns: 3_000_000, adjusted_ns: Some(4_000_000), client_to_server_ns: None, server_to_client_ns: None, server_processing_ns: Some(5_000_000), }); let viewport = viewport(start, start + Duration::from_secs(2)); assert_eq!( target_metric_series(&target, 0, viewport, GraphMetric::AdjustedRtt) .unwrap() .data, vec![(1.0, 4.0)] ); assert_eq!( target_metric_series(&target, 0, viewport, GraphMetric::ServerProcessing) .unwrap() .data, vec![(1.0, 5.0)] ); } #[test] fn graph_metric_cycling_walks_all_metrics() { let cases = [ GraphMetric::EffectiveRtt, GraphMetric::RawRtt, GraphMetric::AdjustedRtt, GraphMetric::ClientToServer, GraphMetric::ServerToClient, GraphMetric::ServerProcessing, GraphMetric::EffectiveRtt, ]; let mut state = TuiState::default(); for metric in cases { assert_eq!(state.graph_metric, metric); state.cycle_graph_metric(); } } #[test] fn view_toggle_switches_between_graph_and_dashboard() { let mut state = TuiState::default(); assert_eq!(state.view, TuiView::Graph); state.toggle_view(); assert_eq!(state.view, TuiView::Dashboard); state.toggle_view(); assert_eq!(state.view, TuiView::Graph); } #[test] fn target_label_span_uses_graph_series_style() { let span = target_label_span("alpha-target", 3, 16); assert_eq!(span.style, target_style(3).add_modifier(Modifier::BOLD)); assert_eq!(span.content.as_ref(), "alpha-target "); } #[test] fn pause_suppresses_scheduled_renders_but_allows_forced_controls() { let now = Instant::now(); let due = now - Duration::from_millis(1); assert!(!should_render(now, due, true, false)); assert!(should_render(now, due, true, true)); let mut state = TuiState::default(); state.toggle_pause(); assert!(state.paused); } #[test] fn sample_details_preserve_signed_values() { let mut state = TuiState::default(); let target = target_instance("target"); let event = reply(4, -1_250_000); state.process_target_event(&target, &event); let target = primary_target(&state); let sample = target.last_sample.unwrap(); assert_eq!(sample.seq, 4); assert_eq!(sample.effective_ns, -1_250_000); assert_eq!(sample.client_to_server_ns, Some(-20_000)); assert_eq!(sample.server_processing_ns, Some(100_000)); let graph = target.graph_history.back().unwrap(); assert_eq!(graph.seq, 4); assert_eq!(graph.effective_ns, -1_250_000); assert_eq!(graph.client_to_server_ns, Some(-20_000)); assert_eq!(graph.server_to_client_ns, Some(30_000)); assert_eq!(graph.server_processing_ns, Some(100_000)); } // Off-screen render coverage. These exercise draw_dashboard through a real // Ratatui backend, so layout selection, panel presence, and the text a user // actually sees are protected rather than only the pure state and viewport // arithmetic covered above. /// Render `state` at the given terminal size and return the visible rows. fn render_rows(state: &TuiState, width: u16, height: u16) -> Vec { let mut terminal = Terminal::new(TestBackend::new(width, height)).unwrap(); terminal.draw(|frame| draw_dashboard(frame, state)).unwrap(); let buffer = terminal.backend().buffer().clone(); (0..buffer.area.height) .map(|y| { (0..buffer.area.width) .map(|x| buffer.cell((x, y)).map(|cell| cell.symbol()).unwrap_or(" ")) .collect::() }) .collect() } fn render_text(state: &TuiState, width: u16, height: u16) -> String { render_rows(state, width, height).join("\n") } /// Whether a bordered panel with this title is on screen. /// /// A panel is identified by its title in a top border, so this cannot be /// satisfied by body text that happens to mention the same word. fn has_panel(rows: &[String], title: &str) -> bool { let needle = format!("\u{250c}{title}"); rows.iter().any(|row| row.contains(&needle)) } /// The interior rows of one bordered panel, sliced to that panel's columns. /// /// Panels sit side by side, so a whole-row search matches text belonging to /// a neighbour, and most panel content is echoed somewhere else on the /// frame. Scoping assertions to the owning panel is what makes them /// protect the panel they name. fn panel_rows(rows: &[String], title: &str) -> Vec { let needle: Vec = format!("\u{250c}{title}").chars().collect(); let (top, start) = rows .iter() .enumerate() .find_map(|(index, row)| { let chars: Vec = row.chars().collect(); chars .windows(needle.len()) .position(|window| window == needle.as_slice()) .map(|column| (index, column)) }) .unwrap_or_else(|| panic!("no panel titled {title} in:\n{}", rows.join("\n"))); let title_row: Vec = rows[top].chars().collect(); let end = title_row[start..] .iter() .position(|glyph| *glyph == '\u{2510}') .map(|offset| start + offset) .expect("a panel top border ends with a corner"); rows[top + 1..] .iter() .map(|row| { row.chars() .skip(start) .take(end - start + 1) .collect::() }) .take_while(|row| !row.starts_with('\u{2514}')) .collect() } /// Whether any interior row of `title`'s panel contains `text`. fn panel_contains(rows: &[String], title: &str, text: &str) -> bool { panel_rows(rows, title).iter().any(|row| row.contains(text)) } /// Assert every target owns a row in the target table, scoped to that /// panel's columns. /// /// A whole-frame search cannot prove this: the first target is also named /// in the session header, and a target that replied appears in recent /// events and as a chart series. fn assert_target_table_rows(rows: &[String], labels: &[&str], replied: &str) { let table = panel_rows(rows, "targets"); let row_for = |label: &str| { table .iter() .find(|row| { row.trim_start_matches('\u{2502}') .trim_start() .starts_with(label) }) .unwrap_or_else(|| { panic!("no target table row for {label} in:\n{}", table.join("\n")) }) .clone() }; for label in labels { let row = row_for(label); assert!( row.contains("opening"), "target row for {label} lost its status column: {row}" ); } // The target that replied shows its last sample. let replied_row = row_for(replied); assert!( replied_row.contains("3.0ms"), "{replied} row lost its last sample: {replied_row}" ); } /// A single-target state carrying a session, one reply, and one warning. /// /// The TUI opens in the graph view, so dashboard tests toggle it. fn active_single_target_state() -> TuiState { let mut state = TuiState::default(); let target = target_instance("target"); state.process_target_event( &target, &ClientEvent::SessionStarted { remote: remote(), token: 0xabcd, negotiated: NegotiatedParams { params: irtt_proto::Params::default(), restrictions: Vec::new(), }, at: ts(Duration::ZERO), }, ); state.process_target_event(&target, &reply(11, 2_500_000)); state.process_target_event( &target, &ClientEvent::Warning { kind: WarningKind::WrongToken, message: "wrong token".to_owned(), at: ts(Duration::ZERO), }, ); state } fn active_dashboard_state() -> TuiState { let mut state = active_single_target_state(); state.toggle_view(); assert_eq!(state.view, TuiView::Dashboard); state } #[test] fn a_terminal_below_the_minimum_width_renders_only_the_resize_notice() { let state = active_dashboard_state(); let text = render_text(&state, MIN_WIDTH - 1, MIN_HEIGHT); assert!(text.contains("terminal too small")); assert!(text.contains("press q / Ctrl-C to quit")); assert!(!text.contains("packets")); assert!(!text.contains("q quit")); } #[test] fn a_terminal_below_the_minimum_height_renders_only_the_resize_notice() { let state = active_dashboard_state(); let text = render_text(&state, MIN_WIDTH, MIN_HEIGHT - 1); assert!(text.contains("terminal too small")); assert!(!text.contains("packets")); assert!(!text.contains("q quit")); } #[test] fn the_smallest_usable_terminal_renders_the_compact_dashboard() { let state = active_dashboard_state(); let rows = render_rows(&state, MIN_WIDTH, MIN_HEIGHT); let text = rows.join("\n"); assert!(!text.contains("terminal too small")); assert!(text.contains("session")); assert!(text.contains("packets")); assert!(rows[usize::from(MIN_HEIGHT) - 2].contains("q quit")); } #[test] fn a_compact_terminal_renders_the_header_packet_and_status_rows() { let state = active_dashboard_state(); let rows = render_rows(&state, 80, 24); let text = rows.join("\n"); assert!(text.contains("irtt-rs")); assert!(text.contains("session")); assert!(text.contains("packets")); assert!(text.contains("sent")); // The compact layout drops the timing, recent-events, and sample panels // that only fit in the large layout. assert!(!text.contains("recent events")); assert!(!text.contains("last warning:")); assert!(!text.contains("timing")); // The status line is the bottom bordered row of the layout. assert!(rows[22].contains("q quit")); } #[test] fn a_large_terminal_renders_every_dashboard_panel() { let state = active_dashboard_state(); let rows = render_rows(&state, 180, 44); let text = rows.join("\n"); for expected in [ "session", "packets", "timing", "recent events", "sample", "effective RTT", "send delay", "server process", ] { assert!(text.contains(expected), "missing {expected} in:\n{text}"); } assert!(rows[42].contains("q quit")); } #[test] fn active_sample_data_and_warnings_are_visible_in_the_large_layout() { let state = active_dashboard_state(); let rows = render_rows(&state, 140, 40); // Session identity, on the header panel's own rows: the token is also // echoed by the recent-events panel. assert!(panel_contains(&rows, "session", "session: 0xabcd")); assert!(panel_contains(&rows, "session", "remote: 127.0.0.1:2112")); // The most recent sample, on the sample panel's own rows. assert!(panel_contains(&rows, "sample", "last seq: 11")); let sample = panel_rows(&rows, "sample"); let sample_metrics = sample .iter() .find(|row| row.contains("raw / adjusted / effective:")) .expect("the sample panel renders a metric row"); // The whole row, so a regression in any one field is caught: the // fixture's adjusted and effective RTT share a value, and asserting // "2.5ms" alone would still pass with the effective field dropped. assert!( sample_metrics.contains("raw / adjusted / effective: 1.5ms / 2.5ms / 2.5ms"), "unexpected sample metric row: {sample_metrics}" ); // The warning reaches the recent-events panel as an event line: the // sample panel's own last-warning row would otherwise satisfy a loose // "warning" plus "wrong token" search. assert!(panel_contains( &rows, "recent events", "warning WrongToken: wrong token" )); // ...and the sample panel's last-warning row carries the warning // itself, not the absent-value placeholder. let last_warning = sample .iter() .find(|row| row.contains("last warning:")) .expect("the sample panel renders a last-warning row"); assert!( last_warning.contains("wrong token"), "last-warning row did not carry the warning: {last_warning}" ); } #[test] fn a_failed_run_renders_the_error_status_and_message() { let mut state = active_dashboard_state(); state.set_error("open timed out".to_owned()); let rows = render_rows(&state, 140, 40); let text = rows.join("\n"); assert!(text.contains("open timed out")); assert!(rows[38].contains(TuiStatus::Error.label())); } #[test] fn a_multi_target_state_renders_the_target_table_in_place_of_packet_counters() { let mut state = TuiState::with_target_labels( TuiConfig::default(), ["alpha".to_owned(), "beta".to_owned(), "gamma".to_owned()], ); state.toggle_view(); state.process_target_event(&target_instance("beta"), &reply(4, 3_000_000)); let rows = render_rows(&state, 140, 40); assert!(has_panel(&rows, "targets")); assert_target_table_rows(&rows, &["alpha", "beta", "gamma"], "beta"); assert!(has_panel(&rows, "timing - first target")); assert!(has_panel(&rows, "sample - first target")); // The table replaces the single-target packet counters. assert!(!has_panel(&rows, "packets")); } #[test] fn the_graph_view_renders_a_chart_instead_of_the_dashboard_panels() { let state = active_single_target_state(); assert_eq!(state.view, TuiView::Graph); let rows = render_rows(&state, 140, 40); let text = rows.join("\n"); assert!(text.contains(state.graph_metric.title())); assert!(text.contains("live")); assert!(rows[38].contains("q quit")); // The graph view replaces every dashboard panel, not just some. for panel in ["packets", "timing", "recent events", "sample"] { assert!( !has_panel(&rows, panel), "{panel} panel rendered in the graph view" ); } } #[test] fn the_multi_target_graph_view_keeps_the_target_table_header() { let mut state = TuiState::with_target_labels( TuiConfig::default(), ["alpha".to_owned(), "beta".to_owned()], ); state.process_target_event(&target_instance("beta"), &reply(4, 3_000_000)); let rows = render_rows(&state, 140, 40); let text = rows.join("\n"); assert!(has_panel(&rows, "targets")); // Scoped to the table: the replying target also names a chart series. assert_target_table_rows(&rows, &["alpha", "beta"], "beta"); assert!(text.contains(state.graph_metric.title())); } #[test] fn a_paused_display_with_dropped_events_is_reported_on_the_status_line() { let mut state = active_dashboard_state(); state.toggle_pause(); state.mark_dropped_managed_events(3); let rows = render_rows(&state, 140, 40); assert!(rows[38].contains("display paused")); assert!(rows[38].contains("incomplete:dropped=3")); } }