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>
This commit is contained in:
fanyang
2026-01-28 14:50:14 +08:00
committed by GitHub
parent 518d26b25f
commit 977e502150
3 changed files with 258 additions and 9 deletions
Generated
+2
View File
@@ -2240,6 +2240,7 @@ dependencies = [
"sys-locale", "sys-locale",
"tabled", "tabled",
"tempfile", "tempfile",
"terminal_size",
"thiserror 1.0.63", "thiserror 1.0.63",
"thunk-rs", "thunk-rs",
"tikv-jemalloc-ctl", "tikv-jemalloc-ctl",
@@ -2258,6 +2259,7 @@ dependencies = [
"tracing", "tracing",
"tracing-subscriber", "tracing-subscriber",
"tun-easytier", "tun-easytier",
"unicode-width 0.1.11",
"url", "url",
"uuid", "uuid",
"version-compare", "version-compare",
+2
View File
@@ -160,6 +160,8 @@ x25519-dalek = "2.0"
# for cli # for cli
tabled = "0.16" tabled = "0.16"
humansize = "2.1.3" humansize = "2.1.3"
terminal_size = "0.4"
unicode-width = "0.1"
base64 = "0.22" base64 = "0.22"
+254 -9
View File
@@ -15,7 +15,9 @@ use easytier::ShellType;
use humansize::format_size; use humansize::format_size;
use rust_i18n::t; use rust_i18n::t;
use service_manager::*; 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 easytier::service_manager::{Service, ServiceInstallOptions};
use tokio::time::timeout; use tokio::time::timeout;
@@ -86,6 +88,13 @@ struct Cli {
)] )]
output_format: OutputFormat, output_format: OutputFormat,
#[arg(
long = "no-trunc",
default_value = "false",
help = "disable column truncation"
)]
no_trunc: bool,
#[command(flatten)] #[command(flatten)]
instance_select: InstanceSelectArgs, instance_select: InstanceSelectArgs,
@@ -388,6 +397,7 @@ struct CommandHandler<'a> {
client: tokio::sync::Mutex<RpcClient>, client: tokio::sync::Mutex<RpcClient>,
verbose: bool, verbose: bool,
output_format: &'a OutputFormat, output_format: &'a OutputFormat,
no_trunc: bool,
instance_selector: InstanceIdentifier, 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(()) 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(()) Ok(())
} }
@@ -1124,7 +1146,7 @@ impl CommandHandler<'_> {
}) })
.collect(); .collect();
print_output(&items, self.output_format)?; print_output(&items, self.output_format, &[], &[], self.no_trunc)?;
Ok(()) Ok(())
} }
@@ -1373,13 +1395,45 @@ impl CommandHandler<'_> {
} }
} }
fn print_output<T>(items: &[T], format: &OutputFormat) -> Result<(), Error> fn print_output<T>(
items: &[T],
format: &OutputFormat,
optional_columns: &[&str],
drop_columns: &[&str],
no_trunc: bool,
) -> Result<(), Error>
where where
T: tabled::Tabled + serde::Serialize, T: tabled::Tabled + serde::Serialize,
{ {
match format { match format {
OutputFormat::Table => { 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::<Vec<_>>();
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 => { OutputFormat::Json => {
println!("{}", serde_json::to_string_pretty(items)?); println!("{}", serde_json::to_string_pretty(items)?);
@@ -1388,6 +1442,178 @@ where
Ok(()) Ok(())
} }
fn terminal_table_width() -> Option<usize> {
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<usize>,
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<T>(items: &[T]) -> Vec<usize>
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<usize> {
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<usize>,
drop_indices: &[usize],
col_widths: &[usize],
) -> (Vec<bool>, Vec<usize>, 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::<usize>();
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::<Vec<_>>();
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] #[tokio::main]
#[tracing::instrument] #[tracing::instrument]
async fn main() -> Result<(), Error> { async fn main() -> Result<(), Error> {
@@ -1404,6 +1630,7 @@ async fn main() -> Result<(), Error> {
client: tokio::sync::Mutex::new(client), client: tokio::sync::Mutex::new(client),
verbose: cli.verbose, verbose: cli.verbose,
output_format: &cli.output_format, output_format: &cli.output_format,
no_trunc: cli.no_trunc,
instance_selector: (&cli.instance_select).into(), 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 => { SubCommand::VpnPortal => {
let vpn_portal_client = handler.get_vpn_portal_client().await?; let vpn_portal_client = handler.get_vpn_portal_client().await?;
@@ -1816,7 +2049,13 @@ async fn main() -> Result<(), Error> {
}) })
.collect::<Vec<_>>(); .collect::<Vec<_>>();
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 { SubCommand::Acl(acl_args) => match &acl_args.sub_command {
Some(AclSubCommand::Stats) | None => { Some(AclSubCommand::Stats) | None => {
@@ -1925,7 +2164,13 @@ async fn main() -> Result<(), Error> {
}) })
.collect(); .collect();
print_output(&table_rows, &cli.output_format)? print_output(
&table_rows,
&cli.output_format,
&["labels"],
&["labels"],
cli.no_trunc,
)?
} }
} }
Some(StatsSubCommand::Prometheus) => { Some(StatsSubCommand::Prometheus) => {