From f113f8a6f353250e97553c187268e8951e0943be Mon Sep 17 00:00:00 2001 From: trkelly23 <69165022+trkelly23@users.noreply.github.com> Date: Fri, 22 Dec 2023 10:54:20 -0500 Subject: [PATCH] feat display jitter feat display jitter(WIP) Includes possible test approach using JSON to mimic trace data feat(tui): improve column width layout logic (WIP) --- Cargo.lock | 196 ++++++++++++++++++- Cargo.toml | 2 + src/backend/trace.rs | 332 +++++++++++++++++++++++++++++++++ src/config/columns.rs | 18 +- src/frontend/columns.rs | 105 ++++++++--- src/frontend/render/table.rs | 73 ++------ src/report/types.rs | 13 +- src/tracing.rs | 4 +- tests/data/base_line.json | 27 +++ tests/data/jinta_1hop.json | 53 ++++++ tests/expected/jinta_1hop.json | 13 ++ 11 files changed, 750 insertions(+), 86 deletions(-) create mode 100644 tests/data/base_line.json create mode 100644 tests/data/jinta_1hop.json create mode 100644 tests/expected/jinta_1hop.json diff --git a/Cargo.lock b/Cargo.lock index 7c365b8e8..b979ab6b3 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -151,6 +151,12 @@ dependencies = [ "rustc-demangle", ] +[[package]] +name = "base64" +version = "0.21.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9d297deb1925b89f2ccc13d7635fa0714f12c87adce1c75356b39ca9b7178567" + [[package]] name = "bitflags" version = "1.3.2" @@ -215,6 +221,7 @@ dependencies = [ "android-tzdata", "iana-time-zone", "num-traits", + "serde", "windows-targets 0.48.5", ] @@ -403,12 +410,57 @@ dependencies = [ "memchr", ] +[[package]] +name = "darling" +version = "0.20.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0209d94da627ab5605dcccf08bb18afa5009cfbef48d8a8b7d7bdbc79be25c5e" +dependencies = [ + "darling_core", + "darling_macro", +] + +[[package]] +name = "darling_core" +version = "0.20.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "177e3443818124b357d8e76f53be906d60937f0d3a90773a664fa63fa253e621" +dependencies = [ + "fnv", + "ident_case", + "proc-macro2", + "quote", + "strsim", + "syn 2.0.39", +] + +[[package]] +name = "darling_macro" +version = "0.20.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "836a9bbc7ad63342d6d6e7b815ccab164bc77a2d95d84bc3117a8c0d5c98e2d5" +dependencies = [ + "darling_core", + "quote", + "syn 2.0.39", +] + [[package]] name = "data-encoding" version = "2.5.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7e962a19be5cfc3f3bf6dd8f61eb50107f356ad6270fbb3ed41476571db78be5" +[[package]] +name = "deranged" +version = "0.3.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b42b6fa04a440b495c8b04d0e71b707c585f83cb9cb28cf8cd0d976c315e31b4" +dependencies = [ + "powerfmt", + "serde", +] + [[package]] name = "derive_more" version = "0.99.17" @@ -489,6 +541,12 @@ version = "0.4.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0ce7134b9999ecaf8bcd65542e436736ef32ddca1b3e06094cb6ec5755203b80" +[[package]] +name = "fnv" +version = "1.0.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3f9eec918d3f24069decb9af1554cad7c880e2da24a9afd88aca000531ab82c1" + [[package]] name = "form_urlencoded" version = "1.2.1" @@ -538,6 +596,15 @@ dependencies = [ "slab", ] +[[package]] +name = "generic-array" +version = "1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fe739944a5406424e080edccb6add95685130b9f160d5407c639c7df0c5836b0" +dependencies = [ + "typenum", +] + [[package]] name = "getrandom" version = "0.2.11" @@ -555,6 +622,12 @@ version = "0.28.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "4271d37baee1b8c7e4b708028c57d816cf9d2434acb33a549475f78c181f6253" +[[package]] +name = "hashbrown" +version = "0.12.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8a9ee70c43aaf417c914396645a0fa852624801b24ebb7ae78fe8272889ac888" + [[package]] name = "hashbrown" version = "0.14.3" @@ -577,6 +650,12 @@ version = "0.3.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d77f7ec81a6d05a3abb01ab6eb7590f6083d08449fe5a1c8b1e620283546ccb7" +[[package]] +name = "hex" +version = "0.4.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7f24254aa9a54b5c858eaee2f5bccdb46aaf0e486a595ed5fd8f86ba55232a70" + [[package]] name = "hex-literal" version = "0.4.1" @@ -677,6 +756,12 @@ dependencies = [ "cc", ] +[[package]] +name = "ident_case" +version = "1.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b9e0384b61958566e926dc50660321d12159025e767c18e043daf26b70104c39" + [[package]] name = "idna" version = "0.4.0" @@ -697,6 +782,17 @@ dependencies = [ "unicode-normalization", ] +[[package]] +name = "indexmap" +version = "1.9.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bd070e393353796e801d209ad339e89596eb4c8d430d18ede6a1cced8fafbd99" +dependencies = [ + "autocfg", + "hashbrown 0.12.3", + "serde", +] + [[package]] name = "indexmap" version = "2.1.0" @@ -704,7 +800,8 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d530e1a18b1cb4c484e6e34556a0d948706958449fca0cab753d649f2bce3d1f" dependencies = [ "equivalent", - "hashbrown", + "hashbrown 0.14.3", + "serde", ] [[package]] @@ -740,6 +837,17 @@ dependencies = [ "serde", ] +[[package]] +name = "iso8601-timestamp" +version = "0.2.16" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "daa57edc7ad71119e3d501cec542b6c3552881a421ebd61a78c7bf25ebc2eb73" +dependencies = [ + "generic-array", + "serde", + "time", +] + [[package]] name = "itertools" version = "0.12.0" @@ -810,7 +918,7 @@ version = "0.12.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "2994eeba8ed550fd9b47a0b38f0242bc3344e496483c6180b69139cc2fa5d1d7" dependencies = [ - "hashbrown", + "hashbrown 0.14.3", ] [[package]] @@ -989,7 +1097,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e1d3afd2628e69da2be385eb6f2fd57c8ac7977ceeff6dc166ff1657b0e386a9" dependencies = [ "fixedbitset", - "indexmap", + "indexmap 2.1.0", ] [[package]] @@ -1004,6 +1112,12 @@ version = "0.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "8b870d8c151b6f2fb93e84a13146138f05d02ed11c7e7c54f8826aaaf7c9f184" +[[package]] +name = "powerfmt" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "439ee305def115ba05938db6eb1644ff94165c5ab5e9420d1c1bcedbba909391" + [[package]] name = "ppv-lite86" version = "0.2.17" @@ -1233,6 +1347,35 @@ dependencies = [ "serde", ] +[[package]] +name = "serde_with" +version = "3.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "64cd236ccc1b7a29e7e2739f27c0b2dd199804abc4290e32f59f3b68d6405c23" +dependencies = [ + "base64", + "chrono", + "hex", + "indexmap 1.9.3", + "indexmap 2.1.0", + "serde", + "serde_json", + "serde_with_macros", + "time", +] + +[[package]] +name = "serde_with_macros" +version = "3.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "93634eb5f75a2323b16de4748022ac4297f9e76b6dced2be287a099f41b5e788" +dependencies = [ + "darling", + "proc-macro2", + "quote", + "syn 2.0.39", +] + [[package]] name = "sharded-slab" version = "0.1.7" @@ -1307,6 +1450,12 @@ dependencies = [ "syn 1.0.109", ] +[[package]] +name = "strsim" +version = "0.10.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "73473c0e59e6d5812c5dfe2a064a6444949f089e20eec9a2e5506596494e4623" + [[package]] name = "strum" version = "0.25.0" @@ -1424,6 +1573,35 @@ dependencies = [ "once_cell", ] +[[package]] +name = "time" +version = "0.3.31" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f657ba42c3f86e7680e53c8cd3af8abbe56b5491790b46e22e19c0d57463583e" +dependencies = [ + "deranged", + "itoa", + "powerfmt", + "serde", + "time-core", + "time-macros", +] + +[[package]] +name = "time-core" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ef927ca75afb808a4d64dd374f00a2adf8d0fcff8e7b184af886c3c87ec4a3f3" + +[[package]] +name = "time-macros" +version = "0.2.16" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "26197e33420244aeb70c3e8c78376ca46571bc4e701e4791c2cd9f57dcb3a43f" +dependencies = [ + "time-core", +] + [[package]] name = "tinyvec" version = "1.6.0" @@ -1482,7 +1660,7 @@ version = "0.21.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d34d383cd00a163b4a5b85053df514d45bc330f6de7737edfe0a93311d1eaa03" dependencies = [ - "indexmap", + "indexmap 2.1.0", "serde", "serde_spanned", "toml_datetime", @@ -1594,7 +1772,8 @@ dependencies = [ "hex-literal", "hickory-resolver", "humantime", - "indexmap", + "indexmap 2.1.0", + "iso8601-timestamp", "itertools", "maxminddb", "nix", @@ -1606,6 +1785,7 @@ dependencies = [ "ratatui", "serde", "serde_json", + "serde_with", "socket2", "strum", "test-case", @@ -1618,6 +1798,12 @@ dependencies = [ "windows-sys 0.52.0", ] +[[package]] +name = "typenum" +version = "1.17.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "42ff0bf0c66b8238c6f3b578df37d0b7848e55df8577b3f74f92a69acceeb825" + [[package]] name = "unicode-bidi" version = "0.3.13" diff --git a/Cargo.toml b/Cargo.toml index 3655e0a7f..d66e80881 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -52,6 +52,8 @@ tracing-subscriber = { version = "0.3.18", default-features = false, features = tracing-chrome = "0.7.1" petgraph = "0.6.4" csv = "1.3.0" +iso8601-timestamp = "0.2.16" +serde_with = "3.4.0" # Library dependencies (Linux) [target.'cfg(target_os = "linux")'.dependencies] diff --git a/src/backend/trace.rs b/src/backend/trace.rs index 619ffbafe..80df60020 100644 --- a/src/backend/trace.rs +++ b/src/backend/trace.rs @@ -144,6 +144,14 @@ pub struct Hop { extensions: Option, mean: f64, m2: f64, + /// The ABS(RTTx - RTTx-n) + jitter: Option, + /// The Sequential jitter average calculated for each + javg: Option, + /// The worst jitter reading recorded + jmax: Option, + /// The interval calculation i.e smooth + jinta: f64, } impl Hop { @@ -219,6 +227,31 @@ impl Hop { } } + /// The duration of the jitter probe observed. + pub fn jitter_ms(&self) -> Option { + self.jitter.map(|j| j.as_secs_f64() * 1000_f64) + } + /// The duration of the jworst probe observed. + pub fn jmax_ms(&self) -> Option { + self.jmax.map(|x| x.as_secs_f64() * 1000_f64) + } + /// The jitter average duration of all probes. + pub fn javg_ms(&self) -> Option { + if self.total_recv() > 0 { + self.javg + } else { + None + } + } + /// The jitter interval of all probes. + pub fn jinta(&self) -> Option { + if self.total_recv() > 0 { + Some(self.jinta) + } else { + None + } + } + /// The last N samples. pub fn samples(&self) -> &[Duration] { &self.samples @@ -244,6 +277,10 @@ impl Default for Hop { m2: 0f64, samples: Vec::default(), extensions: None, + jitter: None, + javg: None, + jmax: None, + jinta: 0f64, } } } @@ -336,6 +373,21 @@ impl TraceData { let dur = probe.duration(); let dur_ms = dur.as_secs_f64() * 1000_f64; hop.total_time += dur; + //Before last is set use it to calc jitter + let last_ms = hop.last_ms().unwrap_or_default(); + let jitter_ms = (last_ms - dur_ms).abs(); + let jitter_dur = Duration::from_secs_f64(jitter_ms / 1000_f64); + hop.jitter = Some(jitter_dur); + let mut javg_ms = hop.javg_ms().unwrap_or_default(); + //Welfords online algorithm avg without dataset values. + javg_ms += (jitter_ms - javg_ms) / hop.total_recv as f64; + hop.javg = Some(javg_ms); + // algorithm is from rfc1889, A.8 or rfc3550 + hop.jinta += jitter_ms - ((hop.jinta + 8.0) / 16.0); + //Max following logic of hop.worst + hop.jmax = hop + .jmax + .map_or(Some(jitter_dur), |d| Some(d.max(jitter_dur))); hop.last = Some(dur); hop.samples.insert(0, dur); hop.best = hop.best.map_or(Some(dur), |d| Some(d.min(dur))); @@ -381,3 +433,283 @@ impl TraceData { } } } +#[cfg(test)] +mod tests { + use super::*; + use crate::backend::trace::{Hop, TraceData}; + use iso8601_timestamp::Timestamp; + use nix::sys::socket::SockaddrIn; + use serde::{Deserialize, Serialize}; + use serde_with::serde_as; + use std::fs::File; + use std::io::Read; + use std::str::FromStr; + use std::time::SystemTime; + use test_case::test_case; + use trippy::tracing::tracer::CompletionReason; + use trippy::tracing::types::{Port, Round, Sequence, TimeToLive, TraceId}; + use trippy::tracing::{IcmpPacketType, Probe}; + + #[derive(Serialize, Deserialize, Debug)] + struct ImportProbe { + sequence: u16, + identifier: u16, + src_port: u16, + dest_port: u16, + ttl: u8, + round: usize, + sent: Option, + status: String, + host: Option, + received: Option, + icmp_packet_type: Option, + extensions: Option, + } + impl ImportProbe { + fn sequence(&self) -> Sequence { + Sequence(self.sequence) + } + fn identifier(&self) -> TraceId { + TraceId(self.identifier) + } + fn src_port(&self) -> Port { + Port(self.src_port) + } + fn dest_port(&self) -> Port { + Port(self.dest_port) + } + fn ttl(&self) -> TimeToLive { + TimeToLive(self.ttl) + } + fn round(&self) -> Round { + Round(self.round) + } + fn sent(&self) -> SystemTime { + match SystemTime::try_from(self.sent.unwrap()) { + Ok(st) => st, + Err(_d) => SystemTime::now(), + } + } + fn received(&self) -> Option { + self.received.map(|r| SystemTime::try_from(r).ok().unwrap()) + } + fn status(&self) -> ProbeStatus { + match self.status.as_str() { + "Complete" => ProbeStatus::Complete, + "NotSent" => ProbeStatus::NotSent, + "Awaited" => ProbeStatus::Awaited, + _ => ProbeStatus::Skipped, + } + } + fn host(&self) -> Option { + self.host + .as_ref() + .map(|h| IpAddr::V4(Ipv4Addr::from_str(h).unwrap())) + } + } + impl From for Probe { + fn from(value: ImportProbe) -> Self { + Self { + sequence: value.sequence(), + identifier: value.identifier(), + src_port: value.src_port(), + dest_port: value.dest_port(), + ttl: value.ttl(), + round: value.round(), + sent: Some(value.sent()), + status: value.status(), + host: value.host(), + received: value.received(), + icmp_packet_type: Some(IcmpPacketType::NotApplicable), + extensions: None, + } + } + } + #[derive(Deserialize,Serialize,Debug,Clone)] + struct HopResults { + /// The total probes sent for this hop. + total_sent: usize, + /// The total probes received for this hop. + total_recv: usize, + /// The round trip time for this hop in the current round. + last: Option, + /// The best round trip time for this hop across all rounds. + best: Option, + /// The worst round trip time for this hop across all rounds. + worst: Option, + /// The history of round trip times across the last N rounds. + mean: f64, + m2: f64, + /// The ABS(RTTx - RTTx-n) + jitter: Option, + /// The Sequential jitter average calculated for each + javg: Option, + /// The worst jitter reading recorded + jmax: Option, + /// The interval calculation i.e smooth + jinta: f64, + } + impl HopResults { + #[allow(clippy::too_many_arguments)] + fn new( + total_sent: usize, + total_recv: usize, + last: u64, + best: u64, + worst: u64, + mean: f64, + m2: f64, + jitter: u64, + javg: f64, + jmax: u64, + jinta: f64, + ) -> Self { + Self {total_sent, + total_recv, + last: Some(Duration::from_millis(last)), + best: Some(Duration::from_millis(best)), + worst: Some(Duration::from_millis(worst)), + mean, + m2, + jitter: Some(Duration::from_millis(jitter)), + javg: Some(javg), + jmax: Some(Duration::from_millis(jmax)), + jinta, + } + } + //TODO: Create combined struct ImportProbe Vec & HopResults. + //JSON will define the test and expected results. + // + //TODO: Add PartialEq or Eq operator impl HopResult Eq for Hop. + // + } + impl PartialEq<&Hop> for HopResults { + fn eq(&self, other: &&Hop) -> bool { + self.last == other.last + && self.jitter == other.jitter + && self.jmax == other.jmax + && self.javg == other.javg + && format!("{:.2}",self.jinta) == format!("{:.2}", other.jinta) + } + } + //#[test_case("base_line.json", &HopResults::new(2,2,700,300,700,0.0,0.0,400,350.0,400,680.28))] + #[test_case("jinta_1hop.json")] + #[ignore = "WIP"] + fn test_probe_file(json_file: &str) { + let mut json = String::new(); + let mut dir_json_file = "./tests/data/".to_owned(); + dir_json_file.push_str(json_file); + let mut file = File::open(dir_json_file).expect("Failed to open input file"); + // Read the network inputs into a string + file.read_to_string(&mut json).expect("Failed to read file"); + + let mut dir_file_results = "./tests/expected/".to_owned(); + dir_file_results.push_str(json_file); + let mut file = File::open(dir_file_results).expect("Failed to open results file"); + // Read the file content into a string + let mut json_expected = String::new(); + file.read_to_string(&mut json_expected).expect("Failed to read file"); + // Deserialize JSON into structs + let expected: HopResults = serde_json::from_str(&json_expected).expect("Failed to deserialize JSON"); + + let mut trace = Trace::new(100); + let import_probes: Vec = serde_json::from_str(&json).unwrap(); + let probes: Vec = import_probes.into_iter().map(Probe::from).collect(); + let round = TracerRound::new(&probes, TimeToLive(1), CompletionReason::TargetFound); + trace.update_from_round(&round); + println!("Hop = {:#?}", trace.hops(Trace::default_flow_id())[0]); + let hop: &Hop = &trace.hops(Trace::default_flow_id())[0]; + ///Check if expected matches results + assert_eq!(expected, &hop ); + // assert_eq!( + // expected.total_recv, + // trace.hops(Trace::default_flow_id())[0].total_recv + // ); + // assert_eq!(expected.last, trace.hops(Trace::default_flow_id())[0].last); + // assert_eq!( + // expected.jitter, + // trace.hops(Trace::default_flow_id())[0].jitter + // ); + // assert_eq!(expected.jmax, trace.hops(Trace::default_flow_id())[0].jmax); + // assert_eq!(expected.javg, trace.hops(Trace::default_flow_id())[0].javg); + // assert_eq!( + // format!("{:.2}", expected.jinta), + // format!("{:.2}", trace.hops(Trace::default_flow_id())[0].jinta) + // ); + } + #[test] + //#[ignore = "WIP"] + fn test_probe_raw_list() { + let json = r#"[{ + "sequence": 1, + "identifier": 1, + "src_port": 80, + "dest_port": 80, + "ttl": 1, + "round": 1, + "sent": "2023-01-01T12:01:55.100", + "status": "Complete", + "host": "10.1.0.2", + "received": "2023-01-01T12:01:55.400", + "icmp_packet_type": null, + "extensions": null + },{ + "sequence": 2, + "identifier": 1, + "src_port": 80, + "dest_port": 80, + "ttl": 1, + "round": 1, + "sent": "2023-01-01T12:01:56.100", + "status": "Complete", + "host": "10.1.0.2", + "received": "2023-01-01T12:01:56.800", + "icmp_packet_type": null, + "extensions": null + }]"#; + let mut trace = Trace::new(100); + let import_probes: Vec = serde_json::from_str(json).unwrap(); + let probes: Vec = import_probes.into_iter().map(Probe::from).collect(); + let round = TracerRound::new(&probes, TimeToLive(1), CompletionReason::TargetFound); + trace.update_from_round(&round); + println!("Hop = {:#?}", trace.hops(Trace::default_flow_id())[0]); + assert_eq!(2, trace.hops(Trace::default_flow_id())[0].total_recv); + } + + #[test] + fn test_probe_raw_single() { + let json = r#"{ + "sequence": 2, + "identifier": 2, + "src_port": 80, + "dest_port": 80, + "ttl": 63, + "round": 2, + "sent": "2022-01-01T12:02:00Z", + "status": "Complete", + "host": "10.1.0.1", + "received": "2022-01-01T12:02:01Z", + "icmp_packet_type": null, + "extensions": null + }"#; + + let iprobe: ImportProbe = serde_json::from_str(json).expect("Failed to deserialize JSON"); + let probe = Probe::from(iprobe); + let mut td = TraceData::new(2); + td.update_from_probe(&probe); + assert_eq!(probe.sequence.0, 2); + println!("Host: {:?}, Age: {:?}", probe.host, probe.sequence); + } + #[test] + #[ignore = "Possible integration test between TraceData,Hop & Probe."] + fn test_jitter() { + //let hop = Hop::default(); + let probe = Probe::default(); + let mut td = TraceData::new(2); + td.hops.push(Hop::default()); + td.hops.push(Hop::default()); + // + td.update_from_probe(&probe); + assert_eq!(td.hops[0].jitter, None); + } +} diff --git a/src/config/columns.rs b/src/config/columns.rs index ac69c9a35..84cacf7d4 100644 --- a/src/config/columns.rs +++ b/src/config/columns.rs @@ -71,6 +71,14 @@ pub enum TuiColumn { StdDev, /// The status of a hop. Status, + /// The jitter abs(RTTx-RTTx-1) + Jitter, + /// The jitter total average + Javg, + /// The worst or max jitter recorded. + Jmax, + /// The smoothed jitter reading + Jinta, } impl TryFrom for TuiColumn { @@ -89,6 +97,10 @@ impl TryFrom for TuiColumn { 'w' => Ok(Self::Worst), 'd' => Ok(Self::StdDev), 't' => Ok(Self::Status), + 'j' => Ok(Self::Jitter), + 'g' => Ok(Self::Javg), + 'x' => Ok(Self::Jmax), + 'i' => Ok(Self::Jinta), c => Err(anyhow!(format!("unknown column code: {c}"))), } } @@ -108,6 +120,10 @@ impl Display for TuiColumn { Self::Worst => write!(f, "w"), Self::StdDev => write!(f, "d"), Self::Status => write!(f, "t"), + Self::Jitter => write!(f, "j"), + Self::Javg => write!(f, "g"), + Self::Jmax => write!(f, "x"), + Self::Jinta => write!(f, "i"), } } } @@ -134,7 +150,7 @@ mod tests { } ///Negative test for invalid characters - #[test_case('x' ; "invalid x")] + #[test_case('k' ; "invalid x")] #[test_case('z' ; "invalid z")] fn test_try_invalid_char_for_tui_column(c: char) { // Negative test for an unknown character diff --git a/src/frontend/columns.rs b/src/frontend/columns.rs index 7cd1f1820..81dfc1d98 100644 --- a/src/frontend/columns.rs +++ b/src/frontend/columns.rs @@ -1,17 +1,39 @@ use crate::config::{TuiColumn, TuiColumns}; +use ratatui::layout::{Constraint, Rect}; 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 Columns { + /// Column width constraints. + pub fn constraints(&self, rect: Rect) -> Vec { + let total_fixed_width = self + .0 + .iter() + .map(|c| match c.width() { + ColumnWidth::Fixed(width) => width, + ColumnWidth::Variable => 0, + }) + .sum(); + let total_variable_width = rect.width.saturating_sub(total_fixed_width); + self.0 + .iter() + .map(|c| match c.width() { + ColumnWidth::Fixed(width) => Constraint::Min(width), + ColumnWidth::Variable => Constraint::Min(total_variable_width), + }) + .collect() + } +} + impl From for Columns { fn from(value: TuiColumns) -> Self { Self(value.0.into_iter().map(Column::from).collect()) } } -///Settings pop-up depends on format macro impl Display for Columns { fn fmt(&self, f: &mut Formatter<'_>) -> std::fmt::Result { let output: Vec = self.0.clone().into_iter().map(Column::into).collect(); @@ -44,6 +66,14 @@ pub enum Column { StdDev, /// The status of a hop. Status, + /// The jitter of a hop(RTTx-RTTx-1). + Jitter, + /// The Average Jitter + Javg, + /// The worst or max jitter hop RTT + Jmax, + /// The smoothed jitter reading for a hop + Jinta, } //Output a char for each column type @@ -61,6 +91,10 @@ impl From for char { Column::Worst => 'w', Column::StdDev => 'd', Column::Status => 't', + Column::Jitter => 'j', + Column::Javg => 'g', + Column::Jmax => 'x', + Column::Jinta => 'i', } } } @@ -79,6 +113,10 @@ impl From for Column { TuiColumn::Worst => Self::Worst, TuiColumn::StdDev => Self::StdDev, TuiColumn::Status => Self::Status, + TuiColumn::Jitter => Self::Jitter, + TuiColumn::Javg => Self::Javg, + TuiColumn::Jmax => Self::Jmax, + TuiColumn::Jinta => Self::Jinta, } } } @@ -97,25 +135,43 @@ impl Display for Column { Self::Worst => write!(f, "Wrst"), Self::StdDev => write!(f, "StDev"), Self::Status => write!(f, "Sts"), + Self::Jitter => write!(f, "Jttr"), + Self::Javg => write!(f, "Javg"), + Self::Jmax => write!(f, "Jmax"), + Self::Jinta => write!(f, "Jint"), } } } +/// Table column layout constraints. +#[derive(Debug, PartialEq)] +enum ColumnWidth { + /// A fixed size column. + Fixed(u16), + /// A column that will use the remaining space. + Variable, +} + impl Column { - pub fn width_pct(self) -> u16 { + /// The width of the column. + pub(self) fn width(self) -> ColumnWidth { #[allow(clippy::match_same_arms)] match self { - Self::Ttl => 3, - Self::Host => 42, - Self::LossPct => 5, - Self::Sent => 5, - Self::Received => 5, - Self::Last => 5, - Self::Average => 5, - Self::Best => 5, - Self::Worst => 5, - Self::StdDev => 5, - Self::Status => 5, + Self::Ttl => ColumnWidth::Fixed(4), + Self::Host => ColumnWidth::Variable, + Self::LossPct => ColumnWidth::Fixed(8), + Self::Sent => ColumnWidth::Fixed(7), + Self::Received => ColumnWidth::Fixed(7), + Self::Last => ColumnWidth::Fixed(7), + Self::Average => ColumnWidth::Fixed(7), + Self::Best => ColumnWidth::Fixed(7), + Self::Worst => ColumnWidth::Fixed(7), + Self::StdDev => ColumnWidth::Fixed(8), + Self::Status => ColumnWidth::Fixed(4), + Self::Jitter => ColumnWidth::Fixed(7), + Self::Javg => ColumnWidth::Fixed(7), + Self::Jmax => ColumnWidth::Fixed(7), + Self::Jinta => ColumnWidth::Fixed(7), } } } @@ -124,6 +180,7 @@ impl Column { mod tests { use test_case::test_case; + use crate::frontend::columns::ColumnWidth; use crate::{ config::{TuiColumn, TuiColumns}, frontend::columns::{Column, Columns}, @@ -174,14 +231,14 @@ mod tests { assert_eq!(format!("{c}"), heading); } - #[test_case(Column::Ttl, 3)] - #[test_case(Column::Host, 42)] - #[test_case(Column::LossPct, 5)] - fn test_column_width_percentage(column_type: Column, pct: u16) { - assert_eq!(column_type.width_pct(), pct); + #[test_case(Column::Ttl, &ColumnWidth::Fixed(4))] + #[test_case(Column::Host, &ColumnWidth::Variable)] + #[test_case(Column::LossPct, &ColumnWidth::Fixed(8))] + fn test_column_width(column_type: Column, width: &ColumnWidth) { + assert_eq!(column_type.width(), *width); } - ///Expect to test the Column Into flow + /// Expect to test the Column Into flow. #[test] fn test_columns_into_string_short() { let cols = Columns(vec![ @@ -193,7 +250,7 @@ mod tests { assert_eq!("hols", format!("{cols}")); } - ///Happy path test for full set of colummns + /// Happy path test for full set of columns. #[test] fn test_columns_into_string_happy_path() { let cols = Columns(vec![ @@ -208,11 +265,15 @@ mod tests { Column::Worst, Column::StdDev, Column::Status, + Column::Jitter, + Column::Javg, + Column::Jmax, + Column::Jinta, ]); - assert_eq!("holsravbwdt", format!("{cols}")); + assert_eq!("holsravbwdtjgxi", format!("{cols}")); } - ///Reverse subset test for subset of colummns + /// Reverse subset test for subset of columns. #[test] fn test_columns_into_string_reverse_str() { let cols = Columns(vec![ diff --git a/src/frontend/render/table.rs b/src/frontend/render/table.rs index 9368607f6..3d701472a 100644 --- a/src/frontend/render/table.rs +++ b/src/frontend/render/table.rs @@ -6,7 +6,7 @@ use crate::frontend::theme::Theme; use crate::frontend::tui_app::TuiApp; use crate::geoip::{GeoIpCity, GeoIpLookup}; use itertools::Itertools; -use ratatui::layout::{Constraint, Rect}; +use ratatui::layout::Rect; use ratatui::style::{Modifier, Style}; use ratatui::widgets::{Block, BorderType, Borders, Cell, Row, Table}; use ratatui::Frame; @@ -32,7 +32,7 @@ use trippy::tracing::{Extension, Extensions, MplsLabelStackMember, UnknownExtens /// - The status of this hop (`Sts`) pub fn render(f: &mut Frame<'_>, app: &mut TuiApp, rect: Rect) { let config = &app.tui_config; - let widths = get_column_widths(&config.tui_columns); + let widths = config.tui_columns.constraints(rect); 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| { @@ -59,8 +59,7 @@ pub fn render(f: &mut Frame<'_>, app: &mut TuiApp, rect: Rect) { .bg(app.tui_config.theme.bg_color) .fg(app.tui_config.theme.text_color), ) - .highlight_style(selected_style) - .column_spacing(1); + .highlight_style(selected_style); f.render_stateful_widget(table, rect, &mut app.table_state); } @@ -132,7 +131,7 @@ fn new_cell( ) -> Cell<'static> { let is_target = app.tracer_data().is_target(hop, app.selected_flow); match column { - Column::Ttl => render_ttl_cell(hop), + Column::Ttl => render_usize_cell(hop.ttl().into()), Column::Host => { let (host_cell, _) = if is_selected_hop && app.show_hop_details { render_hostname_with_details(app, hop, dns, geoip_lookup, config) @@ -142,30 +141,26 @@ fn new_cell( 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::Sent => render_usize_cell(hop.total_sent()), + Column::Received => render_usize_cell(hop.total_recv()), + Column::Last => render_float_cell(hop.last_ms(), 1), Column::Average => render_avg_cell(hop), - Column::Best => render_best_cell(hop), - Column::Worst => render_worst_cell(hop), + Column::Best => render_float_cell(hop.best_ms(), 1), + Column::Worst => render_float_cell(hop.worst_ms(), 1), Column::StdDev => render_stddev_cell(hop), Column::Status => render_status_cell(hop, is_target), + Column::Jitter => render_float_cell(hop.jitter_ms(), 1), + Column::Javg => render_float_cell(hop.javg_ms(), 1), + Column::Jmax => render_float_cell(hop.jmax_ms(), 1), + Column::Jinta => render_float_cell(hop.jinta(), 1), } } -fn render_ttl_cell(hop: &Hop) -> Cell<'static> { - Cell::from(format!("{}", hop.ttl())) -} - fn render_loss_pct_cell(hop: &Hop) -> Cell<'static> { Cell::from(format!("{:.1}%", hop.loss_pct())) } -fn render_total_sent_cell(hop: &Hop) -> Cell<'static> { - Cell::from(format!("{}", hop.total_sent())) -} - -fn render_total_recv_cell(hop: &Hop) -> Cell<'static> { - Cell::from(format!("{}", hop.total_recv())) +fn render_usize_cell(value: usize) -> Cell<'static> { + Cell::from(format!("{value}")) } fn render_avg_cell(hop: &Hop) -> Cell<'static> { @@ -176,30 +171,6 @@ fn render_avg_cell(hop: &Hop) -> Cell<'static> { }) } -fn render_last_cell(hop: &Hop) -> Cell<'static> { - Cell::from( - hop.last_ms() - .map(|last| format!("{last:.1}")) - .unwrap_or_default(), - ) -} - -fn render_best_cell(hop: &Hop) -> Cell<'static> { - Cell::from( - hop.best_ms() - .map(|best| format!("{best:.1}")) - .unwrap_or_default(), - ) -} - -fn render_worst_cell(hop: &Hop) -> Cell<'static> { - Cell::from( - hop.worst_ms() - .map(|worst| format!("{worst:.1}")) - .unwrap_or_default(), - ) -} - fn render_stddev_cell(hop: &Hop) -> Cell<'static> { Cell::from(if hop.total_recv() > 1 { format!("{:.1}", hop.stddev_ms()) @@ -207,6 +178,9 @@ fn render_stddev_cell(hop: &Hop) -> Cell<'static> { String::default() }) } +fn render_float_cell(value: Option, places: usize) -> Cell<'static> { + Cell::from(value.map_or(String::new(), |v| format!("{v:.places$}"))) +} fn render_status_cell(hop: &Hop, is_target: bool) -> Cell<'static> { let lost = hop.total_sent() - hop.total_recv(); @@ -604,14 +578,3 @@ fn fmt_details_line( }; format!("{addr} [{index} of {count}]\n{hosts_rendered}\n{as_formatted}\n{geoip_formatted}\n{ext_formatted}") } - -/// Transforms current columns list into percentages -/// -/// Returns the percentage constraints of columns -fn get_column_widths(columns: &Columns) -> Vec { - columns - .0 - .iter() - .map(|c| Constraint::Percentage(c.width_pct())) - .collect() -} diff --git a/src/report/types.rs b/src/report/types.rs index 7cde0733e..9f7921d19 100644 --- a/src/report/types.rs +++ b/src/report/types.rs @@ -35,6 +35,14 @@ pub struct Hop { pub worst: f64, #[serde(serialize_with = "fixed_width")] pub stddev: f64, + #[serde(serialize_with = "fixed_width")] + pub jitter: f64, + #[serde(serialize_with = "fixed_width")] + pub javg: f64, + #[serde(serialize_with = "fixed_width")] + pub jmax: f64, + #[serde(serialize_with = "fixed_width")] + pub jinta: f64, } impl From<(&backend::trace::Hop, &R)> for Hop { @@ -53,6 +61,10 @@ impl From<(&backend::trace::Hop, &R)> for Hop { best: value.best_ms().unwrap_or_default(), worst: value.worst_ms().unwrap_or_default(), stddev: value.stddev_ms(), + jitter: value.jitter_ms().unwrap_or_default(), + javg: value.javg_ms().unwrap_or_default(), + jmax: value.jmax_ms().unwrap_or_default(), + jinta: value.jinta().unwrap_or_default(), } } } @@ -72,7 +84,6 @@ impl<'a, R: Resolver, I: Iterator> From<(I, &R)> for Hosts { ) } } - impl Display for Hosts { fn fmt(&self, f: &mut Formatter<'_>) -> std::fmt::Result { write!(f, "{}", self.0.iter().format(", ")) diff --git a/src/tracing.rs b/src/tracing.rs index dfd613010..b96fdc4d3 100644 --- a/src/tracing.rs +++ b/src/tracing.rs @@ -3,8 +3,8 @@ mod constants; mod error; mod net; mod probe; -mod tracer; -mod types; +pub mod tracer; +pub mod types; /// Packet wire formats. pub mod packet; diff --git a/tests/data/base_line.json b/tests/data/base_line.json new file mode 100644 index 000000000..6c025c467 --- /dev/null +++ b/tests/data/base_line.json @@ -0,0 +1,27 @@ +[{ + "sequence": 1, + "identifier": 1, + "src_port": 80, + "dest_port": 80, + "ttl": 1, + "round": 1, + "sent": "2023-01-01T12:01:55.100", + "status": "Complete", + "host": "10.1.0.2", + "received": "2023-01-01T12:01:55.400", + "icmp_packet_type": null, + "extensions": null + },{ + "sequence": 2, + "identifier": 1, + "src_port": 80, + "dest_port": 80, + "ttl": 1, + "round": 1, + "sent": "2023-01-01T12:01:56.100", + "status": "Complete", + "host": "10.1.0.2", + "received": "2023-01-01T12:01:56.800", + "icmp_packet_type": null, + "extensions": null + }] \ No newline at end of file diff --git a/tests/data/jinta_1hop.json b/tests/data/jinta_1hop.json new file mode 100644 index 000000000..2e3efb628 --- /dev/null +++ b/tests/data/jinta_1hop.json @@ -0,0 +1,53 @@ +[{ + "sequence": 1, + "identifier": 1, + "src_port": 80, + "dest_port": 80, + "ttl": 1, + "round": 1, + "sent": "2023-01-01T12:01:55.100", + "status": "Complete", + "host": "10.1.0.2", + "received": "2023-01-01T12:01:55.300", + "icmp_packet_type": null, + "extensions": null + },{ + "sequence": 2, + "identifier": 1, + "src_port": 80, + "dest_port": 80, + "ttl": 1, + "round": 1, + "sent": "2023-01-01T12:01:56.100", + "status": "Complete", + "host": "10.1.0.2", + "received": "2023-01-01T12:01:56.200", + "icmp_packet_type": null, + "extensions": null + },{ + "sequence": 3, + "identifier": 1, + "src_port": 80, + "dest_port": 80, + "ttl": 1, + "round": 1, + "sent": "2023-01-01T12:01:57.100", + "status": "Complete", + "host": "10.1.0.2", + "received": "2023-01-01T12:01:57.250", + "icmp_packet_type": null, + "extensions": null + },{ + "sequence": 4, + "identifier": 1, + "src_port": 80, + "dest_port": 80, + "ttl": 1, + "round": 1, + "sent": "2023-01-01T12:01:58.000", + "status": "Complete", + "host": "10.1.0.2", + "received": "2023-01-01T12:01:59.001", + "icmp_packet_type": null, + "extensions": null + }] \ No newline at end of file diff --git a/tests/expected/jinta_1hop.json b/tests/expected/jinta_1hop.json new file mode 100644 index 000000000..3f6e07336 --- /dev/null +++ b/tests/expected/jinta_1hop.json @@ -0,0 +1,13 @@ +{ + "total_sent": 4, + "total_recv": 4, + "last": 1001, + "best": 300, + "worst": 700, + "mean": 0.0, + "m2": 0.0, + "jitter": 851, + "javg": 300.25, + "jmax": 851, + "jinta": 1148.74 +} \ No newline at end of file