Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat: Allow Custom Columns TUI #863

Merged
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
28 changes: 28 additions & 0 deletions src/config.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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};

Expand Down Expand Up @@ -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<u8>,
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -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()
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -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(),
}
}
}
Expand Down Expand Up @@ -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"))
Expand Down
4 changes: 4 additions & 0 deletions src/config/cmd.rs
Original file line number Diff line number Diff line change
Expand Up @@ -161,6 +161,10 @@ pub struct Args {
#[arg(value_enum, long)]
pub tui_as_mode: Option<AsMode>,

/// Custom columns to be displayed in the TUI hops table [default: HOLSRAVBWDT]
#[arg(long)]
pub tui_custom_columns: Option<String>,

/// How to render ICMP extensions [default: off]
#[arg(value_enum, long)]
pub tui_icmp_extension_mode: Option<IcmpExtensionMode>,
Expand Down
207 changes: 207 additions & 0 deletions src/config/columns.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,207 @@
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<TuiColumn>);

impl TryFrom<&str> for TuiColumns {
type Error = anyhow::Error;

fn try_from(value: &str) -> Result<Self, Self::Error> {
Ok(Self(
value
.chars()
.map(TuiColumn::try_from)
.collect::<Result<Vec<_>, 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<String> {
let (_, duplicates) = self.0.iter().fold(
(HashSet::<TuiColumn>::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<char> for TuiColumn {
type Error = anyhow::Error;

fn try_from(value: char) -> Result<Self, Self::Error> {
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::*;
use test_case::test_case;

///Test for expected column matches to characters
#[test_case('h', TuiColumn::Ttl)]
#[test_case('o', TuiColumn::Host)]
#[test_case('l', TuiColumn::LossPct)]
#[test_case('s', TuiColumn::Sent)]
#[test_case('r', TuiColumn::Received)]
#[test_case('a', TuiColumn::Last)]
#[test_case('v', TuiColumn::Average)]
#[test_case('b', TuiColumn::Best)]
#[test_case('w', TuiColumn::Worst)]
#[test_case('d', TuiColumn::StdDev)]
#[test_case('t', TuiColumn::Status)]
fn test_try_from_char_for_tui_column(c: char, t: TuiColumn) {
assert_eq!(TuiColumn::try_from(c).unwrap(), t);
}

///Negative test for invalid characters
#[test_case('x' ; "invalid x")]
#[test_case('z' ; "invalid z")]
fn test_try_invalid_char_for_tui_column(c: char) {
// Negative test for an unknown character
assert!(TuiColumn::try_from(c).is_err());
}

///Test for TuiColumn type match of Display
#[test_case(TuiColumn::Ttl, "h")]
#[test_case(TuiColumn::Host, "o")]
#[test_case(TuiColumn::LossPct, "l")]
#[test_case(TuiColumn::Sent, "s")]
#[test_case(TuiColumn::Received, "r")]
#[test_case(TuiColumn::Last, "a")]
#[test_case(TuiColumn::Average, "v")]
#[test_case(TuiColumn::Best, "b")]
#[test_case(TuiColumn::Worst, "w")]
#[test_case(TuiColumn::StdDev, "d")]
#[test_case(TuiColumn::Status, "t")]
fn test_display_formatting_for_tui_column(t: TuiColumn, letter: &'static str) {
assert_eq!(format!("{t}"), letter);
}

#[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()]);
}
}
3 changes: 3 additions & 0 deletions src/config/constants.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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;

Expand Down
2 changes: 2 additions & 0 deletions src/config/file.rs
Original file line number Diff line number Diff line change
Expand Up @@ -223,6 +223,7 @@ pub struct ConfigTui {
pub tui_geoip_mode: Option<GeoIpMode>,
pub tui_max_addrs: Option<u8>,
pub geoip_mmdb_file: Option<String>,
pub tui_custom_columns: Option<String>,
}

impl Default for ConfigTui {
Expand All @@ -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),
Expand Down
1 change: 1 addition & 0 deletions src/frontend.rs
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@ use trippy::dns::DnsResolver;
use tui_app::TuiApp;

mod binding;
mod columns;
mod config;
mod render;
mod theme;
Expand Down
Loading
Loading