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)
|
||||
}
|
||||
File diff suppressed because it is too large.
Load diff
Reference in new issue
Block a user