diff --git a/Cargo.lock b/Cargo.lock index b2f98bb..2d3e7d7 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -17,6 +17,15 @@ version = "0.2.21" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "683d7910e743518b0e34f1186f92494becacb047c7b6bf616c96772180fef923" +[[package]] +name = "android_system_properties" +version = "0.1.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ae221649c9976a6f6c56ae1facf410f3ddb33cc661c4b7b61020a912d4237fbc" +dependencies = [ + "libc", +] + [[package]] name = "anstream" version = "1.0.0" @@ -199,6 +208,16 @@ dependencies = [ "rustversion", ] +[[package]] +name = "cc" +version = "1.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f74872d07caf508b30a21f6836e7d7016a2eaf7d9ff4f48deaa58cd8a0407630" +dependencies = [ + "find-msvc-tools", + "shlex", +] + [[package]] name = "cfg-if" version = "1.0.5" @@ -211,6 +230,17 @@ version = "0.2.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f079e83a288787bcd14a6aea84cee5c87a67c5a3e660c30f557a3d24761b3527" +[[package]] +name = "chrono" +version = "0.4.45" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1aa79e62e7697b8e29b513a68abacf485adcd1fe8284a4316c5ae868e6633327" +dependencies = [ + "iana-time-zone", + "num-traits", + "windows-link", +] + [[package]] name = "clap" version = "4.6.7" @@ -292,6 +322,12 @@ dependencies = [ "unicode-segmentation", ] +[[package]] +name = "core-foundation-sys" +version = "0.8.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "773648b94d0e5d620f64f280777445740e61fe701025087ec8b57f45c791888b" + [[package]] name = "cpufeatures" version = "0.2.17" @@ -553,6 +589,12 @@ dependencies = [ "winapi", ] +[[package]] +name = "find-msvc-tools" +version = "0.1.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "aedcfb3409746eddb02b9e19ebda1c3394f759a152e48ee875a0844d1b955484" + [[package]] name = "finl_unicode" version = "1.5.0" @@ -686,6 +728,30 @@ dependencies = [ "typenum", ] +[[package]] +name = "iana-time-zone" +version = "0.1.65" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e31bc9ad994ba00e440a8aa5c9ef0ec67d5cb5e5cb0cc7f8b744a35b389cc470" +dependencies = [ + "android_system_properties", + "core-foundation-sys", + "iana-time-zone-haiku", + "js-sys", + "log", + "wasm-bindgen", + "windows-core", +] + +[[package]] +name = "iana-time-zone-haiku" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f31827a206f56af32e590ba56d5d2d085f558508192593743f16b2306495269f" +dependencies = [ + "cc", +] + [[package]] name = "ident_case" version = "1.0.1" @@ -751,6 +817,7 @@ dependencies = [ name = "irtt-rs" version = "0.7.1" dependencies = [ + "chrono", "clap", "crossterm", "ctrlc", @@ -1604,6 +1671,12 @@ dependencies = [ "digest 0.10.7", ] +[[package]] +name = "shlex" +version = "2.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f8fadd59c855ef2080decdef8ff161eb6661b86933c9d82e5ba29dc602a55aba" + [[package]] name = "signal-hook" version = "0.3.18" @@ -2153,12 +2226,65 @@ version = "0.4.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "712e227841d057c1ee1cd2fb22fa7e5a5461ae8e48fa2ca79ec42cfc1931183f" +[[package]] +name = "windows-core" +version = "0.62.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b8e83a14d34d0623b51dce9581199302a221863196a1dde71a7663a4c2be9deb" +dependencies = [ + "windows-implement", + "windows-interface", + "windows-link", + "windows-result", + "windows-strings", +] + +[[package]] +name = "windows-implement" +version = "0.60.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "053e2e040ab57b9dc951b72c264860db7eb3b0200ba345b4e4c3b14f67855ddf" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "windows-interface" +version = "0.59.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3f316c4a2570ba26bbec722032c4099d8c8bc095efccdc15688708623367e358" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + [[package]] name = "windows-link" version = "0.2.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f0805222e57f7521d6a62e36fa9163bc891acd422f971defe97d64e70d0a4fe5" +[[package]] +name = "windows-result" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7781fa89eaf60850ac3d2da7af8e5242a5ea78d1a11c49bf2910bb5a73853eb5" +dependencies = [ + "windows-link", +] + +[[package]] +name = "windows-strings" +version = "0.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7837d08f69c77cf6b07689544538e017c1bfcf57e34b4c0ff58e6c2cd3b37091" +dependencies = [ + "windows-link", +] + [[package]] name = "windows-sys" version = "0.61.2" diff --git a/crates/irtt-app/Cargo.toml b/crates/irtt-app/Cargo.toml index 1af6ef6..99c3d89 100644 --- a/crates/irtt-app/Cargo.toml +++ b/crates/irtt-app/Cargo.toml @@ -44,9 +44,10 @@ doc = false default = ["client", "server", "tui"] client = ["dep:tokio", "dep:irtt-client", "irtt-client/ancillary", "irtt-client/tokio", "dep:irtt-proto", "dep:irtt-stats"] server = ["dep:irtt-server", "dep:tokio"] -tui =["dep:tokio", "dep:futures-core", "dep:irtt-client", "irtt-client/ancillary", "irtt-client/tokio", "dep:irtt-proto", "dep:irtt-stats", "dep:ratatui", "dep:crossterm"] +tui =["dep:tokio", "dep:futures-core", "dep:irtt-client", "irtt-client/ancillary", "irtt-client/tokio", "dep:irtt-proto", "dep:irtt-stats", "dep:ratatui", "dep:crossterm", "dep:chrono"] [dependencies] +chrono = { version = "0.4.45", default-features = false, features = ["clock"], optional = true } clap = { version = "4.6.7", features = ["derive", "env"] } crossterm = { version = "0.29.0", features = ["event-stream"], optional = true } futures-core = { version = "0.3.34", default-features = false, optional = true } diff --git a/crates/irtt-app/src/cmd/tui/ui.rs b/crates/irtt-app/src/cmd/tui/ui.rs index cbd804c..0c11d8c 100644 --- a/crates/irtt-app/src/cmd/tui/ui.rs +++ b/crates/irtt-app/src/cmd/tui/ui.rs @@ -1,9 +1,10 @@ use std::{ collections::{BTreeMap, VecDeque}, io::{self, Stdout}, - time::{Duration, Instant}, + time::{Duration, Instant, SystemTime}, }; +use chrono::{DateTime, Local, TimeZone, Utc}; use crossterm::{ cursor::Show, event::KeyCode, @@ -11,7 +12,9 @@ use crossterm::{ terminal::{disable_raw_mode, enable_raw_mode, EnterAlternateScreen, LeaveAlternateScreen}, }; use irtt_client::managed::{ManagedTargetEndReason, ManagedTargetOutcome, TargetInstance}; -use irtt_client::{ClientEvent, NegotiationResult, OneWayDelaySample, RttSample, SignedDuration}; +use irtt_client::{ + ClientEvent, ClientTimestamp, NegotiationResult, OneWayDelaySample, RttSample, SignedDuration, +}; use irtt_stats::{StatsCollector, TimeStats}; use ratatui::{ backend::CrosstermBackend, @@ -40,7 +43,26 @@ 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 MAX_GRAPH_WINDOW: Duration = Duration::from_secs(24 * 60 * 60); + +const GRAPH_WINDOWS: &[Duration] = &[ + Duration::from_secs(5), + Duration::from_secs(10), + Duration::from_secs(15), + Duration::from_secs(30), + Duration::from_secs(60), + Duration::from_secs(2 * 60), + Duration::from_secs(5 * 60), + Duration::from_secs(10 * 60), + Duration::from_secs(15 * 60), + Duration::from_secs(30 * 60), + Duration::from_secs(60 * 60), + Duration::from_secs(2 * 60 * 60), + Duration::from_secs(4 * 60 * 60), + Duration::from_secs(8 * 60 * 60), + Duration::from_secs(12 * 60 * 60), + Duration::from_secs(24 * 60 * 60), +]; pub(super) struct TuiTerminal { terminal: Terminal>, @@ -297,7 +319,7 @@ impl TuiState { .. } => { target.push_graph_sample(GraphSample { - timestamp: received_at.mono, + timestamp: *received_at, seq: *seq, rtt: *rtt, one_way: *one_way, @@ -472,14 +494,24 @@ impl TuiState { fn oldest_graph_sample_time(&self) -> Option { self.targets .iter() - .filter_map(|target| target.graph_history.front().map(|sample| sample.timestamp)) + .filter_map(|target| { + target + .graph_history + .front() + .map(|sample| sample.timestamp.mono) + }) .min() } fn newest_graph_sample_time(&self) -> Option { self.targets .iter() - .filter_map(|target| target.graph_history.back().map(|sample| sample.timestamp)) + .filter_map(|target| { + target + .graph_history + .back() + .map(|sample| sample.timestamp.mono) + }) .max() } } @@ -660,7 +692,6 @@ impl GraphViewport { start: end.checked_sub(self.window).unwrap_or(end), end, window: self.window, - is_live: matches!(self.mode, GraphViewportMode::Follow), } } @@ -695,11 +726,11 @@ impl GraphViewport { } fn zoom_in(&mut self, newest: Option) { - self.set_window(self.window * 2 / 3, newest); + self.set_window(graph_zoom_window(self.window, false), newest); } fn zoom_out(&mut self, newest: Option) { - self.set_window(self.window * 3 / 2, newest); + self.set_window(graph_zoom_window(self.window, true), newest); } fn reset_window(&mut self, newest: Option) { @@ -742,6 +773,23 @@ impl GraphViewport { } } +fn graph_zoom_window(current: Duration, larger: bool) -> Duration { + if larger { + GRAPH_WINDOWS + .iter() + .copied() + .find(|&window| window > current) + .unwrap_or(MAX_GRAPH_WINDOW) + } else { + GRAPH_WINDOWS + .iter() + .copied() + .rev() + .find(|&window| window < current) + .unwrap_or(MIN_GRAPH_WINDOW) + } +} + #[derive(Debug, Clone, Copy, PartialEq, Eq)] enum GraphViewportMode { Follow, @@ -753,7 +801,6 @@ struct GraphViewportRange { start: Instant, end: Instant, window: Duration, - is_live: bool, } #[derive(Debug, Clone, Copy, PartialEq, Eq)] @@ -839,7 +886,7 @@ impl GraphMetric { #[derive(Debug, Clone, Copy, PartialEq, Eq)] struct GraphSample { - timestamp: Instant, + timestamp: ClientTimestamp, seq: u32, rtt: RttSample, one_way: Option, @@ -985,7 +1032,7 @@ fn target_table(state: &TuiState, area: Rect) -> Table<'static> { }, target .last_sample - .map(|sample| format_span(sample.timestamp.elapsed())) + .map(|sample| format_span(sample.timestamp.mono.elapsed())) .unwrap_or_else(|| ABSENT.to_owned()), ]) .style(target_style(idx)) @@ -1194,7 +1241,7 @@ fn render_graph_area(frame: &mut Frame<'_>, area: Rect, state: &TuiState) { .x_axis( Axis::default() .bounds(viewport_x_bounds(viewport)) - .labels(viewport_x_axis_labels(viewport)) + .labels(viewport_x_axis_labels(state, viewport, area.width)) .style(Style::default().fg(Color::Gray)), ) .y_axis( @@ -1229,7 +1276,9 @@ fn target_metric_series( let data = target .graph_history .iter() - .filter(|sample| sample.timestamp >= viewport.start && sample.timestamp <= viewport.end) + .filter(|sample| { + sample.timestamp.mono >= viewport.start && sample.timestamp.mono <= viewport.end + }) .filter_map(|sample| { metric .value_ns(sample) @@ -1246,6 +1295,7 @@ fn target_metric_series( fn sample_x(sample: &GraphSample, viewport: GraphViewportRange) -> f64 { sample .timestamp + .mono .saturating_duration_since(viewport.start) .as_secs_f64() } @@ -1348,11 +1398,95 @@ 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" }), - ] +// Use a sample near the viewport end, including when viewing retained history. +// Wall-clock jumps affect labels only; the viewport and series remain monotonic. +fn graph_timestamp_anchor(state: &TuiState, end: Instant) -> Option { + state + .targets + .iter() + .filter_map(|target| { + target + .graph_history + .iter() + .rev() + .find(|sample| sample.timestamp.mono <= end) + .or_else(|| target.graph_history.front()) + }) + .map(|sample| sample.timestamp) + .min_by_key(|timestamp| { + if timestamp.mono <= end { + end.duration_since(timestamp.mono) + } else { + timestamp.mono.duration_since(end) + } + }) +} + +fn graph_wall_time(anchor: ClientTimestamp, tick: Instant) -> Option { + if tick >= anchor.mono { + anchor.wall.checked_add(tick.duration_since(anchor.mono)) + } else { + anchor.wall.checked_sub(anchor.mono.duration_since(tick)) + } +} + +fn graph_local_time(wall: SystemTime) -> Option> { + let nanos = match wall.duration_since(SystemTime::UNIX_EPOCH) { + Ok(duration) => duration.as_nanos() as i128, + Err(error) => -(error.duration().as_nanos() as i128), + }; + let seconds = i64::try_from(nanos.div_euclid(1_000_000_000)).ok()?; + let subsecond = nanos.rem_euclid(1_000_000_000) as u32; + let utc = DateTime::::from_timestamp(seconds, subsecond)?; + // Chrono also falls back to UTC when the system timezone cannot be found. + Some( + Local + .timestamp_opt(seconds, subsecond) + .single() + .map(|local| local.fixed_offset()) + .unwrap_or_else(|| utc.fixed_offset()), + ) +} + +fn viewport_x_axis_labels( + state: &TuiState, + viewport: GraphViewportRange, + width: u16, +) -> Vec> { + let Some(anchor) = graph_timestamp_anchor(state, viewport.end) else { + return vec![Span::raw(ABSENT), Span::raw(ABSENT)]; + }; + let local_at = |tick| graph_wall_time(anchor, tick).and_then(graph_local_time); + let date_boundary = local_at(viewport.start) + .zip(local_at(viewport.end)) + .is_some_and(|(start, end)| start.date_naive() != end.date_naive()); + // Reserve space for the borders/y axis and a gap between labels. Use the + // longest possible label for this window so narrow charts remain readable. + let fractional = viewport.window < Duration::from_secs(8); + let label_width = 8 + if fractional { 2 } else { 0 } + if date_boundary { 6 } else { 0 }; + let count = (usize::from(width.saturating_sub(14)) / (label_width + 3)).clamp(2, 5); + let step = viewport.window / (count - 1) as u32; + let fractional = step < Duration::from_secs(2); + (0..count) + .map(|idx| { + let tick = if idx == count - 1 { + Some(viewport.end) + } else { + viewport.start.checked_add(step * idx as u32) + }; + let label = tick.and_then(local_at).map(|local| { + let mut label = local.format("%H:%M:%S").to_string(); + if fractional { + label.push_str(&format!(".{}", local.timestamp_subsec_millis() / 100)); + } + if date_boundary && (idx == 0 || idx == count - 1) { + label = format!("{} {label}", local.format("%m-%d")); + } + label + }); + Span::raw(label.unwrap_or_else(|| ABSENT.to_owned())) + }) + .collect() } fn y_axis_label_count(height: u16) -> usize { @@ -1479,15 +1613,39 @@ 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) + + if secs < 60 { + return format!("{:.1}s", nanos as f64 / 1_000_000_000.0); + } + + let seconds = secs % 60; + let minutes = (secs / 60) % 60; + let hours = (secs / 3600) % 24; + let days = secs / 86_400; + + if days != 0 { + if hours == 0 { + format!("{days}d") + } else { + format!("{days}d{hours:02}h") + } + } else if hours != 0 { + if minutes == 0 { + format!("{hours}h") + } else { + format!("{hours}h{minutes:02}m") + } + } else if seconds == 0 { + format!("{minutes}m") } else { - format!("{}m{:02}s", secs / 60, secs % 60) + format!("{minutes}m{seconds:02}s") } }