From 977e502150b32a09491f4283fd09937934b03a0f Mon Sep 17 00:00:00 2001 From: fanyang Date: Wed, 28 Jan 2026 14:50:14 +0800 Subject: [PATCH] feat(cli): add column truncation controls (#1838) - drop low-priority columns when tables exceed terminal width - truncate optional columns to fit remaining width - add --no-trunc flag to disable truncation - compute column widths using unicode display width Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> --- Cargo.lock | 2 + easytier/Cargo.toml | 2 + easytier/src/easytier-cli.rs | 263 +++++++++++++++++++++++++++++++++-- 3 files changed, 258 insertions(+), 9 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index b42e321d..1496055c 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -2240,6 +2240,7 @@ dependencies = [ "sys-locale", "tabled", "tempfile", + "terminal_size", "thiserror 1.0.63", "thunk-rs", "tikv-jemalloc-ctl", @@ -2258,6 +2259,7 @@ dependencies = [ "tracing", "tracing-subscriber", "tun-easytier", + "unicode-width 0.1.11", "url", "uuid", "version-compare", diff --git a/easytier/Cargo.toml b/easytier/Cargo.toml index c8abff83..e271aefb 100644 --- a/easytier/Cargo.toml +++ b/easytier/Cargo.toml @@ -160,6 +160,8 @@ x25519-dalek = "2.0" # for cli tabled = "0.16" humansize = "2.1.3" +terminal_size = "0.4" +unicode-width = "0.1" base64 = "0.22" diff --git a/easytier/src/easytier-cli.rs b/easytier/src/easytier-cli.rs index 544a9d65..923b9a87 100644 --- a/easytier/src/easytier-cli.rs +++ b/easytier/src/easytier-cli.rs @@ -15,7 +15,9 @@ use easytier::ShellType; use humansize::format_size; use rust_i18n::t; use service_manager::*; -use tabled::settings::Style; +use tabled::settings::{location::ByColumnName, object::Columns, Disable, Modify, Style, Width}; +use terminal_size::{terminal_size, Width as TerminalWidth}; +use unicode_width::UnicodeWidthStr; use easytier::service_manager::{Service, ServiceInstallOptions}; use tokio::time::timeout; @@ -86,6 +88,13 @@ struct Cli { )] output_format: OutputFormat, + #[arg( + long = "no-trunc", + default_value = "false", + help = "disable column truncation" + )] + no_trunc: bool, + #[command(flatten)] instance_select: InstanceSelectArgs, @@ -388,6 +397,7 @@ struct CommandHandler<'a> { client: tokio::sync::Mutex, verbose: bool, output_format: &'a OutputFormat, + no_trunc: bool, instance_selector: InstanceIdentifier, } @@ -707,7 +717,13 @@ impl CommandHandler<'_> { } }); - print_output(&items, self.output_format)?; + print_output( + &items, + self.output_format, + &["tunnel", "version"], + &["version", "tunnel", "nat", "tx", "rx", "loss", "lat(ms)"], + self.no_trunc, + )?; Ok(()) } @@ -942,7 +958,13 @@ impl CommandHandler<'_> { }); } - print_output(&items, self.output_format)?; + print_output( + &items, + self.output_format, + &["proxy_cidrs", "version"], + &["proxy_cidrs", "version"], + self.no_trunc, + )?; Ok(()) } @@ -1124,7 +1146,7 @@ impl CommandHandler<'_> { }) .collect(); - print_output(&items, self.output_format)?; + print_output(&items, self.output_format, &[], &[], self.no_trunc)?; Ok(()) } @@ -1373,13 +1395,45 @@ impl CommandHandler<'_> { } } -fn print_output(items: &[T], format: &OutputFormat) -> Result<(), Error> +fn print_output( + items: &[T], + format: &OutputFormat, + optional_columns: &[&str], + drop_columns: &[&str], + no_trunc: bool, +) -> Result<(), Error> where T: tabled::Tabled + serde::Serialize, { match format { OutputFormat::Table => { - println!("{}", tabled::Table::new(items).with(Style::markdown())); + let mut table = tabled::Table::new(items); + table.with(Style::markdown()); + if no_trunc { + println!("{}", table); + return Ok(()); + } + let headers = T::headers() + .iter() + .map(|header| header.as_ref().to_string()) + .collect::>(); + let col_widths = compute_column_widths(items); + let terminal_width = terminal_table_width(); + let drop_indices = header_indices(&headers, drop_columns); + let optional_indices = header_indices(&headers, optional_columns); + let (active, drop_indices, total_width) = + select_columns_to_drop(terminal_width, &drop_indices, &col_widths); + apply_column_drops(&mut table, &drop_indices); + apply_optional_column_truncation( + &mut table, + terminal_width, + &headers, + &optional_indices, + &col_widths, + &active, + total_width, + ); + println!("{}", table); } OutputFormat::Json => { println!("{}", serde_json::to_string_pretty(items)?); @@ -1388,6 +1442,178 @@ where Ok(()) } +fn terminal_table_width() -> Option { + let (TerminalWidth(width), _) = terminal_size()?; + let width = width as usize; + // Avoid wrapping at the last column which can still trigger a hard line break. + width.checked_sub(1) +} + +fn apply_optional_column_truncation( + table: &mut tabled::Table, + terminal_width: Option, + headers: &[String], + optional_indices: &[usize], + col_widths: &[usize], + active: &[bool], + total_width: usize, +) { + let Some(terminal_width) = terminal_width else { + return; + }; + if optional_indices.is_empty() || total_width <= terminal_width { + return; + } + + let targets = optional_column_targets(terminal_width, optional_indices, col_widths, active); + for (index, width) in targets { + if let Some(name) = headers.get(index) { + table.with( + Modify::new(ByColumnName::new(name)).with(Width::truncate(width).suffix("...")), + ); + } + } +} + +fn apply_column_drops(table: &mut tabled::Table, drop_indices: &[usize]) { + let mut indices = drop_indices.to_vec(); + indices.sort_unstable_by(|a, b| b.cmp(a)); + for index in indices { + table.with(Disable::column(Columns::single(index))); + } +} + +fn compute_column_widths(items: &[T]) -> Vec +where + T: tabled::Tabled, +{ + let mut widths = vec![0usize; T::LENGTH]; + for (idx, header) in T::headers().iter().enumerate() { + widths[idx] = widths[idx].max(text_width(header.as_ref())); + } + for item in items { + for (idx, field) in item.fields().iter().enumerate() { + widths[idx] = widths[idx].max(text_width(field.as_ref())); + } + } + widths +} + +fn text_width(text: &str) -> usize { + text.split('\n') + .map(UnicodeWidthStr::width) + .max() + .unwrap_or(0) +} + +fn header_indices(headers: &[String], names: &[&str]) -> Vec { + let mut indices = Vec::new(); + for name in names { + if let Some(index) = headers + .iter() + .position(|header| header.eq_ignore_ascii_case(name)) + { + if !indices.contains(&index) { + indices.push(index); + } + } + } + indices +} + +fn select_columns_to_drop( + terminal_width: Option, + drop_indices: &[usize], + col_widths: &[usize], +) -> (Vec, Vec, usize) { + let mut active = vec![true; col_widths.len()]; + let Some(terminal_width) = terminal_width else { + let total = table_total_width(col_widths, &active); + return (active, vec![], total); + }; + + let mut total = table_total_width(col_widths, &active); + if total <= terminal_width { + return (active, vec![], total); + } + + let mut dropped = vec![]; + for &index in drop_indices { + if total <= terminal_width { + break; + } + if active[index] { + active[index] = false; + dropped.push(index); + total = table_total_width(col_widths, &active); + } + } + + (active, dropped, total) +} + +fn table_total_width(col_widths: &[usize], active: &[bool]) -> usize { + let col_count = active.iter().filter(|value| **value).count(); + if col_count == 0 { + return 0; + } + let content_width = col_widths + .iter() + .zip(active.iter()) + .filter_map(|(width, keep)| keep.then_some(*width)) + .sum::(); + content_width + 3 * col_count + 1 +} + +fn optional_column_targets( + terminal_width: usize, + optional_indices: &[usize], + col_widths: &[usize], + active: &[bool], +) -> Vec<(usize, usize)> { + if optional_indices.is_empty() { + return vec![]; + } + + let mut is_optional = vec![false; col_widths.len()]; + for &index in optional_indices { + if let Some(flag) = is_optional.get_mut(index) { + *flag = true; + } + } + + let optional_indices = optional_indices + .iter() + .copied() + .filter(|idx| active.get(*idx).copied().unwrap_or(false)) + .collect::>(); + if optional_indices.is_empty() { + return vec![]; + } + + let col_count = active.iter().filter(|value| **value).count(); + let overhead = 3 * col_count + 1; + let mut required_width = overhead; + for (idx, width) in col_widths.iter().enumerate() { + if active.get(idx).copied().unwrap_or(false) && !is_optional[idx] { + required_width += *width; + } + } + + let remaining = terminal_width.saturating_sub(required_width); + let min_width = 6usize; + let per_column = if remaining == 0 { + min_width + } else { + (remaining / optional_indices.len()).clamp(min_width, 24) + }; + + optional_indices + .into_iter() + .map(|idx| (idx, col_widths[idx].min(per_column))) + .collect() +} + #[tokio::main] #[tracing::instrument] async fn main() -> Result<(), Error> { @@ -1404,6 +1630,7 @@ async fn main() -> Result<(), Error> { client: tokio::sync::Mutex::new(client), verbose: cli.verbose, output_format: &cli.output_format, + no_trunc: cli.no_trunc, instance_selector: (&cli.instance_select).into(), }; @@ -1608,7 +1835,13 @@ async fn main() -> Result<(), Error> { }); } - print_output(&table_rows, &cli.output_format)?; + print_output( + &table_rows, + &cli.output_format, + &["direct_peers"], + &["direct_peers"], + cli.no_trunc, + )?; } SubCommand::VpnPortal => { let vpn_portal_client = handler.get_vpn_portal_client().await?; @@ -1816,7 +2049,13 @@ async fn main() -> Result<(), Error> { }) .collect::>(); - print_output(&table_rows, &cli.output_format)?; + print_output( + &table_rows, + &cli.output_format, + &["start_time", "state", "transport_type"], + &["start_time", "state", "transport_type"], + cli.no_trunc, + )?; } SubCommand::Acl(acl_args) => match &acl_args.sub_command { Some(AclSubCommand::Stats) | None => { @@ -1925,7 +2164,13 @@ async fn main() -> Result<(), Error> { }) .collect(); - print_output(&table_rows, &cli.output_format)? + print_output( + &table_rows, + &cli.output_format, + &["labels"], + &["labels"], + cli.no_trunc, + )? } } Some(StatsSubCommand::Prometheus) => {