feat(tui): optimize terminal UI and simplify graph data series
This commit is contained in:
4 files changed
+440
-304
No files matched your search
@@ -0,0 +1,134 @@
|
||||
use std::time::Duration;
|
||||
|
||||
use clap::Parser;
|
||||
use irtt_client::ClientConfig;
|
||||
|
||||
#[cfg(test)]
|
||||
use crate::shared::client::TimestampArg;
|
||||
use crate::shared::client::{parse_test_duration, CommonClientArgs};
|
||||
|
||||
pub const DEFAULT_TUI_DURATION: Duration = Duration::ZERO;
|
||||
|
||||
#[derive(Debug, Clone, Parser)]
|
||||
#[command(name = "irtt-tui", about = "Minimal IRTT-compatible TUI client")]
|
||||
pub struct TuiArgs {
|
||||
/// Server address or host, with optional port.
|
||||
pub server: String,
|
||||
|
||||
#[arg(
|
||||
long,
|
||||
default_value = "0",
|
||||
value_parser = parse_test_duration,
|
||||
help = "Test duration; use 0 for continuous mode",
|
||||
long_help = "Test duration; use 0 for continuous mode. The TUI defaults to continuous mode."
|
||||
)]
|
||||
pub duration: Duration,
|
||||
|
||||
#[command(flatten)]
|
||||
pub common: CommonClientArgs,
|
||||
}
|
||||
|
||||
impl TuiArgs {
|
||||
pub fn to_client_config(&self) -> ClientConfig {
|
||||
self.common.to_client_config(&self.server, self.duration)
|
||||
}
|
||||
|
||||
pub fn is_continuous(&self) -> bool {
|
||||
self.duration == Duration::ZERO
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
pub fn timestamp_mode(&self) -> TimestampArg {
|
||||
self.common.tstamp
|
||||
}
|
||||
}
|
||||
|
||||
impl std::ops::Deref for TuiArgs {
|
||||
type Target = CommonClientArgs;
|
||||
|
||||
fn deref(&self) -> &Self::Target {
|
||||
&self.common
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::cmd::client::ClientArgs;
|
||||
use clap::{CommandFactory, Parser};
|
||||
|
||||
fn parse(args: &[&str]) -> Result<TuiArgs, clap::Error> {
|
||||
let mut argv = vec!["irtt-tui"];
|
||||
argv.extend_from_slice(args);
|
||||
TuiArgs::try_parse_from(argv)
|
||||
}
|
||||
|
||||
fn parse_client(args: &[&str]) -> Result<ClientArgs, clap::Error> {
|
||||
let mut argv = vec!["irtt-cli"];
|
||||
argv.extend_from_slice(args);
|
||||
ClientArgs::try_parse_from(argv)
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn tui_parser_defaults_to_continuous_and_has_no_output_option() {
|
||||
let args = parse(&["127.0.0.1:2112"]).unwrap();
|
||||
assert_eq!(args.server, "127.0.0.1:2112");
|
||||
assert_eq!(args.duration, DEFAULT_TUI_DURATION);
|
||||
assert!(args.is_continuous());
|
||||
assert_eq!(args.to_client_config().duration, None);
|
||||
|
||||
let finite = parse(&["--duration", "30s", "127.0.0.1:2112"]).unwrap();
|
||||
assert_eq!(finite.duration, Duration::from_secs(30));
|
||||
assert_eq!(
|
||||
finite.to_client_config().duration,
|
||||
Some(Duration::from_secs(30))
|
||||
);
|
||||
|
||||
assert!(parse(&["--output", "human", "127.0.0.1:2112"]).is_err());
|
||||
let help = TuiArgs::command().render_help().to_string();
|
||||
assert!(!help.contains("--output"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn shared_client_options_match_between_stream_and_tui_parsers() {
|
||||
let shared = [
|
||||
"--duration",
|
||||
"30s",
|
||||
"--interval",
|
||||
"250ms",
|
||||
"--length",
|
||||
"128",
|
||||
"--hmac",
|
||||
"secret",
|
||||
"--clock",
|
||||
"monotonic",
|
||||
"--tstamp",
|
||||
"receive",
|
||||
"--stats",
|
||||
"count",
|
||||
"--sfill",
|
||||
"abc",
|
||||
"--dscp",
|
||||
"46",
|
||||
"--ttl",
|
||||
"64",
|
||||
"--loose",
|
||||
"127.0.0.1:2112",
|
||||
];
|
||||
let client = parse_client(&shared).unwrap().to_client_config();
|
||||
let tui = parse(&shared).unwrap().to_client_config();
|
||||
|
||||
assert_eq!(client.server_addr, tui.server_addr);
|
||||
assert_eq!(client.duration, tui.duration);
|
||||
assert_eq!(client.interval, tui.interval);
|
||||
assert_eq!(client.length, tui.length);
|
||||
assert_eq!(client.received_stats, tui.received_stats);
|
||||
assert_eq!(client.stamp_at, tui.stamp_at);
|
||||
assert_eq!(client.clock, tui.clock);
|
||||
assert_eq!(client.dscp, tui.dscp);
|
||||
assert_eq!(client.hmac_key, tui.hmac_key);
|
||||
assert_eq!(client.server_fill, tui.server_fill);
|
||||
assert_eq!(client.negotiation_policy, tui.negotiation_policy);
|
||||
assert_eq!(client.socket_config.ttl, tui.socket_config.ttl);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,6 @@
|
||||
pub mod args;
|
||||
pub mod run;
|
||||
mod ui;
|
||||
|
||||
pub use args::TuiArgs;
|
||||
pub use run::run_tui;
|
||||
@@ -0,0 +1,216 @@
|
||||
use std::{
|
||||
io,
|
||||
sync::atomic::{AtomicBool, Ordering},
|
||||
time::{Duration, Instant},
|
||||
};
|
||||
|
||||
use crossterm::event::{self, Event, KeyCode, KeyEventKind, KeyModifiers};
|
||||
use irtt_stats::StatsCollector;
|
||||
|
||||
use crate::{
|
||||
cmd::tui::args::TuiArgs,
|
||||
shared::client::{is_shutdown_requested, ClientSession},
|
||||
};
|
||||
|
||||
use super::ui::{should_render, TuiConfig, TuiState, TuiStatus, TuiTerminal};
|
||||
|
||||
fn stats_config(continuous: bool) -> irtt_stats::StatsConfig {
|
||||
if continuous {
|
||||
irtt_stats::StatsConfig::continuous()
|
||||
} else {
|
||||
irtt_stats::StatsConfig::finite()
|
||||
}
|
||||
}
|
||||
|
||||
const RENDER_INTERVAL: Duration = Duration::from_millis(250);
|
||||
const TUI_WAIT_SLICE: Duration = Duration::from_millis(20);
|
||||
const IDLE_SLEEP: Duration = Duration::from_millis(5);
|
||||
|
||||
pub fn run_tui(
|
||||
args: TuiArgs,
|
||||
shutdown_requested: &AtomicBool,
|
||||
) -> Result<(), Box<dyn std::error::Error>> {
|
||||
let continuous = args.is_continuous();
|
||||
let mut terminal = TuiTerminal::enter()?;
|
||||
let mut state = TuiState::new(TuiConfig::from_args(&args));
|
||||
let mut stats = StatsCollector::new(stats_config(continuous));
|
||||
let mut next_render = Instant::now();
|
||||
|
||||
if is_shutdown_requested(shutdown_requested) {
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
state.set_status(TuiStatus::Opening);
|
||||
render_if_due(&mut terminal, &state, &stats, &mut next_render, true)?;
|
||||
|
||||
let mut session = match ClientSession::connect(args.to_client_config(), continuous) {
|
||||
Ok(session) => session,
|
||||
Err(err) => {
|
||||
state.set_error(err.to_string());
|
||||
render_if_due(&mut terminal, &state, &stats, &mut next_render, true)?;
|
||||
return Err(Box::new(err));
|
||||
}
|
||||
};
|
||||
|
||||
if is_shutdown_requested(shutdown_requested) {
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
let events = session.open()?;
|
||||
state.process_events(&events, &mut stats);
|
||||
render_if_due(&mut terminal, &state, &stats, &mut next_render, true)?;
|
||||
|
||||
let mut interrupted = false;
|
||||
// Keep this in lockstep with run_stream: send due probes, drain available
|
||||
// replies, poll timeouts, sleep toward the next absolute send deadline,
|
||||
// then perform the same final drain, timeout poll, and close sequence.
|
||||
while session.should_continue(shutdown_requested) {
|
||||
if handle_input(&mut state, shutdown_requested)? {
|
||||
render_if_due(&mut terminal, &state, &stats, &mut next_render, true)?;
|
||||
}
|
||||
if state.quit_requested {
|
||||
interrupted = true;
|
||||
break;
|
||||
}
|
||||
|
||||
let events = session.step(shutdown_requested)?;
|
||||
state.process_events(&events, &mut stats);
|
||||
|
||||
if is_shutdown_requested(shutdown_requested) {
|
||||
interrupted = true;
|
||||
break;
|
||||
}
|
||||
|
||||
render_if_due(&mut terminal, &state, &stats, &mut next_render, false)?;
|
||||
wait_for_tui_activity(
|
||||
session.next_send_deadline(),
|
||||
&mut next_render,
|
||||
&mut state,
|
||||
&stats,
|
||||
&mut terminal,
|
||||
shutdown_requested,
|
||||
)?;
|
||||
}
|
||||
interrupted |= is_shutdown_requested(shutdown_requested);
|
||||
|
||||
if interrupted {
|
||||
state.set_status(TuiStatus::Interrupted);
|
||||
render_if_due(&mut terminal, &state, &stats, &mut next_render, true)?;
|
||||
}
|
||||
|
||||
if session.should_drain_final(interrupted) {
|
||||
state.set_status(TuiStatus::Draining);
|
||||
let mut drain_render = Instant::now();
|
||||
session.drain_final(|events| {
|
||||
state.process_events(events, &mut stats);
|
||||
let _ = render_if_due(&mut terminal, &state, &stats, &mut drain_render, false);
|
||||
})?;
|
||||
render_if_due(&mut terminal, &state, &stats, &mut next_render, true)?;
|
||||
}
|
||||
|
||||
let events = session.poll_timeouts()?;
|
||||
state.process_events(&events, &mut stats);
|
||||
|
||||
state.set_status(TuiStatus::Closing);
|
||||
render_if_due(&mut terminal, &state, &stats, &mut next_render, true)?;
|
||||
|
||||
let events = session.close()?;
|
||||
state.process_events(&events, &mut stats);
|
||||
state.set_status(TuiStatus::Complete);
|
||||
render_if_due(&mut terminal, &state, &stats, &mut next_render, true)?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn handle_input(state: &mut TuiState, shutdown_requested: &AtomicBool) -> io::Result<bool> {
|
||||
let mut force_render = false;
|
||||
while event::poll(Duration::ZERO)? {
|
||||
let Event::Key(key) = event::read()? else {
|
||||
continue;
|
||||
};
|
||||
if key.kind == KeyEventKind::Release {
|
||||
continue;
|
||||
}
|
||||
match key.code {
|
||||
KeyCode::Char('q') => {
|
||||
state.quit_requested = true;
|
||||
shutdown_requested.store(true, Ordering::Relaxed);
|
||||
force_render = true;
|
||||
}
|
||||
KeyCode::Char('c') if key.modifiers.contains(KeyModifiers::CONTROL) => {
|
||||
state.quit_requested = true;
|
||||
shutdown_requested.store(true, Ordering::Relaxed);
|
||||
force_render = true;
|
||||
}
|
||||
KeyCode::Char('r') => {
|
||||
state.clear_visible_history();
|
||||
force_render = true;
|
||||
}
|
||||
KeyCode::Char('p') => {
|
||||
state.toggle_pause();
|
||||
force_render = true;
|
||||
}
|
||||
KeyCode::Char('g') => {
|
||||
state.cycle_graph_mode();
|
||||
force_render = true;
|
||||
}
|
||||
KeyCode::Char('f') => {
|
||||
state.toggle_full_graph();
|
||||
force_render = true;
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
Ok(force_render)
|
||||
}
|
||||
|
||||
fn render_if_due(
|
||||
terminal: &mut TuiTerminal,
|
||||
state: &TuiState,
|
||||
stats: &StatsCollector,
|
||||
next_render: &mut Instant,
|
||||
force: bool,
|
||||
) -> io::Result<()> {
|
||||
let now = Instant::now();
|
||||
if !should_render(now, *next_render, state.paused, force) {
|
||||
return Ok(());
|
||||
}
|
||||
terminal.draw(state, &stats.snapshot())?;
|
||||
*next_render = now + RENDER_INTERVAL;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn wait_for_tui_activity(
|
||||
next_send_deadline: Option<Instant>,
|
||||
next_render: &mut Instant,
|
||||
state: &mut TuiState,
|
||||
stats: &StatsCollector,
|
||||
terminal: &mut TuiTerminal,
|
||||
shutdown_requested: &AtomicBool,
|
||||
) -> io::Result<()> {
|
||||
let wait_for = tui_wait_duration(next_send_deadline, *next_render, state.paused);
|
||||
if wait_for.is_zero() || !event::poll(wait_for)? {
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
if handle_input(state, shutdown_requested)? {
|
||||
render_if_due(terminal, state, stats, next_render, true)?;
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn tui_wait_duration(
|
||||
next_send_deadline: Option<Instant>,
|
||||
next_render: Instant,
|
||||
paused: bool,
|
||||
) -> Duration {
|
||||
let now = Instant::now();
|
||||
let send_wait = next_send_deadline
|
||||
.map(|deadline| deadline.saturating_duration_since(now))
|
||||
.unwrap_or(IDLE_SLEEP);
|
||||
let render_wait = if paused {
|
||||
send_wait
|
||||
} else {
|
||||
next_render.saturating_duration_since(now)
|
||||
};
|
||||
send_wait.min(render_wait).min(TUI_WAIT_SLICE)
|
||||
}
|
||||
@@ -1,18 +1,15 @@
|
||||
use std::{
|
||||
collections::VecDeque,
|
||||
io::{self, Stdout},
|
||||
sync::atomic::{AtomicBool, Ordering},
|
||||
time::{Duration, Instant},
|
||||
};
|
||||
|
||||
use crossterm::{
|
||||
cursor::Show,
|
||||
event::{self, Event, KeyCode, KeyEventKind, KeyModifiers},
|
||||
execute,
|
||||
terminal::{disable_raw_mode, enable_raw_mode, EnterAlternateScreen, LeaveAlternateScreen},
|
||||
};
|
||||
use irtt_cli::CliArgs;
|
||||
use irtt_client::{Client, ClientEvent, NegotiatedParams, SignedDuration};
|
||||
use irtt_client::{ClientEvent, NegotiatedParams, SignedDuration};
|
||||
use irtt_stats::{Snapshot, StatsCollector, TimeStats};
|
||||
use ratatui::{
|
||||
backend::CrosstermBackend,
|
||||
@@ -24,254 +21,20 @@ use ratatui::{
|
||||
Frame, Terminal,
|
||||
};
|
||||
|
||||
use crate::{
|
||||
final_drain_duration, is_shutdown_requested, open_event, should_continue_run,
|
||||
should_drain_final, should_send_probe, stats_config, IDLE_SLEEP, RECV_BUDGET,
|
||||
};
|
||||
use crate::{cmd::tui::args::TuiArgs, shared::client::expected_probe_count};
|
||||
|
||||
const RENDER_INTERVAL: Duration = Duration::from_millis(250);
|
||||
const TUI_WAIT_SLICE: Duration = Duration::from_millis(20);
|
||||
const HISTORY_LIMIT: usize = 240;
|
||||
const RECENT_EVENT_LIMIT: usize = 80;
|
||||
const MIN_CHART_POINTS: usize = 12;
|
||||
const MIN_WIDTH: u16 = 56;
|
||||
const MIN_HEIGHT: u16 = 18;
|
||||
|
||||
pub fn run_tui(
|
||||
args: CliArgs,
|
||||
shutdown_requested: &AtomicBool,
|
||||
) -> Result<(), Box<dyn std::error::Error>> {
|
||||
let continuous = args.is_continuous();
|
||||
let mut terminal = TuiTerminal::enter()?;
|
||||
let mut state = TuiState::new(TuiConfig::from_args(&args));
|
||||
let mut stats = StatsCollector::new(stats_config(continuous));
|
||||
let mut next_render = Instant::now();
|
||||
|
||||
if is_shutdown_requested(shutdown_requested) {
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
state.set_status(TuiStatus::Opening);
|
||||
render_if_due(&mut terminal, &state, &stats, &mut next_render, true)?;
|
||||
|
||||
let mut client = match Client::connect(args.to_client_config()) {
|
||||
Ok(client) => client,
|
||||
Err(err) => {
|
||||
state.set_error(err.to_string());
|
||||
render_if_due(&mut terminal, &state, &stats, &mut next_render, true)?;
|
||||
return Err(Box::new(err));
|
||||
}
|
||||
};
|
||||
|
||||
if is_shutdown_requested(shutdown_requested) {
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
let open = client.open()?;
|
||||
state.process_event(open_event(&open), &mut stats);
|
||||
render_if_due(&mut terminal, &state, &stats, &mut next_render, true)?;
|
||||
|
||||
let mut interrupted = false;
|
||||
// Keep this in lockstep with run_stream: send due probes, drain available
|
||||
// replies, poll timeouts, sleep toward the next absolute send deadline,
|
||||
// then perform the same final drain, timeout poll, and close sequence.
|
||||
while should_continue_run(continuous, client.next_send_deadline(), shutdown_requested) {
|
||||
if handle_input(&mut state, shutdown_requested)? {
|
||||
render_if_due(&mut terminal, &state, &stats, &mut next_render, true)?;
|
||||
}
|
||||
if state.quit_requested {
|
||||
interrupted = true;
|
||||
break;
|
||||
}
|
||||
|
||||
if should_send_probe(client.next_send_deadline(), shutdown_requested) {
|
||||
let events = client.send_probe()?;
|
||||
state.process_events(&events, &mut stats);
|
||||
}
|
||||
|
||||
let events = client.recv_available(RECV_BUDGET)?;
|
||||
state.process_events(&events, &mut stats);
|
||||
|
||||
let events = client.poll_timeouts()?;
|
||||
state.process_events(&events, &mut stats);
|
||||
|
||||
if is_shutdown_requested(shutdown_requested) {
|
||||
interrupted = true;
|
||||
break;
|
||||
}
|
||||
|
||||
render_if_due(&mut terminal, &state, &stats, &mut next_render, false)?;
|
||||
wait_for_tui_activity(
|
||||
client.next_send_deadline(),
|
||||
&mut next_render,
|
||||
&mut state,
|
||||
&stats,
|
||||
&mut terminal,
|
||||
shutdown_requested,
|
||||
)?;
|
||||
}
|
||||
interrupted |= is_shutdown_requested(shutdown_requested);
|
||||
|
||||
if interrupted {
|
||||
state.set_status(TuiStatus::Interrupted);
|
||||
render_if_due(&mut terminal, &state, &stats, &mut next_render, true)?;
|
||||
}
|
||||
|
||||
if should_drain_final(continuous, interrupted) {
|
||||
drain_final_replies(&mut client, &mut state, &mut stats, &mut terminal)?;
|
||||
}
|
||||
|
||||
let events = client.poll_timeouts()?;
|
||||
state.process_events(&events, &mut stats);
|
||||
|
||||
state.set_status(TuiStatus::Closing);
|
||||
render_if_due(&mut terminal, &state, &stats, &mut next_render, true)?;
|
||||
|
||||
let events = client.close()?;
|
||||
state.process_events(&events, &mut stats);
|
||||
state.set_status(TuiStatus::Complete);
|
||||
render_if_due(&mut terminal, &state, &stats, &mut next_render, true)?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn drain_final_replies(
|
||||
client: &mut Client,
|
||||
state: &mut TuiState,
|
||||
stats: &mut StatsCollector,
|
||||
terminal: &mut TuiTerminal,
|
||||
) -> Result<(), Box<dyn std::error::Error>> {
|
||||
state.set_status(TuiStatus::Draining);
|
||||
let deadline = Instant::now() + final_drain_duration(client.probe_timeout());
|
||||
let mut next_render = Instant::now();
|
||||
loop {
|
||||
let mut received = false;
|
||||
|
||||
let events = client.recv_available(RECV_BUDGET)?;
|
||||
received |= !events.is_empty();
|
||||
state.process_events(&events, stats);
|
||||
|
||||
let events = client.poll_timeouts()?;
|
||||
received |= !events.is_empty();
|
||||
state.process_events(&events, stats);
|
||||
|
||||
render_if_due(terminal, state, stats, &mut next_render, false)?;
|
||||
|
||||
if client.is_run_complete() || Instant::now() >= deadline {
|
||||
break;
|
||||
}
|
||||
|
||||
if !received {
|
||||
std::thread::sleep(IDLE_SLEEP);
|
||||
}
|
||||
}
|
||||
render_if_due(terminal, state, stats, &mut next_render, true)?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn handle_input(state: &mut TuiState, shutdown_requested: &AtomicBool) -> io::Result<bool> {
|
||||
let mut force_render = false;
|
||||
while event::poll(Duration::ZERO)? {
|
||||
let Event::Key(key) = event::read()? else {
|
||||
continue;
|
||||
};
|
||||
if key.kind == KeyEventKind::Release {
|
||||
continue;
|
||||
}
|
||||
match key.code {
|
||||
KeyCode::Char('q') => {
|
||||
state.quit_requested = true;
|
||||
shutdown_requested.store(true, Ordering::Relaxed);
|
||||
force_render = true;
|
||||
}
|
||||
KeyCode::Char('c') if key.modifiers.contains(KeyModifiers::CONTROL) => {
|
||||
state.quit_requested = true;
|
||||
shutdown_requested.store(true, Ordering::Relaxed);
|
||||
force_render = true;
|
||||
}
|
||||
KeyCode::Char('r') => {
|
||||
state.clear_visible_history();
|
||||
force_render = true;
|
||||
}
|
||||
KeyCode::Char('p') => {
|
||||
state.toggle_pause();
|
||||
force_render = true;
|
||||
}
|
||||
KeyCode::Char('g') => {
|
||||
state.cycle_graph_mode();
|
||||
force_render = true;
|
||||
}
|
||||
KeyCode::Char('f') => {
|
||||
state.toggle_full_graph();
|
||||
force_render = true;
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
Ok(force_render)
|
||||
}
|
||||
|
||||
fn render_if_due(
|
||||
terminal: &mut TuiTerminal,
|
||||
state: &TuiState,
|
||||
stats: &StatsCollector,
|
||||
next_render: &mut Instant,
|
||||
force: bool,
|
||||
) -> io::Result<()> {
|
||||
let now = Instant::now();
|
||||
if !should_render(now, *next_render, state.paused, force) {
|
||||
return Ok(());
|
||||
}
|
||||
terminal.draw(state, &stats.snapshot())?;
|
||||
*next_render = now + RENDER_INTERVAL;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn wait_for_tui_activity(
|
||||
next_send_deadline: Option<Instant>,
|
||||
next_render: &mut Instant,
|
||||
state: &mut TuiState,
|
||||
stats: &StatsCollector,
|
||||
terminal: &mut TuiTerminal,
|
||||
shutdown_requested: &AtomicBool,
|
||||
) -> io::Result<()> {
|
||||
let wait_for = tui_wait_duration(next_send_deadline, *next_render, state.paused);
|
||||
if wait_for.is_zero() || !event::poll(wait_for)? {
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
if handle_input(state, shutdown_requested)? {
|
||||
render_if_due(terminal, state, stats, next_render, true)?;
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn tui_wait_duration(
|
||||
next_send_deadline: Option<Instant>,
|
||||
next_render: Instant,
|
||||
paused: bool,
|
||||
) -> Duration {
|
||||
let now = Instant::now();
|
||||
let send_wait = next_send_deadline
|
||||
.map(|deadline| deadline.saturating_duration_since(now))
|
||||
.unwrap_or(IDLE_SLEEP);
|
||||
let render_wait = if paused {
|
||||
send_wait
|
||||
} else {
|
||||
next_render.saturating_duration_since(now)
|
||||
};
|
||||
send_wait.min(render_wait).min(TUI_WAIT_SLICE)
|
||||
}
|
||||
|
||||
fn should_render(now: Instant, next_render: Instant, paused: bool, force: bool) -> bool {
|
||||
force || (!paused && now >= next_render)
|
||||
}
|
||||
|
||||
struct TuiTerminal {
|
||||
pub(super) struct TuiTerminal {
|
||||
terminal: Terminal<CrosstermBackend<Stdout>>,
|
||||
}
|
||||
|
||||
impl TuiTerminal {
|
||||
fn enter() -> io::Result<Self> {
|
||||
pub(super) fn enter() -> io::Result<Self> {
|
||||
enable_raw_mode()?;
|
||||
let mut stdout = io::stdout();
|
||||
if let Err(err) = execute!(stdout, EnterAlternateScreen) {
|
||||
@@ -299,7 +62,7 @@ impl TuiTerminal {
|
||||
}
|
||||
}
|
||||
|
||||
fn draw(&mut self, state: &TuiState, snapshot: &Snapshot) -> io::Result<()> {
|
||||
pub(super) fn draw(&mut self, state: &TuiState, snapshot: &Snapshot) -> io::Result<()> {
|
||||
self.terminal
|
||||
.draw(|frame| draw_dashboard(frame, state, snapshot))
|
||||
.map(|_| ())
|
||||
@@ -315,7 +78,7 @@ impl Drop for TuiTerminal {
|
||||
}
|
||||
|
||||
#[derive(Debug)]
|
||||
struct TuiState {
|
||||
pub(super) struct TuiState {
|
||||
remote: Option<String>,
|
||||
session: Option<String>,
|
||||
status: TuiStatus,
|
||||
@@ -328,12 +91,12 @@ struct TuiState {
|
||||
last_warning: Option<String>,
|
||||
graph_mode: GraphMode,
|
||||
full_graph: bool,
|
||||
paused: bool,
|
||||
quit_requested: bool,
|
||||
pub(super) paused: bool,
|
||||
pub(super) quit_requested: bool,
|
||||
}
|
||||
|
||||
impl TuiState {
|
||||
fn new(config: TuiConfig) -> Self {
|
||||
pub(super) fn new(config: TuiConfig) -> Self {
|
||||
Self {
|
||||
remote: None,
|
||||
session: None,
|
||||
@@ -352,13 +115,13 @@ impl TuiState {
|
||||
}
|
||||
}
|
||||
|
||||
fn process_events(&mut self, events: &[ClientEvent], stats: &mut StatsCollector) {
|
||||
pub(super) fn process_events(&mut self, events: &[ClientEvent], stats: &mut StatsCollector) {
|
||||
for event in events {
|
||||
self.process_event(event, stats);
|
||||
}
|
||||
}
|
||||
|
||||
fn process_event(&mut self, event: &ClientEvent, stats: &mut StatsCollector) {
|
||||
pub(super) fn process_event(&mut self, event: &ClientEvent, stats: &mut StatsCollector) {
|
||||
stats.process(event);
|
||||
match event {
|
||||
ClientEvent::SessionStarted {
|
||||
@@ -464,30 +227,30 @@ impl TuiState {
|
||||
}
|
||||
}
|
||||
|
||||
fn set_status(&mut self, status: TuiStatus) {
|
||||
pub(super) fn set_status(&mut self, status: TuiStatus) {
|
||||
self.status = status;
|
||||
}
|
||||
|
||||
fn set_error(&mut self, message: String) {
|
||||
pub(super) fn set_error(&mut self, message: String) {
|
||||
self.status = TuiStatus::Error;
|
||||
self.last_warning = Some(message.clone());
|
||||
self.push_event(format!("error {message}"));
|
||||
}
|
||||
|
||||
fn clear_visible_history(&mut self) {
|
||||
pub(super) fn clear_visible_history(&mut self) {
|
||||
self.graph_history.clear();
|
||||
self.push_event("visible graph history reset".to_owned());
|
||||
}
|
||||
|
||||
fn toggle_pause(&mut self) {
|
||||
pub(super) fn toggle_pause(&mut self) {
|
||||
self.paused = !self.paused;
|
||||
}
|
||||
|
||||
fn cycle_graph_mode(&mut self) {
|
||||
pub(super) fn cycle_graph_mode(&mut self) {
|
||||
self.graph_mode = self.graph_mode.next();
|
||||
}
|
||||
|
||||
fn toggle_full_graph(&mut self) {
|
||||
pub(super) fn toggle_full_graph(&mut self) {
|
||||
self.full_graph = !self.full_graph;
|
||||
}
|
||||
|
||||
@@ -507,7 +270,7 @@ impl Default for TuiState {
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
struct TuiConfig {
|
||||
pub(super) struct TuiConfig {
|
||||
interval: Duration,
|
||||
duration: Option<Duration>,
|
||||
timeout: Duration,
|
||||
@@ -515,7 +278,7 @@ struct TuiConfig {
|
||||
}
|
||||
|
||||
impl TuiConfig {
|
||||
fn from_args(args: &CliArgs) -> Self {
|
||||
pub(super) fn from_args(args: &TuiArgs) -> Self {
|
||||
Self {
|
||||
interval: args.interval,
|
||||
duration: (!args.is_continuous()).then_some(args.duration),
|
||||
@@ -538,7 +301,7 @@ impl Default for TuiConfig {
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
enum TuiStatus {
|
||||
pub(super) enum TuiStatus {
|
||||
Opening,
|
||||
Running,
|
||||
Draining,
|
||||
@@ -563,7 +326,7 @@ impl TuiStatus {
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
enum GraphMode {
|
||||
pub(super) enum GraphMode {
|
||||
Rtt,
|
||||
OneWay,
|
||||
Combined,
|
||||
@@ -591,7 +354,7 @@ impl GraphMode {
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
struct GraphSample {
|
||||
pub(super) struct GraphSample {
|
||||
seq: u32,
|
||||
effective_ns: i128,
|
||||
raw_ns: i128,
|
||||
@@ -602,7 +365,7 @@ struct GraphSample {
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
struct LastSample {
|
||||
pub(super) struct LastSample {
|
||||
seq: u32,
|
||||
raw_ns: i128,
|
||||
adjusted_ns: Option<i128>,
|
||||
@@ -619,20 +382,7 @@ fn push_bounded<T>(items: &mut VecDeque<T>, item: T, limit: usize) {
|
||||
items.push_back(item);
|
||||
}
|
||||
|
||||
fn expected_probe_count(duration: Duration, interval: Duration) -> u64 {
|
||||
let interval_nanos = interval.as_nanos();
|
||||
if interval_nanos == 0 {
|
||||
return u64::MAX;
|
||||
}
|
||||
|
||||
let expected = duration
|
||||
.as_nanos()
|
||||
.saturating_add(interval_nanos.saturating_sub(1))
|
||||
/ interval_nanos;
|
||||
expected.min(u128::from(u64::MAX)) as u64
|
||||
}
|
||||
|
||||
fn draw_dashboard(frame: &mut Frame<'_>, state: &TuiState, snapshot: &Snapshot) {
|
||||
pub(super) fn draw_dashboard(frame: &mut Frame<'_>, state: &TuiState, snapshot: &Snapshot) {
|
||||
let area = frame.area();
|
||||
if area.width < MIN_WIDTH || area.height < MIN_HEIGHT {
|
||||
frame.render_widget(too_small(), area);
|
||||
@@ -651,6 +401,10 @@ fn draw_dashboard(frame: &mut Frame<'_>, state: &TuiState, snapshot: &Snapshot)
|
||||
}
|
||||
}
|
||||
|
||||
pub(super) fn should_render(now: Instant, next_render: Instant, paused: bool, force: bool) -> bool {
|
||||
force || (!paused && now >= next_render)
|
||||
}
|
||||
|
||||
fn draw_full_graph(frame: &mut Frame<'_>, area: Rect, state: &TuiState, snapshot: &Snapshot) {
|
||||
let rows = Layout::default()
|
||||
.direction(Direction::Vertical)
|
||||
@@ -903,8 +657,14 @@ fn render_split_graph(frame: &mut Frame<'_>, area: Rect, state: &TuiState) {
|
||||
let rtt = graph_series(GraphMode::Rtt, &visible);
|
||||
let one_way = graph_series(GraphMode::OneWay, &visible);
|
||||
|
||||
render_chart(frame, rows[0], "RTT history", &visible, &rtt);
|
||||
render_chart(frame, rows[1], "one-way delay history", &visible, &one_way);
|
||||
render_chart(frame, rows[0], GraphMode::Rtt.title(), &visible, &rtt);
|
||||
render_chart(
|
||||
frame,
|
||||
rows[1],
|
||||
GraphMode::OneWay.title(),
|
||||
&visible,
|
||||
&one_way,
|
||||
);
|
||||
}
|
||||
|
||||
fn render_chart(
|
||||
@@ -918,7 +678,7 @@ fn render_chart(
|
||||
let note = if visible.is_empty() {
|
||||
"waiting for primary replies"
|
||||
} else {
|
||||
"n/a for negotiated timestamps/stats"
|
||||
"one-way data unavailable for negotiated timestamp mode"
|
||||
};
|
||||
frame.render_widget(
|
||||
Paragraph::new(note)
|
||||
@@ -955,22 +715,16 @@ fn render_chart(
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
enum GraphValue {
|
||||
EffectiveRtt,
|
||||
RawRtt,
|
||||
AdjustedRtt,
|
||||
ClientToServer,
|
||||
ServerToClient,
|
||||
ServerProcessing,
|
||||
}
|
||||
|
||||
impl GraphValue {
|
||||
fn name(self) -> &'static str {
|
||||
match self {
|
||||
Self::EffectiveRtt => "eff RTT",
|
||||
Self::RawRtt => "raw RTT",
|
||||
Self::AdjustedRtt => "adj RTT",
|
||||
Self::ClientToServer => "c2s",
|
||||
Self::ServerToClient => "s2c",
|
||||
Self::ServerProcessing => "srv proc",
|
||||
}
|
||||
}
|
||||
|
||||
@@ -979,22 +733,16 @@ impl GraphValue {
|
||||
Self::EffectiveRtt => Style::default()
|
||||
.fg(Color::Cyan)
|
||||
.add_modifier(Modifier::BOLD),
|
||||
Self::RawRtt => Style::default().fg(Color::Yellow),
|
||||
Self::AdjustedRtt => Style::default().fg(Color::Green),
|
||||
Self::ClientToServer => Style::default().fg(Color::Magenta),
|
||||
Self::ServerToClient => Style::default().fg(Color::LightBlue),
|
||||
Self::ServerProcessing => Style::default().fg(Color::LightRed),
|
||||
}
|
||||
}
|
||||
|
||||
fn value_ns(self, sample: &GraphSample) -> Option<i128> {
|
||||
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,
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1002,9 +750,9 @@ impl GraphValue {
|
||||
impl GraphMode {
|
||||
fn title(self) -> &'static str {
|
||||
match self {
|
||||
Self::Rtt => "RTT history",
|
||||
Self::OneWay => "one-way delay history",
|
||||
Self::Combined => "RTT + one-way history",
|
||||
Self::Rtt => "RTT history — effective",
|
||||
Self::OneWay => "one-way delay — c2s / s2c",
|
||||
Self::Combined => "RTT + one-way correlation",
|
||||
Self::Split => "split history",
|
||||
}
|
||||
}
|
||||
@@ -1029,16 +777,8 @@ fn visible_history_window(history: &VecDeque<GraphSample>, chart_width: u16) ->
|
||||
|
||||
fn graph_series(mode: GraphMode, visible: &[&GraphSample]) -> Vec<ChartSeries> {
|
||||
let values: &[GraphValue] = match mode {
|
||||
GraphMode::Rtt => &[
|
||||
GraphValue::EffectiveRtt,
|
||||
GraphValue::RawRtt,
|
||||
GraphValue::AdjustedRtt,
|
||||
],
|
||||
GraphMode::OneWay => &[
|
||||
GraphValue::ClientToServer,
|
||||
GraphValue::ServerToClient,
|
||||
GraphValue::ServerProcessing,
|
||||
],
|
||||
GraphMode::Rtt => &[GraphValue::EffectiveRtt],
|
||||
GraphMode::OneWay => &[GraphValue::ClientToServer, GraphValue::ServerToClient],
|
||||
GraphMode::Combined => &[
|
||||
GraphValue::EffectiveRtt,
|
||||
GraphValue::ClientToServer,
|
||||
@@ -1419,6 +1159,18 @@ mod tests {
|
||||
}
|
||||
}
|
||||
|
||||
fn graph_sample_with_timing(seq: u32, effective_ns: i128) -> GraphSample {
|
||||
GraphSample {
|
||||
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 {
|
||||
value: GraphValue::EffectiveRtt,
|
||||
@@ -1618,10 +1370,38 @@ mod tests {
|
||||
assert!(one_way.is_empty());
|
||||
assert_eq!(
|
||||
rtt.iter().map(|series| series.value).collect::<Vec<_>>(),
|
||||
vec![GraphValue::EffectiveRtt]
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn graph_modes_use_readable_default_series() {
|
||||
let visible_samples = [graph_sample_with_timing(1, 3_000_000)];
|
||||
let visible: Vec<_> = visible_samples.iter().collect();
|
||||
|
||||
assert_eq!(
|
||||
graph_series(GraphMode::Rtt, &visible)
|
||||
.iter()
|
||||
.map(|series| series.value)
|
||||
.collect::<Vec<_>>(),
|
||||
vec![GraphValue::EffectiveRtt]
|
||||
);
|
||||
assert_eq!(
|
||||
graph_series(GraphMode::OneWay, &visible)
|
||||
.iter()
|
||||
.map(|series| series.value)
|
||||
.collect::<Vec<_>>(),
|
||||
vec![GraphValue::ClientToServer, GraphValue::ServerToClient]
|
||||
);
|
||||
assert_eq!(
|
||||
graph_series(GraphMode::Combined, &visible)
|
||||
.iter()
|
||||
.map(|series| series.value)
|
||||
.collect::<Vec<_>>(),
|
||||
vec![
|
||||
GraphValue::EffectiveRtt,
|
||||
GraphValue::RawRtt,
|
||||
GraphValue::AdjustedRtt
|
||||
GraphValue::ClientToServer,
|
||||
GraphValue::ServerToClient
|
||||
]
|
||||
);
|
||||
}
|
||||
Reference in new issue
Block a user