From 9d02f00392807c5ba44b1e14dd72730d5cb53b12 Mon Sep 17 00:00:00 2001 From: trkelly23 Date: Tue, 28 Nov 2023 13:11:50 -0500 Subject: [PATCH] feat: Allow Custom Columns TUI feat: Allow custom TUI Columns feat: Allow custom TUI Column feat: allow customizing columns rebased (WIP) feat: Allow Custom Columns TUI --- src/config.rs | 28 +++++ src/config/cmd.rs | 4 + src/config/columns.rs | 198 ++++++++++++++++++++++++++++++++ src/config/constants.rs | 3 + src/config/file.rs | 2 + src/frontend.rs | 1 + src/frontend/columns.rs | 159 +++++++++++++++++++++++++ src/frontend/config.rs | 7 +- src/frontend/render/settings.rs | 10 +- src/frontend/render/table.rs | 131 ++++++++++++--------- src/main.rs | 1 + trippy-config-sample.toml | 22 +++- 12 files changed, 506 insertions(+), 60 deletions(-) create mode 100644 src/config/columns.rs create mode 100644 src/frontend/columns.rs diff --git a/src/config.rs b/src/config.rs index de896de00..abd2277ab 100644 --- a/src/config.rs +++ b/src/config.rs @@ -20,12 +20,14 @@ use trippy::tracing::{ mod binding; mod cmd; +mod columns; mod constants; mod file; mod theme; pub use binding::{TuiBindings, TuiKeyBinding}; pub use cmd::Args; +pub use columns::{TuiColumn, TuiColumns}; pub use constants::MAX_HOPS; pub use theme::{TuiColor, TuiTheme}; @@ -237,6 +239,7 @@ pub struct TrippyConfig { pub tui_privacy_max_ttl: u8, pub tui_address_mode: AddressMode, pub tui_as_mode: AsMode, + pub tui_custom_columns: TuiColumns, pub tui_icmp_extension_mode: IcmpExtensionMode, pub tui_geoip_mode: GeoIpMode, pub tui_max_addrs: Option, @@ -445,6 +448,14 @@ impl TrippyConfig { constants::DEFAULT_TUI_AS_MODE, ); + let columns = cfg_layer( + args.tui_custom_columns, + cfg_file_tui.tui_custom_columns, + String::from(constants::DEFAULT_CUSTOM_COLUMNS), + ); + + let tui_custom_columns = TuiColumns::try_from(columns.as_str())?; + let tui_icmp_extension_mode = cfg_layer( args.tui_icmp_extension_mode, cfg_file_tui.tui_icmp_extension_mode, @@ -570,6 +581,7 @@ impl TrippyConfig { validate_report_cycles(report_cycles)?; validate_dns(dns_resolve_method, dns_lookup_as_info)?; validate_geoip(tui_geoip_mode, &geoip_mmdb_file)?; + validate_tui_custom_columns(&tui_custom_columns)?; let tui_theme_items = args .tui_theme_colors .into_iter() @@ -611,6 +623,7 @@ impl TrippyConfig { tui_privacy_max_ttl, tui_address_mode, tui_as_mode, + tui_custom_columns, tui_icmp_extension_mode, tui_geoip_mode, tui_max_addrs, @@ -677,6 +690,7 @@ impl Default for TrippyConfig { log_format: constants::DEFAULT_LOG_FORMAT, log_filter: String::from(constants::DEFAULT_LOG_FILTER), log_span_events: constants::DEFAULT_LOG_SPAN_EVENTS, + tui_custom_columns: TuiColumns::default(), } } } @@ -778,6 +792,20 @@ fn validate_privilege( } } +fn validate_tui_custom_columns(tui_custom_columns: &TuiColumns) -> anyhow::Result<()> { + let duplicates = tui_custom_columns.find_duplicates(); + if tui_custom_columns.0.is_empty() { + Err(anyhow!( + "Missing or no custom columns - The command line or config file value is blank" + )) + } else if duplicates.is_empty() { + Ok(()) + } else { + let dup_str = duplicates.iter().join(", "); + Err(anyhow!("Duplicate custom columns: {dup_str}")) + } +} + fn validate_logging(mode: Mode, verbose: bool) -> anyhow::Result<()> { if matches!(mode, Mode::Tui) && verbose { Err(anyhow!("cannot enable verbose logging in tui mode")) diff --git a/src/config/cmd.rs b/src/config/cmd.rs index 067482e34..e730f9041 100644 --- a/src/config/cmd.rs +++ b/src/config/cmd.rs @@ -161,6 +161,10 @@ pub struct Args { #[arg(value_enum, long)] pub tui_as_mode: Option, + /// Custom columns to be displayed in the TUI hops table [default: HOLSRAVBWDT] + #[arg(long)] + pub tui_custom_columns: Option, + /// How to render ICMP extensions [default: off] #[arg(value_enum, long)] pub tui_icmp_extension_mode: Option, diff --git a/src/config/columns.rs b/src/config/columns.rs new file mode 100644 index 000000000..8fc6eb073 --- /dev/null +++ b/src/config/columns.rs @@ -0,0 +1,198 @@ +use anyhow::anyhow; +use itertools::Itertools; +use std::collections::HashSet; +use std::fmt::{Display, Formatter}; + +/// The columns to display in the hops table of the TUI. +#[derive(Debug, Clone, Eq, PartialEq)] +pub struct TuiColumns(pub Vec); + +impl TryFrom<&str> for TuiColumns { + type Error = anyhow::Error; + + fn try_from(value: &str) -> Result { + Ok(Self( + value + .chars() + .map(TuiColumn::try_from) + .collect::, Self::Error>>()?, + )) + } +} + +impl Default for TuiColumns { + fn default() -> Self { + Self::try_from(super::constants::DEFAULT_CUSTOM_COLUMNS).expect("custom columns") + } +} + +impl TuiColumns { + /// Validate the columns. + /// + /// Returns any duplicate columns. + pub fn find_duplicates(&self) -> Vec { + let (_, duplicates) = self.0.iter().fold( + (HashSet::::new(), Vec::new()), + |(mut all, mut dups), column| { + if all.iter().contains(column) { + dups.push(column.to_string()); + } else { + all.insert(*column); + } + (all, dups) + }, + ); + duplicates + } +} + +/// A TUI hops table column. +#[derive(Debug, Copy, Clone, Eq, PartialEq, Hash)] +pub enum TuiColumn { + /// The ttl for a hop. + Ttl, + /// The hostname for a hostname. + Host, + /// The packet loss % for a hop. + LossPct, + /// The number of probes sent for a hop. + Sent, + /// The number of responses received for a hop. + Received, + /// The last RTT for a hop. + Last, + /// The rolling average RTT for a hop. + Average, + /// The best RTT for a hop. + Best, + /// The worst RTT for a hop. + Worst, + /// The stddev of RTT for a hop. + StdDev, + /// The status of a hop. + Status, +} + +impl TryFrom for TuiColumn { + type Error = anyhow::Error; + + fn try_from(value: char) -> Result { + match value.to_ascii_lowercase() { + 'h' => Ok(Self::Ttl), + 'o' => Ok(Self::Host), + 'l' => Ok(Self::LossPct), + 's' => Ok(Self::Sent), + 'r' => Ok(Self::Received), + 'a' => Ok(Self::Last), + 'v' => Ok(Self::Average), + 'b' => Ok(Self::Best), + 'w' => Ok(Self::Worst), + 'd' => Ok(Self::StdDev), + 't' => Ok(Self::Status), + c => Err(anyhow!(format!("unknown column code: {c}"))), + } + } +} + +impl Display for TuiColumn { + fn fmt(&self, f: &mut Formatter<'_>) -> std::fmt::Result { + match self { + Self::Ttl => write!(f, "h"), + Self::Host => write!(f, "o"), + Self::LossPct => write!(f, "l"), + Self::Sent => write!(f, "s"), + Self::Received => write!(f, "r"), + Self::Last => write!(f, "a"), + Self::Average => write!(f, "v"), + Self::Best => write!(f, "b"), + Self::Worst => write!(f, "w"), + Self::StdDev => write!(f, "d"), + Self::Status => write!(f, "t"), + } + } +} +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_try_from_char_for_tui_column() { + assert_eq!(TuiColumn::try_from('h').unwrap(), TuiColumn::Ttl); + assert_eq!(TuiColumn::try_from('o').unwrap(), TuiColumn::Host); + assert_eq!(TuiColumn::try_from('l').unwrap(), TuiColumn::LossPct); + assert_eq!(TuiColumn::try_from('s').unwrap(), TuiColumn::Sent); + assert_eq!(TuiColumn::try_from('r').unwrap(), TuiColumn::Received); + assert_eq!(TuiColumn::try_from('a').unwrap(), TuiColumn::Last); + assert_eq!(TuiColumn::try_from('v').unwrap(), TuiColumn::Average); + assert_eq!(TuiColumn::try_from('b').unwrap(), TuiColumn::Best); + assert_eq!(TuiColumn::try_from('w').unwrap(), TuiColumn::Worst); + assert_eq!(TuiColumn::try_from('d').unwrap(), TuiColumn::StdDev); + assert_eq!(TuiColumn::try_from('t').unwrap(), TuiColumn::Status); + + // Negative test for an unknown character + assert!(TuiColumn::try_from('x').is_err()); + } + + #[test] + fn test_display_formatting_for_tui_column() { + assert_eq!(format!("{}", TuiColumn::Ttl), "h"); + assert_eq!(format!("{}", TuiColumn::Host), "o"); + assert_eq!(format!("{}", TuiColumn::LossPct), "l"); + assert_eq!(format!("{}", TuiColumn::Sent), "s"); + assert_eq!(format!("{}", TuiColumn::Received), "r"); + assert_eq!(format!("{}", TuiColumn::Last), "a"); + assert_eq!(format!("{}", TuiColumn::Average), "v"); + assert_eq!(format!("{}", TuiColumn::Best), "b"); + assert_eq!(format!("{}", TuiColumn::Worst), "w"); + assert_eq!(format!("{}", TuiColumn::StdDev), "d"); + assert_eq!(format!("{}", TuiColumn::Status), "t"); + } + + #[test] + fn test_try_from_str_for_tui_columns() { + let valid_input = "hol"; + let tui_columns = TuiColumns::try_from(valid_input).unwrap(); + assert_eq!( + tui_columns, + TuiColumns(vec![TuiColumn::Ttl, TuiColumn::Host, TuiColumn::LossPct]) + ); + + // Test for invalid characters in the input + let invalid_input = "xyz"; + assert!(TuiColumns::try_from(invalid_input).is_err()); + } + + #[test] + fn test_default_for_tui_columns() { + let default_columns = TuiColumns::default(); + assert_eq!( + default_columns, + TuiColumns(vec![ + TuiColumn::Ttl, + TuiColumn::Host, + TuiColumn::LossPct, + TuiColumn::Sent, + TuiColumn::Received, + TuiColumn::Last, + TuiColumn::Average, + TuiColumn::Best, + TuiColumn::Worst, + TuiColumn::StdDev, + TuiColumn::Status + ]) + ); + } + + #[test] + fn test_find_duplicates_for_tui_columns() { + let columns_with_duplicates = TuiColumns(vec![ + TuiColumn::Ttl, + TuiColumn::Host, + TuiColumn::LossPct, + TuiColumn::Host, // Duplicate + ]); + + let duplicates = columns_with_duplicates.find_duplicates(); + assert_eq!(duplicates, vec!["o".to_string()]); + } +} diff --git a/src/config/constants.rs b/src/config/constants.rs index 73a630075..651a54d69 100644 --- a/src/config/constants.rs +++ b/src/config/constants.rs @@ -85,6 +85,9 @@ pub const DEFAULT_TUI_PRESERVE_SCREEN: bool = false; /// The default value for `tui-as-mode`. pub const DEFAULT_TUI_AS_MODE: AsMode = AsMode::Asn; +/// The default value for `tui-custom-columns`. +pub const DEFAULT_CUSTOM_COLUMNS: &str = "HOLSRAVBWDT"; + /// The default value for `tui-icmp-extension-mode`. pub const DEFAULT_TUI_ICMP_EXTENSION_MODE: IcmpExtensionMode = IcmpExtensionMode::Off; diff --git a/src/config/file.rs b/src/config/file.rs index bf89eb2e9..a00a121fa 100644 --- a/src/config/file.rs +++ b/src/config/file.rs @@ -223,6 +223,7 @@ pub struct ConfigTui { pub tui_geoip_mode: Option, pub tui_max_addrs: Option, pub geoip_mmdb_file: Option, + pub tui_custom_columns: Option, } impl Default for ConfigTui { @@ -235,6 +236,7 @@ impl Default for ConfigTui { tui_privacy_max_ttl: Some(super::constants::DEFAULT_TUI_PRIVACY_MAX_TTL), tui_address_mode: Some(super::constants::DEFAULT_TUI_ADDRESS_MODE), tui_as_mode: Some(super::constants::DEFAULT_TUI_AS_MODE), + tui_custom_columns: Some(String::from(super::constants::DEFAULT_CUSTOM_COLUMNS)), tui_icmp_extension_mode: Some(super::constants::DEFAULT_TUI_ICMP_EXTENSION_MODE), tui_geoip_mode: Some(super::constants::DEFAULT_TUI_GEOIP_MODE), tui_max_addrs: Some(super::constants::DEFAULT_TUI_MAX_ADDRS), diff --git a/src/frontend.rs b/src/frontend.rs index fc3829dca..8813d69ab 100644 --- a/src/frontend.rs +++ b/src/frontend.rs @@ -18,6 +18,7 @@ use trippy::dns::DnsResolver; use tui_app::TuiApp; mod binding; +mod columns; mod config; mod render; mod theme; diff --git a/src/frontend/columns.rs b/src/frontend/columns.rs new file mode 100644 index 000000000..bb1a78070 --- /dev/null +++ b/src/frontend/columns.rs @@ -0,0 +1,159 @@ +use crate::config::{TuiColumn, TuiColumns}; +use std::fmt::{Display, Formatter}; + +/// The columns to display in the hops table of the TUI. +#[derive(Debug, Clone, Eq, PartialEq)] +pub struct Columns(pub Vec); + +impl From for Columns { + fn from(value: TuiColumns) -> Self { + Self(value.0.into_iter().map(Column::from).collect()) + } +} + +/// A TUI hops table column. +#[derive(Debug, Copy, Clone, Eq, PartialEq)] +pub enum Column { + /// The ttl for a hop. + Ttl, + /// The hostname for a hostname. + Host, + /// The packet loss % for a hop. + LossPct, + /// The number of probes sent for a hop. + Sent, + /// The number of responses received for a hop. + Received, + /// The last RTT for a hop. + Last, + /// The rolling average RTT for a hop. + Average, + /// The best RTT for a hop. + Best, + /// The worst RTT for a hop. + Worst, + /// The stddev of RTT for a hop. + StdDev, + /// The status of a hop. + Status, +} + +impl From for Column { + fn from(value: TuiColumn) -> Self { + match value { + TuiColumn::Ttl => Self::Ttl, + TuiColumn::Host => Self::Host, + TuiColumn::LossPct => Self::LossPct, + TuiColumn::Sent => Self::Sent, + TuiColumn::Received => Self::Received, + TuiColumn::Last => Self::Last, + TuiColumn::Average => Self::Average, + TuiColumn::Best => Self::Best, + TuiColumn::Worst => Self::Worst, + TuiColumn::StdDev => Self::StdDev, + TuiColumn::Status => Self::Status, + } + } +} + +impl Display for Column { + fn fmt(&self, f: &mut Formatter<'_>) -> std::fmt::Result { + match self { + Self::Ttl => write!(f, "#"), + Self::Host => write!(f, "Host"), + Self::LossPct => write!(f, "Loss%"), + Self::Sent => write!(f, "Snd"), + Self::Received => write!(f, "Recv"), + Self::Last => write!(f, "Last"), + Self::Average => write!(f, "Avg"), + Self::Best => write!(f, "Best"), + Self::Worst => write!(f, "Wrst"), + Self::StdDev => write!(f, "StDev"), + Self::Status => write!(f, "Sts"), + } + } +} + +impl Column { + /// TODO we should calculate width % based on which columns are preset + pub fn width_pct(self, column_count: u16) -> u16 { + let adjusted_count: u16 = if column_count > 0 { column_count } else { 10 }; + #[allow(clippy::match_same_arms)] + match self { + Self::Host => 40, + Self::LossPct + | Self::Sent + | Self::Received + | Self::Last + | Self::Average + | Self::Best + | Self::Worst + | Self::StdDev + | Self::Ttl + | Self::Status => 60 / adjusted_count, + } + } +} +mod tests { + use crate::{ + config::{TuiColumn, TuiColumns}, + frontend::columns::{Column, Columns}, + }; + + #[test] + fn test_columns_conversion_from_tui_columns() { + let tui_columns = TuiColumns(vec![ + TuiColumn::Ttl, + TuiColumn::Host, + TuiColumn::LossPct, + TuiColumn::Sent, + ]); + + let columns = Columns::from(tui_columns); + + assert_eq!( + columns, + Columns(vec![ + Column::Ttl, + Column::Host, + Column::LossPct, + Column::Sent, + ]) + ); + } + + #[test] + fn test_column_conversion_from_tui_column() { + let tui_column = TuiColumn::Received; + let column = Column::from(tui_column); + + assert_eq!(column, Column::Received); + } + + #[test] + fn test_column_display_formatting() { + assert_eq!(format!("{}", Column::Ttl), "#"); + assert_eq!(format!("{}", Column::Host), "Host"); + assert_eq!(format!("{}", Column::LossPct), "Loss%"); + assert_eq!(format!("{}", Column::Sent), "Snd"); + assert_eq!(format!("{}", Column::Received), "Recv"); + assert_eq!(format!("{}", Column::Last), "Last"); + assert_eq!(format!("{}", Column::Average), "Avg"); + assert_eq!(format!("{}", Column::Best), "Best"); + assert_eq!(format!("{}", Column::Worst), "Wrst"); + assert_eq!(format!("{}", Column::StdDev), "StDev"); + assert_eq!(format!("{}", Column::Status), "Sts"); + } + + #[test] + fn test_column_width_percentage() { + assert_eq!(Column::Ttl.width_pct(4), 15); + assert_eq!(Column::Host.width_pct(2), 40); + assert_eq!(Column::Host.width_pct(10), 40); + assert_eq!(Column::LossPct.width_pct(6), 10); + assert_eq!(Column::Sent.width_pct(7), 8); + assert_eq!(Column::Sent.width_pct(9), 6); + assert_eq!(Column::Status.width_pct(8), 7); + assert_eq!(Column::Status.width_pct(10), 6); + } +} diff --git a/src/frontend/config.rs b/src/frontend/config.rs index bcf96ff5b..d5d7e7b4d 100644 --- a/src/frontend/config.rs +++ b/src/frontend/config.rs @@ -1,6 +1,7 @@ -use crate::config::{AddressMode, AsMode, GeoIpMode, TuiTheme}; +use crate::config::{AddressMode, AsMode, GeoIpMode, TuiColumns, TuiTheme}; use crate::config::{IcmpExtensionMode, TuiBindings}; use crate::frontend::binding::Bindings; +use crate::frontend::columns::Columns; use crate::frontend::theme::Theme; use std::time::Duration; @@ -33,6 +34,8 @@ pub struct TuiConfig { pub theme: Theme, /// The Tui keyboard bindings. pub bindings: Bindings, + /// The columns to display in the hops table. + pub tui_columns: Columns, } impl TuiConfig { @@ -51,6 +54,7 @@ impl TuiConfig { max_flows: usize, tui_theme: TuiTheme, tui_bindings: &TuiBindings, + tui_columns: &TuiColumns, ) -> Self { Self { refresh_rate, @@ -66,6 +70,7 @@ impl TuiConfig { max_flows, theme: Theme::from(tui_theme), bindings: Bindings::from(*tui_bindings), + tui_columns: Columns::from(tui_columns.clone()), } } } diff --git a/src/frontend/render/settings.rs b/src/frontend/render/settings.rs index c4fecfca3..4d7cbbb0b 100644 --- a/src/frontend/render/settings.rs +++ b/src/frontend/render/settings.rs @@ -15,7 +15,7 @@ use trippy::tracing::PortDirection; pub fn render(f: &mut Frame<'_>, app: &mut TuiApp) { let all_settings = format_all_settings(app); let (name, info, items) = &all_settings[app.settings_tab_selected]; - let area = util::centered_rect(60, 60, f.size()); + let area = util::centered_rect(75, 60, f.size()); let chunks = Layout::default() .direction(Direction::Vertical) .constraints(SETTINGS_TABLE_WIDTH.as_ref()) @@ -78,7 +78,7 @@ fn render_settings_table( .max() .unwrap_or_default() .max(30); - let table_widths = [Constraint::Min(item_width), Constraint::Length(60)]; + let table_widths = [Constraint::Min(item_width), Constraint::Length(80)]; let table = Table::new(rows) .header(header) .block( @@ -177,6 +177,10 @@ fn format_tui_settings(app: &TuiApp) -> Vec { .max_addrs .map_or_else(|| String::from("auto"), |m| m.to_string()), ), + SettingsItem::new( + "tui-custom-columns", + format!("{:?}", app.tui_config.tui_columns), + ), ] } @@ -420,7 +424,7 @@ fn format_theme_settings(app: &TuiApp) -> Vec { /// The name and number of items for each tabs in the setting dialog. pub const SETTINGS_TABS: [(&str, usize); 6] = [ - ("Tui", 9), + ("Tui", 10), ("Trace", 15), ("Dns", 4), ("GeoIp", 1), diff --git a/src/frontend/render/table.rs b/src/frontend/render/table.rs index 4f9499fd1..83a1ca481 100644 --- a/src/frontend/render/table.rs +++ b/src/frontend/render/table.rs @@ -1,5 +1,6 @@ use crate::backend::trace::Hop; use crate::config::{AddressMode, AsMode, GeoIpMode, IcmpExtensionMode}; +use crate::frontend::columns::{Column, Columns}; use crate::frontend::config::TuiConfig; use crate::frontend::theme::Theme; use crate::frontend::tui_app::TuiApp; @@ -30,12 +31,20 @@ use trippy::tracing::{Extension, Extensions, MplsLabelStackMember, UnknownExtens /// - The standard deviation round-trip time for all probes at this hop (`StDev`) /// - The status of this hop (`Sts`) pub fn render(f: &mut Frame<'_>, app: &mut TuiApp, rect: Rect) { - let header = render_table_header(app.tui_config.theme); + let config = &app.tui_config; + let widths = get_column_widths(&config.tui_columns); + let header = render_table_header(app.tui_config.theme, &config.tui_columns); let selected_style = Style::default().add_modifier(Modifier::REVERSED); - let rows = - app.tracer_data().hops(app.selected_flow).iter().map(|hop| { - render_table_row(app, hop, &app.resolver, &app.geoip_lookup, &app.tui_config) - }); + let rows = app.tracer_data().hops(app.selected_flow).iter().map(|hop| { + render_table_row( + app, + hop, + &app.resolver, + &app.geoip_lookup, + &app.tui_config, + &config.tui_columns, + ) + }); let table = Table::new(rows) .header(header) .block( @@ -51,15 +60,16 @@ pub fn render(f: &mut Frame<'_>, app: &mut TuiApp, rect: Rect) { .fg(app.tui_config.theme.text_color), ) .highlight_style(selected_style) - .widths(&TABLE_WIDTH); + .widths(widths.as_slice()) + .column_spacing(1); f.render_stateful_widget(table, rect, &mut app.table_state); } /// Render the table header. -fn render_table_header(theme: Theme) -> Row<'static> { - let header_cells = TABLE_HEADER - .iter() - .map(|h| Cell::from(*h).style(Style::default().fg(theme.hops_table_header_text_color))); +fn render_table_header(theme: Theme, table_columns: &Columns) -> Row<'static> { + let header_cells = table_columns.0.iter().map(|c| { + Cell::from(c.to_string()).style(Style::default().fg(theme.hops_table_header_text_color)) + }); Row::new(header_cells) .style(Style::default().bg(theme.hops_table_header_bg_color)) .height(1) @@ -73,41 +83,33 @@ fn render_table_row( dns: &DnsResolver, geoip_lookup: &GeoIpLookup, config: &TuiConfig, + custom_columns: &Columns, ) -> Row<'static> { let is_selected_hop = app .selected_hop() .map(|h| h.ttl() == hop.ttl()) .unwrap_or_default(); - let is_target = app.tracer_data().is_target(hop, app.selected_flow); let is_in_round = app.tracer_data().is_in_round(hop, app.selected_flow); - let ttl_cell = render_ttl_cell(hop); - let (hostname_cell, row_height) = if is_selected_hop && app.show_hop_details { + let (_, row_height) = if is_selected_hop && app.show_hop_details { render_hostname_with_details(app, hop, dns, geoip_lookup, config) } else { render_hostname(app, hop, dns, geoip_lookup) }; - let loss_pct_cell = render_loss_pct_cell(hop); - let total_sent_cell = render_total_sent_cell(hop); - let total_recv_cell = render_total_recv_cell(hop); - let last_cell = render_last_cell(hop); - let avg_cell = render_avg_cell(hop); - let best_cell = render_best_cell(hop); - let worst_cell = render_worst_cell(hop); - let stddev_cell = render_stddev_cell(hop); - let status_cell = render_status_cell(hop, is_target); - let cells = [ - ttl_cell, - hostname_cell, - loss_pct_cell, - total_sent_cell, - total_recv_cell, - last_cell, - avg_cell, - best_cell, - worst_cell, - stddev_cell, - status_cell, - ]; + let cells: Vec> = custom_columns + .0 + .iter() + .map(|column| { + new_cell( + *column, + is_selected_hop, + app, + hop, + dns, + geoip_lookup, + config, + ) + }) + .collect(); let row_color = if is_in_round { config.theme.hops_table_row_active_text_color } else { @@ -118,7 +120,38 @@ fn render_table_row( .bottom_margin(0) .style(Style::default().fg(row_color)) } - +///Returns a Cell matched on short char of the Column +fn new_cell( + column: Column, + is_selected_hop: bool, + app: &TuiApp, + hop: &Hop, + dns: &DnsResolver, + geoip_lookup: &GeoIpLookup, + config: &TuiConfig, +) -> Cell<'static> { + let is_target = app.tracer_data().is_target(hop, app.selected_flow); + match column { + Column::Ttl => render_ttl_cell(hop), + Column::Host => { + let (host_cell, _) = if is_selected_hop && app.show_hop_details { + render_hostname_with_details(app, hop, dns, geoip_lookup, config) + } else { + render_hostname(app, hop, dns, geoip_lookup) + }; + host_cell + } + Column::LossPct => render_loss_pct_cell(hop), + Column::Sent => render_total_sent_cell(hop), + Column::Received => render_total_recv_cell(hop), + Column::Last => render_last_cell(hop), + Column::Average => render_avg_cell(hop), + Column::Best => render_best_cell(hop), + Column::Worst => render_worst_cell(hop), + Column::StdDev => render_stddev_cell(hop), + Column::Status => render_status_cell(hop, is_target), + } +} fn render_ttl_cell(hop: &Hop) -> Cell<'static> { Cell::from(format!("{}", hop.ttl())) } @@ -571,21 +604,11 @@ fn fmt_details_line( }; format!("{addr} [{index} of {count}]\n{hosts_rendered}\n{as_formatted}\n{geoip_formatted}\n{ext_formatted}") } - -const TABLE_HEADER: [&str; 11] = [ - "#", "Host", "Loss%", "Snt", "Recv", "Last", "Avg", "Best", "Wrst", "StDev", "Sts", -]; - -const TABLE_WIDTH: [Constraint; 11] = [ - Constraint::Percentage(3), - Constraint::Percentage(42), - Constraint::Percentage(5), - Constraint::Percentage(5), - Constraint::Percentage(5), - Constraint::Percentage(5), - Constraint::Percentage(5), - Constraint::Percentage(5), - Constraint::Percentage(5), - Constraint::Percentage(5), - Constraint::Percentage(5), -]; +fn get_column_widths(columns: &Columns) -> Vec { + let column_count = columns.0.len() as u16; + columns + .0 + .iter() + .map(|c| Constraint::Percentage(c.width_pct(column_count))) + .collect() +} diff --git a/src/main.rs b/src/main.rs index b30a8bcdb..f0b32d335 100644 --- a/src/main.rs +++ b/src/main.rs @@ -317,6 +317,7 @@ fn make_tui_config(args: &TrippyConfig) -> TuiConfig { args.tui_max_flows, args.tui_theme, &args.tui_bindings, + &args.tui_custom_columns, ) } diff --git a/trippy-config-sample.toml b/trippy-config-sample.toml index cddeaa7e7..3f5abbd9c 100644 --- a/trippy-config-sample.toml +++ b/trippy-config-sample.toml @@ -216,7 +216,6 @@ dns-timeout = "5s" # Only applicable for modes pretty, markdown, csv and json. report-cycles = 10 - # # General Tui Configuration. # @@ -241,7 +240,26 @@ tui-address-mode = "host" # name - Display the AS name tui-as-mode = "asn" -# How to render ICMP extensions +# Custom columns to be displayed in the TUI hops table. +# +# Allowed values are: +# +# H - Ttl +# O - Hostname +# L - Loss % +# S - Probes sent +# R - Responses received +# A - Last RTT +# V - Average RTT +# B - Best RTT +# W - Worst RTT +# D - Stddev +# T - Status +# +# The columns will be shown in the order specified. +tui-custom-columns = "HOLSRAVBWDT" + +# How to render ICMP extensions. # # off - Do not show icmp extensions [default] # mpls - Show MPLS label(s) only