From 974e7062ddd6d29a50a8ef753688a4d80ba59a7f Mon Sep 17 00:00:00 2001 From: Jannik Straube Date: Tue, 30 Dec 2025 19:00:29 +0100 Subject: [PATCH 1/4] Add real-time TUI charts and enhanced simulation - Sparklines in main table showing throughput history - Expandable detail view with full line charts (throughput/packets/errors) - Sophisticated traffic simulation with realistic HPC patterns - Ring buffer storage for historical metrics - Auto-scaling units (GB/s, MB/s, KB/s) - Keyboard navigation (j/k, Enter, Tab) - Pulsing status indicators for active ports --- Cargo.lock | 278 ++++++++++----- Cargo.toml | 7 +- src/discovery/fake.rs | 84 ----- src/discovery/mod.rs | 2 - src/history.rs | 439 ++++++++++++++++++++++++ src/main.rs | 26 +- src/metrics.rs | 42 +++ src/simulation.rs | 406 ++++++++++++++++++++++ src/ui.rs | 781 ++++++++++++++++++++++++++++++++++++------ 9 files changed, 1778 insertions(+), 287 deletions(-) delete mode 100644 src/discovery/fake.rs create mode 100644 src/history.rs create mode 100644 src/simulation.rs diff --git a/Cargo.lock b/Cargo.lock index 2160e31..87f7067 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -43,28 +43,29 @@ checksum = "9555578bc9e57714c812a1f84e4fc5b4d21fcb063490c624de019f7464c91268" [[package]] name = "compact_str" -version = "0.7.1" +version = "0.8.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f86b9c4c00838774a6d902ef931eff7470720c51d90c2e32cfe15dc304737b3f" +checksum = "3b79c4069c6cad78e2e0cdfcbd26275770669fb39fd308a752dc110e83b9af32" dependencies = [ "castaway", "cfg-if", "itoa", + "rustversion", "ryu", "static_assertions", ] [[package]] name = "crossterm" -version = "0.27.0" +version = "0.28.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f476fe445d41c9e991fd07515a6f463074b782242ccf4a5b7b1d1012e70824df" +checksum = "829d955a0bb380ef178a640b91779e3987da38c9aea133b20614cfed8cdea9c6" dependencies = [ "bitflags", "crossterm_winapi", - "libc", "mio", "parking_lot", + "rustix 0.38.44", "signal-hook", "signal-hook-mio", "winapi", @@ -79,6 +80,41 @@ dependencies = [ "winapi", ] +[[package]] +name = "darling" +version = "0.20.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fc7f46116c46ff9ab3eb1597a45688b6715c6e628b5c133e288e709a29bcb4ee" +dependencies = [ + "darling_core", + "darling_macro", +] + +[[package]] +name = "darling_core" +version = "0.20.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0d00b9596d185e565c2207a0b01f8bd1a135483d02d9b7b0a54b11da8d53412e" +dependencies = [ + "fnv", + "ident_case", + "proc-macro2", + "quote", + "strsim", + "syn", +] + +[[package]] +name = "darling_macro" +version = "0.20.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fc34b93ccb385b40dc71c6fceac4b2ad23662c7eeb248cf10d529b7e055b6ead" +dependencies = [ + "darling_core", + "quote", + "syn", +] + [[package]] name = "either" version = "1.15.0" @@ -91,6 +127,28 @@ version = "1.0.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "877a4ace8713b0bcf2a4e7eec82529c029f1d0619886d18145fea96c3ffe5c0f" +[[package]] +name = "errno" +version = "0.3.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "39cab71617ae0d63f51a36d69f866391735b51691dbda63cf6f96d042b63efeb" +dependencies = [ + "libc", + "windows-sys 0.61.2", +] + +[[package]] +name = "fastrand" +version = "2.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "37909eebbb50d72f9059c3b6d82c0463f2ff062c9e95845c43a6c9c0355411be" + +[[package]] +name = "fnv" +version = "1.0.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3f9eec918d3f24069decb9af1554cad7c880e2da24a9afd88aca000531ab82c1" + [[package]] name = "foldhash" version = "0.1.5" @@ -147,15 +205,35 @@ dependencies = [ "ratatui", "serde", "serde_json", + "tempfile", ] [[package]] -name = "itertools" -version = "0.12.1" +name = "ident_case" +version = "1.0.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ba291022dbbd398a455acf126c1e341954079855bc60dfdda641363bd6922569" +checksum = "b9e0384b61958566e926dc50660321d12159025e767c18e043daf26b70104c39" + +[[package]] +name = "indoc" +version = "2.0.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "79cf5c93f93228cf8efb3ba362535fb11199ac548a09ce117c9b1adc3030d706" dependencies = [ - "either", + "rustversion", +] + +[[package]] +name = "instability" +version = "0.3.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6778b0196eefee7df739db78758e5cf9b37412268bfa5650bfeed028aed20d9c" +dependencies = [ + "darling", + "indoc", + "proc-macro2", + "quote", + "syn", ] [[package]] @@ -175,9 +253,21 @@ checksum = "4a5f13b858c8d314ee3e8f639011f7ccefe71f97f96e50151fb991f267928e2c" [[package]] name = "libc" -version = "0.2.175" +version = "0.2.178" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "37c93d8daa9d8a012fd8ab92f088405fb202ea0b6ab73ee2482ae66af4f42091" + +[[package]] +name = "linux-raw-sys" +version = "0.4.15" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6a82ae493e598baaea5209805c49bbf2ea7de956d50d7da0da1164f9c6d28543" +checksum = "d26c52dbd32dccf2d10cac7725f8eae5296885fb5703b261f7d0a0739ec807ab" + +[[package]] +name = "linux-raw-sys" +version = "0.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "df1d3c3b53da64cf5760482273a98e575c651a67eec7f77df96b5b642de8f039" [[package]] name = "lock_api" @@ -212,16 +302,22 @@ checksum = "32a282da65faaf38286cf3be983213fcf1d2e2a58700e808f83f4ea9a4804bc0" [[package]] name = "mio" -version = "0.8.11" +version = "1.0.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a4a650543ca06a924e8b371db273b2756685faae30f8487da1b56505a8f78b0c" +checksum = "78bed444cc8a2160f01cbcf811ef18cac863ad68ae8ca62092e8db51d51c761c" dependencies = [ "libc", "log", "wasi 0.11.1+wasi-snapshot-preview1", - "windows-sys", + "windows-sys 0.59.0", ] +[[package]] +name = "once_cell" +version = "1.21.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "42f5e15c9953c5e4ccceeb2e7382a716482c34515315f7b03532b8b4e8393d2d" + [[package]] name = "parking_lot" version = "0.12.4" @@ -242,7 +338,7 @@ dependencies = [ "libc", "redox_syscall", "smallvec", - "windows-targets 0.52.6", + "windows-targets", ] [[package]] @@ -315,22 +411,23 @@ dependencies = [ [[package]] name = "ratatui" -version = "0.26.3" +version = "0.29.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f44c9e68fd46eda15c646fbb85e1040b657a58cdc8c98db1d97a55930d991eef" +checksum = "eabd94c2f37801c20583fc49dd5cd6b0ba68c716787c2dd6ed18571e1e63117b" dependencies = [ "bitflags", "cassowary", "compact_str", "crossterm", - "itertools 0.12.1", + "indoc", + "instability", + "itertools", "lru", "paste", - "stability", "strum", "unicode-segmentation", "unicode-truncate", - "unicode-width", + "unicode-width 0.2.0", ] [[package]] @@ -342,6 +439,32 @@ dependencies = [ "bitflags", ] +[[package]] +name = "rustix" +version = "0.38.44" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fdb5bc1ae2baa591800df16c9ca78619bf65c0488b41b96ccec5d11220d8c154" +dependencies = [ + "bitflags", + "errno", + "libc", + "linux-raw-sys 0.4.15", + "windows-sys 0.59.0", +] + +[[package]] +name = "rustix" +version = "1.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "146c9e247ccc180c1f61615433868c99f3de3ae256a30a43b49f67c2d9171f34" +dependencies = [ + "bitflags", + "errno", + "libc", + "linux-raw-sys 0.11.0", + "windows-sys 0.61.2", +] + [[package]] name = "rustversion" version = "1.0.22" @@ -428,22 +551,18 @@ version = "1.15.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "67b1b7a3b5fe4f1376887184045fcf45c69e92af734b7aaddc05fb777b6fbd03" -[[package]] -name = "stability" -version = "0.2.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d904e7009df136af5297832a3ace3370cd14ff1546a232f4f185036c2736fcac" -dependencies = [ - "quote", - "syn", -] - [[package]] name = "static_assertions" version = "1.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a2eb9349b6444b326872e140eb1cf5e7c522154d69e7a0ffb0fb81c06b37543f" +[[package]] +name = "strsim" +version = "0.11.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7da8b5736845d9f2fcb837ea5d9e2628564b3b043a70948a3f0b778838c5fb4f" + [[package]] name = "strum" version = "0.26.3" @@ -477,6 +596,19 @@ dependencies = [ "unicode-ident", ] +[[package]] +name = "tempfile" +version = "3.24.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "655da9c7eb6305c55742045d5a8d2037996d61d8de95806335c7c86ce0f82e9c" +dependencies = [ + "fastrand", + "getrandom", + "once_cell", + "rustix 1.1.3", + "windows-sys 0.61.2", +] + [[package]] name = "unicode-ident" version = "1.0.18" @@ -495,9 +627,9 @@ version = "1.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b3644627a5af5fa321c95b9b235a72fd24cd29c648c2c379431e6628655627bf" dependencies = [ - "itertools 0.13.0", + "itertools", "unicode-segmentation", - "unicode-width", + "unicode-width 0.1.14", ] [[package]] @@ -506,6 +638,12 @@ version = "0.1.14" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7dd6e30e90baa6f72411720665d41d89b9a3d039dc45b8faea1ddd07f617f6af" +[[package]] +name = "unicode-width" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1fc81956842c57dac11422a97c3b8195a1ff727f06e85c84ed2e8aa277c9a0fd" + [[package]] name = "wasi" version = "0.11.1+wasi-snapshot-preview1" @@ -551,26 +689,20 @@ checksum = "f0805222e57f7521d6a62e36fa9163bc891acd422f971defe97d64e70d0a4fe5" [[package]] name = "windows-sys" -version = "0.48.0" +version = "0.59.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "677d2418bec65e3338edb076e806bc1ec15693c5d0104683f2efe857f61056a9" +checksum = "1e38bc4d79ed67fd075bcc251a1c39b32a1776bbe92e5bef1f0bf1f8c531853b" dependencies = [ - "windows-targets 0.48.5", + "windows-targets", ] [[package]] -name = "windows-targets" -version = "0.48.5" +name = "windows-sys" +version = "0.61.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9a2fa6e2155d7247be68c096456083145c183cbbbc2764150dda45a87197940c" +checksum = "ae137229bcbd6cdf0f7b80a31df61766145077ddf49416a728b02cb3921ff3fc" dependencies = [ - "windows_aarch64_gnullvm 0.48.5", - "windows_aarch64_msvc 0.48.5", - "windows_i686_gnu 0.48.5", - "windows_i686_msvc 0.48.5", - "windows_x86_64_gnu 0.48.5", - "windows_x86_64_gnullvm 0.48.5", - "windows_x86_64_msvc 0.48.5", + "windows-link", ] [[package]] @@ -579,46 +711,28 @@ version = "0.52.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9b724f72796e036ab90c1021d4780d4d3d648aca59e491e6b98e725b84e99973" dependencies = [ - "windows_aarch64_gnullvm 0.52.6", - "windows_aarch64_msvc 0.52.6", - "windows_i686_gnu 0.52.6", + "windows_aarch64_gnullvm", + "windows_aarch64_msvc", + "windows_i686_gnu", "windows_i686_gnullvm", - "windows_i686_msvc 0.52.6", - "windows_x86_64_gnu 0.52.6", - "windows_x86_64_gnullvm 0.52.6", - "windows_x86_64_msvc 0.52.6", + "windows_i686_msvc", + "windows_x86_64_gnu", + "windows_x86_64_gnullvm", + "windows_x86_64_msvc", ] -[[package]] -name = "windows_aarch64_gnullvm" -version = "0.48.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2b38e32f0abccf9987a4e3079dfb67dcd799fb61361e53e2882c3cbaf0d905d8" - [[package]] name = "windows_aarch64_gnullvm" version = "0.52.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "32a4622180e7a0ec044bb555404c800bc9fd9ec262ec147edd5989ccd0c02cd3" -[[package]] -name = "windows_aarch64_msvc" -version = "0.48.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "dc35310971f3b2dbbf3f0690a219f40e2d9afcf64f9ab7cc1be722937c26b4bc" - [[package]] name = "windows_aarch64_msvc" version = "0.52.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "09ec2a7bb152e2252b53fa7803150007879548bc709c039df7627cabbd05d469" -[[package]] -name = "windows_i686_gnu" -version = "0.48.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a75915e7def60c94dcef72200b9a8e58e5091744960da64ec734a6c6e9b3743e" - [[package]] name = "windows_i686_gnu" version = "0.52.6" @@ -631,48 +745,24 @@ version = "0.52.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0eee52d38c090b3caa76c563b86c3a4bd71ef1a819287c19d586d7334ae8ed66" -[[package]] -name = "windows_i686_msvc" -version = "0.48.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8f55c233f70c4b27f66c523580f78f1004e8b5a8b659e05a4eb49d4166cca406" - [[package]] name = "windows_i686_msvc" version = "0.52.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "240948bc05c5e7c6dabba28bf89d89ffce3e303022809e73deaefe4f6ec56c66" -[[package]] -name = "windows_x86_64_gnu" -version = "0.48.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "53d40abd2583d23e4718fddf1ebec84dbff8381c07cae67ff7768bbf19c6718e" - [[package]] name = "windows_x86_64_gnu" version = "0.52.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "147a5c80aabfbf0c7d901cb5895d1de30ef2907eb21fbbab29ca94c5b08b1a78" -[[package]] -name = "windows_x86_64_gnullvm" -version = "0.48.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0b7b52767868a23d5bab768e390dc5f5c55825b6d30b86c844ff2dc7414044cc" - [[package]] name = "windows_x86_64_gnullvm" version = "0.52.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "24d5b23dc417412679681396f2b49f3de8c1473deb516bd34410872eff51ed0d" -[[package]] -name = "windows_x86_64_msvc" -version = "0.48.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ed94fce61571a4006852b7389a063ab983c02eb1bb37b47f8272ce92d06d9538" - [[package]] name = "windows_x86_64_msvc" version = "0.52.6" diff --git a/Cargo.toml b/Cargo.toml index ae1a3c8..333f746 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -12,12 +12,15 @@ keywords = ["infiniband", "monitoring", "tui", "network", "rdma"] categories = ["command-line-utilities", "network-programming"] [dependencies] -ratatui = "0.26" -crossterm = "0.27" +ratatui = "0.29" +crossterm = "0.28" rand = "0.9.2" serde = { version = "1.0", features = ["derive"] } serde_json = "1.0" hostname = "0.4" +[dev-dependencies] +tempfile = "3.14" + [lints.clippy] pedantic = "deny" \ No newline at end of file diff --git a/src/discovery/fake.rs b/src/discovery/fake.rs deleted file mode 100644 index e9072cd..0000000 --- a/src/discovery/fake.rs +++ /dev/null @@ -1,84 +0,0 @@ -use crate::types::{AdapterInfo, PortCounters, PortInfo}; -use std::sync::atomic::{AtomicU64, Ordering}; - -static BASE_RX_BYTES_0: AtomicU64 = AtomicU64::new(1_234_567_890); -static BASE_TX_BYTES_0: AtomicU64 = AtomicU64::new(987_654_321); -static BASE_RX_PACKETS_0: AtomicU64 = AtomicU64::new(1_000_000); -static BASE_TX_PACKETS_0: AtomicU64 = AtomicU64::new(950_000); -static BASE_RX_ERRORS_0: AtomicU64 = AtomicU64::new(12); -static BASE_TX_ERRORS_0: AtomicU64 = AtomicU64::new(5); - -static BASE_RX_BYTES_1: AtomicU64 = AtomicU64::new(5_555_555_555); -static BASE_TX_BYTES_1: AtomicU64 = AtomicU64::new(4_444_444_444); -static BASE_RX_PACKETS_1: AtomicU64 = AtomicU64::new(2_500_000); -static BASE_TX_PACKETS_1: AtomicU64 = AtomicU64::new(2_400_000); -static BASE_RX_ERRORS_1: AtomicU64 = AtomicU64::new(8); -static BASE_TX_ERRORS_1: AtomicU64 = AtomicU64::new(3); -static BASE_RX_DROPPED_1: AtomicU64 = AtomicU64::new(1); - -pub fn generate_fake_adapters() -> Vec { - let rx_bytes_0 = BASE_RX_BYTES_0.fetch_add(rand::random::() % 100_000, Ordering::Relaxed); - let tx_bytes_0 = BASE_TX_BYTES_0.fetch_add(rand::random::() % 80_000, Ordering::Relaxed); - let rx_packets_0 = BASE_RX_PACKETS_0.fetch_add(rand::random::() % 1000, Ordering::Relaxed); - let tx_packets_0 = BASE_TX_PACKETS_0.fetch_add(rand::random::() % 900, Ordering::Relaxed); - let rx_errors_0 = - BASE_RX_ERRORS_0.fetch_add(u64::from(rand::random::() % 100 < 5), Ordering::Relaxed); - let tx_errors_0 = - BASE_TX_ERRORS_0.fetch_add(u64::from(rand::random::() % 100 < 3), Ordering::Relaxed); - - let rx_bytes_1 = BASE_RX_BYTES_1.fetch_add(rand::random::() % 150_000, Ordering::Relaxed); - let tx_bytes_1 = BASE_TX_BYTES_1.fetch_add(rand::random::() % 120_000, Ordering::Relaxed); - let rx_packets_1 = BASE_RX_PACKETS_1.fetch_add(rand::random::() % 1500, Ordering::Relaxed); - let tx_packets_1 = BASE_TX_PACKETS_1.fetch_add(rand::random::() % 1400, Ordering::Relaxed); - let rx_errors_1 = - BASE_RX_ERRORS_1.fetch_add(u64::from(rand::random::() % 100 < 4), Ordering::Relaxed); - let tx_errors_1 = - BASE_TX_ERRORS_1.fetch_add(u64::from(rand::random::() % 100 < 2), Ordering::Relaxed); - let rx_dropped_1 = - BASE_RX_DROPPED_1.fetch_add(u64::from(rand::random::() % 100 < 1), Ordering::Relaxed); - - vec![ - AdapterInfo { - name: "mlx5_0".to_string(), - ports: vec![ - PortInfo { - port_number: 1, - state: crate::types::PortState::Active, - rate: "100 Gb/sec (4X EDR)".to_string(), - counters: PortCounters { - rx_bytes: rx_bytes_0, - tx_bytes: tx_bytes_0, - rx_packets: rx_packets_0, - tx_packets: tx_packets_0, - rx_errors: rx_errors_0, - tx_errors: tx_errors_0, - rx_dropped: 0, - }, - }, - PortInfo { - port_number: 2, - state: crate::types::PortState::Down, - rate: "100 Gb/sec (4X EDR)".to_string(), - counters: PortCounters::default(), - }, - ], - }, - AdapterInfo { - name: "mlx5_1".to_string(), - ports: vec![PortInfo { - port_number: 1, - state: crate::types::PortState::Active, - rate: "200 Gb/sec (4X HDR)".to_string(), - counters: PortCounters { - rx_bytes: rx_bytes_1, - tx_bytes: tx_bytes_1, - rx_packets: rx_packets_1, - tx_packets: tx_packets_1, - rx_errors: rx_errors_1, - tx_errors: tx_errors_1, - rx_dropped: rx_dropped_1, - }, - }], - }, - ] -} diff --git a/src/discovery/mod.rs b/src/discovery/mod.rs index 5929c32..b879a0f 100644 --- a/src/discovery/mod.rs +++ b/src/discovery/mod.rs @@ -1,5 +1,3 @@ -pub(crate) mod fake; - use crate::types::{AdapterInfo, PortCounters, PortInfo, PortState}; const MLX5_DATA_MULTIPLIER: u64 = 4; // mlx5 reports in 32-bit words diff --git a/src/history.rs b/src/history.rs new file mode 100644 index 0000000..ea1628c --- /dev/null +++ b/src/history.rs @@ -0,0 +1,439 @@ +//! Historical data storage with configurable time windows +//! +//! Provides ring buffer storage for time-series metrics data, +//! enabling sparklines and charts in the TUI. + +#![allow(dead_code)] // Many methods are for future use or testing +#![allow(clippy::cast_precision_loss)] // Acceptable for metrics +#![allow(clippy::cast_possible_truncation)] // Acceptable for sparkline values +#![allow(clippy::cast_sign_loss)] // Values are always positive + +use std::collections::HashMap; + +/// Default history length (number of samples) +pub const DEFAULT_HISTORY_SIZE: usize = 120; // 30 seconds at 4 samples/sec + +/// Ring buffer for storing historical values +#[derive(Debug, Clone)] +pub struct RingBuffer { + data: Vec, + capacity: usize, + write_pos: usize, + len: usize, +} + +impl RingBuffer { + /// Create a new ring buffer with the specified capacity + pub fn new(capacity: usize) -> Self { + Self { + data: vec![T::default(); capacity], + capacity, + write_pos: 0, + len: 0, + } + } + + /// Push a new value into the buffer + pub fn push(&mut self, value: T) { + self.data[self.write_pos] = value; + self.write_pos = (self.write_pos + 1) % self.capacity; + self.len = self.len.saturating_add(1).min(self.capacity); + } + + /// Get the number of elements currently in the buffer + pub fn len(&self) -> usize { + self.len + } + + /// Check if the buffer is empty + pub fn is_empty(&self) -> bool { + self.len == 0 + } + + /// Get the most recent value + pub fn last(&self) -> Option<&T> { + if self.len == 0 { + return None; + } + let idx = if self.write_pos == 0 { + self.capacity - 1 + } else { + self.write_pos - 1 + }; + Some(&self.data[idx]) + } + + /// Get values in chronological order (oldest to newest) + pub fn iter(&self) -> impl Iterator { + let start = if self.len < self.capacity { + 0 + } else { + self.write_pos + }; + + (0..self.len).map(move |i| { + let idx = (start + i) % self.capacity; + &self.data[idx] + }) + } + + /// Get the last N values in chronological order + pub fn last_n(&self, n: usize) -> impl Iterator { + let take_count = n.min(self.len); + let skip_count = self.len.saturating_sub(take_count); + self.iter().skip(skip_count) + } + + /// Get values as a vector (chronological order) + pub fn to_vec(&self) -> Vec { + self.iter().cloned().collect() + } + + /// Clear all data + pub fn clear(&mut self) { + self.write_pos = 0; + self.len = 0; + } + + /// Get capacity + pub fn capacity(&self) -> usize { + self.capacity + } +} + +/// Historical metrics for a single port +#[derive(Debug, Clone)] +pub struct PortHistory { + pub rx_bytes_per_sec: RingBuffer, + pub tx_bytes_per_sec: RingBuffer, + pub rx_packets_per_sec: RingBuffer, + pub tx_packets_per_sec: RingBuffer, + pub error_rate: RingBuffer, +} + +impl PortHistory { + /// Create a new port history with default capacity + pub fn new() -> Self { + Self::with_capacity(DEFAULT_HISTORY_SIZE) + } + + /// Create a new port history with specified capacity + pub fn with_capacity(capacity: usize) -> Self { + Self { + rx_bytes_per_sec: RingBuffer::new(capacity), + tx_bytes_per_sec: RingBuffer::new(capacity), + rx_packets_per_sec: RingBuffer::new(capacity), + tx_packets_per_sec: RingBuffer::new(capacity), + error_rate: RingBuffer::new(capacity), + } + } + + /// Record a new data point + pub fn record(&mut self, rx_bps: f64, tx_bps: f64, rx_pps: f64, tx_pps: f64, errors: f64) { + self.rx_bytes_per_sec.push(rx_bps); + self.tx_bytes_per_sec.push(tx_bps); + self.rx_packets_per_sec.push(rx_pps); + self.tx_packets_per_sec.push(tx_pps); + self.error_rate.push(errors); + } + + /// Get sparkline data for RX throughput (last N samples, normalized to 0-1) + pub fn rx_sparkline_data(&self, samples: usize) -> Vec { + normalize_for_sparkline(self.rx_bytes_per_sec.last_n(samples)) + } + + /// Get sparkline data for TX throughput (last N samples, normalized to 0-1) + pub fn tx_sparkline_data(&self, samples: usize) -> Vec { + normalize_for_sparkline(self.tx_bytes_per_sec.last_n(samples)) + } + + /// Get combined RX+TX sparkline data + pub fn combined_sparkline_data(&self, samples: usize) -> Vec { + let rx: Vec = self.rx_bytes_per_sec.last_n(samples).copied().collect(); + let tx: Vec = self.tx_bytes_per_sec.last_n(samples).copied().collect(); + + let combined: Vec = rx.iter().zip(tx.iter()).map(|(r, t)| r + t).collect(); + normalize_for_sparkline(combined.iter()) + } + + /// Get the peak throughput observed + pub fn peak_throughput(&self) -> f64 { + let rx_max = self + .rx_bytes_per_sec + .iter() + .copied() + .fold(0.0_f64, f64::max); + let tx_max = self + .tx_bytes_per_sec + .iter() + .copied() + .fold(0.0_f64, f64::max); + rx_max + tx_max + } + + /// Get average throughput + pub fn avg_throughput(&self) -> f64 { + if self.rx_bytes_per_sec.is_empty() { + return 0.0; + } + let rx_sum: f64 = self.rx_bytes_per_sec.iter().sum(); + let tx_sum: f64 = self.tx_bytes_per_sec.iter().sum(); + (rx_sum + tx_sum) / self.rx_bytes_per_sec.len() as f64 + } +} + +impl Default for PortHistory { + fn default() -> Self { + Self::new() + } +} + +/// Normalize values for sparkline display (0-7 range for 8-level sparkline) +fn normalize_for_sparkline<'a>(values: impl Iterator) -> Vec { + let values: Vec = values.copied().collect(); + if values.is_empty() { + return vec![]; + } + + let max = values.iter().copied().fold(0.0_f64, f64::max); + if max <= 0.0 { + return vec![0; values.len()]; + } + + values + .iter() + .map(|v| ((v / max) * 7.0).round() as u64) + .collect() +} + +/// Collection of all port histories +#[derive(Debug, Default)] +pub struct HistoryCollector { + histories: HashMap, + capacity: usize, +} + +impl HistoryCollector { + /// Create a new history collector with default capacity + pub fn new() -> Self { + Self::with_capacity(DEFAULT_HISTORY_SIZE) + } + + /// Create a new history collector with specified capacity + pub fn with_capacity(capacity: usize) -> Self { + Self { + histories: HashMap::new(), + capacity, + } + } + + /// Get or create history for a port + pub fn get_or_create(&mut self, adapter: &str, port: u16) -> &mut PortHistory { + let key = format!("{adapter}:{port}"); + self.histories + .entry(key) + .or_insert_with(|| PortHistory::with_capacity(self.capacity)) + } + + /// Get history for a port (read-only) + pub fn get(&self, adapter: &str, port: u16) -> Option<&PortHistory> { + let key = format!("{adapter}:{port}"); + self.histories.get(&key) + } + + /// Record metrics for a port + #[allow(clippy::too_many_arguments)] + pub fn record( + &mut self, + adapter: &str, + port: u16, + rx_bps: f64, + tx_bps: f64, + rx_pps: f64, + tx_pps: f64, + errors: f64, + ) { + self.get_or_create(adapter, port) + .record(rx_bps, tx_bps, rx_pps, tx_pps, errors); + } + + /// Remove stale entries for ports that no longer exist + pub fn retain_ports(&mut self, active_ports: &[(String, u16)]) { + let active_keys: std::collections::HashSet = active_ports + .iter() + .map(|(adapter, port)| format!("{adapter}:{port}")) + .collect(); + + self.histories.retain(|key, _| active_keys.contains(key)); + } + + /// Get all port keys + pub fn keys(&self) -> impl Iterator { + self.histories.keys() + } + + /// Get total number of tracked ports + pub fn port_count(&self) -> usize { + self.histories.len() + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_ring_buffer_basic() { + let mut buf: RingBuffer = RingBuffer::new(3); + + assert!(buf.is_empty()); + assert_eq!(buf.len(), 0); + + buf.push(1); + buf.push(2); + buf.push(3); + + assert_eq!(buf.len(), 3); + assert!(!buf.is_empty()); + + let values: Vec = buf.iter().copied().collect(); + assert_eq!(values, vec![1, 2, 3]); + } + + #[test] + fn test_ring_buffer_overflow() { + let mut buf: RingBuffer = RingBuffer::new(3); + + buf.push(1); + buf.push(2); + buf.push(3); + buf.push(4); // Overwrites 1 + + assert_eq!(buf.len(), 3); + + let values: Vec = buf.iter().copied().collect(); + assert_eq!(values, vec![2, 3, 4]); + } + + #[test] + fn test_ring_buffer_last() { + let mut buf: RingBuffer = RingBuffer::new(5); + + assert!(buf.last().is_none()); + + buf.push(10); + assert_eq!(buf.last(), Some(&10)); + + buf.push(20); + buf.push(30); + assert_eq!(buf.last(), Some(&30)); + } + + #[test] + fn test_ring_buffer_last_n() { + let mut buf: RingBuffer = RingBuffer::new(5); + + for i in 1..=10 { + buf.push(i); + } + + let last_3: Vec = buf.last_n(3).copied().collect(); + assert_eq!(last_3, vec![8, 9, 10]); + + let last_10: Vec = buf.last_n(10).copied().collect(); + assert_eq!(last_10, vec![6, 7, 8, 9, 10]); + } + + #[test] + fn test_port_history_record() { + let mut history = PortHistory::with_capacity(10); + + history.record(1000.0, 500.0, 10.0, 5.0, 0.0); + history.record(2000.0, 1000.0, 20.0, 10.0, 0.1); + + assert_eq!(history.rx_bytes_per_sec.len(), 2); + assert_eq!(history.tx_bytes_per_sec.len(), 2); + } + + #[test] + fn test_normalize_for_sparkline() { + let values = vec![0.0, 50.0, 100.0, 25.0, 75.0]; + let normalized = normalize_for_sparkline(values.iter()); + + assert_eq!(normalized.len(), 5); + assert_eq!(normalized[0], 0); // 0% + assert_eq!(normalized[2], 7); // 100% + } + + #[test] + fn test_normalize_empty() { + let values: Vec = vec![]; + let normalized = normalize_for_sparkline(values.iter()); + assert!(normalized.is_empty()); + } + + #[test] + fn test_normalize_all_zero() { + let values = vec![0.0, 0.0, 0.0]; + let normalized = normalize_for_sparkline(values.iter()); + assert_eq!(normalized, vec![0, 0, 0]); + } + + #[test] + fn test_history_collector_basic() { + let mut collector = HistoryCollector::new(); + + collector.record("mlx5_0", 1, 1000.0, 500.0, 10.0, 5.0, 0.0); + collector.record("mlx5_0", 2, 2000.0, 1000.0, 20.0, 10.0, 0.1); + + assert_eq!(collector.port_count(), 2); + assert!(collector.get("mlx5_0", 1).is_some()); + assert!(collector.get("mlx5_0", 2).is_some()); + assert!(collector.get("mlx5_1", 1).is_none()); + } + + #[test] + fn test_history_collector_retain() { + let mut collector = HistoryCollector::new(); + + collector.record("mlx5_0", 1, 1000.0, 500.0, 10.0, 5.0, 0.0); + collector.record("mlx5_0", 2, 2000.0, 1000.0, 20.0, 10.0, 0.1); + collector.record("mlx5_1", 1, 3000.0, 1500.0, 30.0, 15.0, 0.0); + + assert_eq!(collector.port_count(), 3); + + // Retain only mlx5_0:1 and mlx5_1:1 + collector.retain_ports(&[ + ("mlx5_0".to_string(), 1), + ("mlx5_1".to_string(), 1), + ]); + + assert_eq!(collector.port_count(), 2); + assert!(collector.get("mlx5_0", 1).is_some()); + assert!(collector.get("mlx5_0", 2).is_none()); + assert!(collector.get("mlx5_1", 1).is_some()); + } + + #[test] + fn test_port_history_peak_throughput() { + let mut history = PortHistory::with_capacity(10); + + history.record(1000.0, 500.0, 10.0, 5.0, 0.0); + history.record(2000.0, 1500.0, 20.0, 10.0, 0.0); + history.record(500.0, 250.0, 5.0, 2.0, 0.0); + + // Peak is 2000 + 1500 = 3500 + assert!((history.peak_throughput() - 3500.0).abs() < 0.001); + } + + #[test] + fn test_port_history_avg_throughput() { + let mut history = PortHistory::with_capacity(10); + + history.record(1000.0, 500.0, 10.0, 5.0, 0.0); + history.record(2000.0, 1000.0, 20.0, 10.0, 0.0); + + // Avg is ((1000+500) + (2000+1000)) / 2 = 4500 / 2 = 2250 + assert!((history.avg_throughput() - 2250.0).abs() < 0.001); + } +} diff --git a/src/main.rs b/src/main.rs index d29fefb..2565e2a 100644 --- a/src/main.rs +++ b/src/main.rs @@ -1,5 +1,7 @@ mod discovery; +mod history; mod metrics; +mod simulation; mod types; mod ui; @@ -38,11 +40,11 @@ fn run_json_mode() -> Result<(), io::Error> { let use_fake_data = std::env::var("IBTOP_FAKE_DATA").is_ok(); let adapters = if use_fake_data { - discovery::fake::generate_fake_adapters() + simulation::generate_fake_adapters() } else { let real_adapters = discovery::discover_adapters(); if real_adapters.is_empty() && std::env::var("IBTOP_DEMO").is_ok() { - discovery::fake::generate_fake_adapters() + simulation::generate_fake_adapters() } else { real_adapters } @@ -85,6 +87,7 @@ fn run_interactive_mode() -> Result<(), io::Error> { fn run_app(terminal: &mut Terminal) -> io::Result<()> { let use_fake_data = std::env::var("IBTOP_FAKE_DATA").is_ok(); let mut metrics = metrics::MetricsCollector::new(); + let mut app_state = ui::AppState::new(); let hostname = get_hostname(); let ui_refresh_duration = Duration::from_millis(UI_REFRESH_INTERVAL_MS); @@ -98,11 +101,11 @@ fn run_app(terminal: &mut Terminal) -> io::Resu if now.duration_since(last_metrics_update) >= metrics_update_interval { adapters = if use_fake_data { - discovery::fake::generate_fake_adapters() + simulation::generate_fake_adapters() } else { let real_adapters = discovery::discover_adapters(); if real_adapters.is_empty() && std::env::var("IBTOP_DEMO").is_ok() { - discovery::fake::generate_fake_adapters() + simulation::generate_fake_adapters() } else { real_adapters } @@ -112,21 +115,34 @@ fn run_app(terminal: &mut Terminal) -> io::Resu last_metrics_update = now; } - terminal.draw(|f| ui::draw(f, &adapters, &metrics, &hostname))?; + terminal.draw(|f| ui::draw(f, &adapters, &metrics, &hostname, &mut app_state))?; let timeout = ui_refresh_duration.saturating_sub(now.elapsed()); if event::poll(timeout)? { if let Event::Key(key) = event::read()? { match key.code { + // Quit KeyCode::Char('q') | KeyCode::Esc => return Ok(()), KeyCode::Char('c') if key.modifiers.contains(KeyModifiers::CONTROL) => { return Ok(()) } + + // Navigation + KeyCode::Char('j') | KeyCode::Down => app_state.select_next(), + KeyCode::Char('k') | KeyCode::Up => app_state.select_prev(), + + // Detail view + KeyCode::Enter => app_state.toggle_detail(), + KeyCode::Tab if app_state.detail_expanded => app_state.next_tab(), + KeyCode::BackTab if app_state.detail_expanded => app_state.prev_tab(), + + // Force refresh KeyCode::Char('r') => { last_metrics_update = Instant::now() .checked_sub(metrics_update_interval) .unwrap_or_else(Instant::now); } + _ => {} } } diff --git a/src/metrics.rs b/src/metrics.rs index ac6eaea..149f0c4 100644 --- a/src/metrics.rs +++ b/src/metrics.rs @@ -1,12 +1,16 @@ use std::collections::HashMap; use std::time::{Duration, Instant}; +use crate::history::HistoryCollector; use crate::types::{AdapterInfo, PortCounters}; #[derive(Debug, Clone)] pub struct PortMetrics { pub rx_bytes_per_sec: f64, pub tx_bytes_per_sec: f64, + pub rx_packets_per_sec: f64, + pub tx_packets_per_sec: f64, + pub error_rate: f64, } impl Default for PortMetrics { @@ -14,6 +18,9 @@ impl Default for PortMetrics { Self { rx_bytes_per_sec: 0.0, tx_bytes_per_sec: 0.0, + rx_packets_per_sec: 0.0, + tx_packets_per_sec: 0.0, + error_rate: 0.0, } } } @@ -23,6 +30,7 @@ pub struct MetricsCollector { previous_counters: HashMap, current_metrics: HashMap, last_collection: Option, + pub history: HistoryCollector, } impl MetricsCollector { @@ -31,6 +39,7 @@ impl MetricsCollector { previous_counters: HashMap::new(), current_metrics: HashMap::new(), last_collection: None, + history: HistoryCollector::new(), } } @@ -42,14 +51,28 @@ impl MetricsCollector { // Track current port keys to clean up stale entries let mut current_port_keys = std::collections::HashSet::new(); + let mut active_ports = Vec::new(); for adapter in adapters { for port in &adapter.ports { let port_key = format!("{}:{}", adapter.name, port.port_number); current_port_keys.insert(port_key.clone()); + active_ports.push((adapter.name.clone(), port.port_number)); if let Some(prev_counters) = self.previous_counters.get(&port_key) { let metrics = Self::calculate_rates(prev_counters, &port.counters, time_delta); + + // Record to history + self.history.record( + &adapter.name, + port.port_number, + metrics.rx_bytes_per_sec, + metrics.tx_bytes_per_sec, + metrics.rx_packets_per_sec, + metrics.tx_packets_per_sec, + metrics.error_rate, + ); + self.current_metrics.insert(port_key.clone(), metrics); } @@ -64,6 +87,7 @@ impl MetricsCollector { .retain(|key, _| current_port_keys.contains(key)); self.current_metrics .retain(|key, _| current_port_keys.contains(key)); + self.history.retain_ports(&active_ports); self.last_collection = Some(now); } @@ -82,10 +106,19 @@ impl MetricsCollector { let rx_bytes_delta = current.rx_bytes.saturating_sub(prev.rx_bytes); let tx_bytes_delta = current.tx_bytes.saturating_sub(prev.tx_bytes); + let rx_packets_delta = current.rx_packets.saturating_sub(prev.rx_packets); + let tx_packets_delta = current.tx_packets.saturating_sub(prev.tx_packets); + + let prev_errors = prev.rx_errors + prev.tx_errors; + let current_errors = current.rx_errors + current.tx_errors; + let error_delta = current_errors.saturating_sub(prev_errors); PortMetrics { rx_bytes_per_sec: rx_bytes_delta as f64 / delta_seconds, tx_bytes_per_sec: tx_bytes_delta as f64 / delta_seconds, + rx_packets_per_sec: rx_packets_delta as f64 / delta_seconds, + tx_packets_per_sec: tx_packets_delta as f64 / delta_seconds, + error_rate: error_delta as f64 / delta_seconds, } } @@ -93,4 +126,13 @@ impl MetricsCollector { let port_key = format!("{adapter_name}:{port_number}"); self.current_metrics.get(&port_key) } + + /// Get historical data for a port + pub fn get_history( + &self, + adapter_name: &str, + port_number: u16, + ) -> Option<&crate::history::PortHistory> { + self.history.get(adapter_name, port_number) + } } diff --git a/src/simulation.rs b/src/simulation.rs new file mode 100644 index 0000000..a8de1ad --- /dev/null +++ b/src/simulation.rs @@ -0,0 +1,406 @@ +//! Sophisticated traffic simulation for demo and testing +//! +//! Generates realistic `InfiniBand` traffic patterns including: +//! - Burst patterns (MPI collective operations) +//! - Steady streaming (RDMA transfers) +//! - Wave patterns (periodic workloads) +//! - Idle with occasional spikes (interactive) +//! - Congestion patterns (network contention) + +#![allow(dead_code)] // TrafficPattern methods are for extensibility +#![allow(clippy::similar_names)] // rx/tx pairs are intentionally similar +#![allow(clippy::cast_precision_loss)] // Acceptable for metrics + +use crate::types::{AdapterInfo, PortCounters, PortInfo, PortState}; +use std::f64::consts::PI; +use std::sync::atomic::{AtomicU64, Ordering}; +use std::time::Instant; + +/// Traffic pattern types for simulation +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum TrafficPattern { + /// MPI collective operations - periodic bursts with gaps + Burst, + /// Steady RDMA transfers - consistent high throughput + Steady, + /// Periodic workload - sine wave pattern + Wave, + /// Interactive/idle - low baseline with random spikes + Interactive, + /// Network congestion - high with periodic drops + Congestion, +} + +impl TrafficPattern { + /// Returns all available patterns for cycling + pub const fn all() -> &'static [TrafficPattern] { + &[ + TrafficPattern::Burst, + TrafficPattern::Steady, + TrafficPattern::Wave, + TrafficPattern::Interactive, + TrafficPattern::Congestion, + ] + } + + /// Human-readable name for the pattern + pub const fn name(self) -> &'static str { + match self { + TrafficPattern::Burst => "MPI Collective", + TrafficPattern::Steady => "RDMA Stream", + TrafficPattern::Wave => "Periodic Load", + TrafficPattern::Interactive => "Interactive", + TrafficPattern::Congestion => "Congested", + } + } +} + +/// Simulated port configuration +struct SimulatedPort { + adapter_name: &'static str, + port_number: u16, + state: PortState, + rate: &'static str, + pattern: TrafficPattern, + /// Base throughput in bytes/sec (for 100% utilization reference) + max_throughput: u64, + /// RX/TX ratio (0.5 = balanced, >0.5 = more RX) + rx_tx_ratio: f64, +} + +/// Configuration for simulation +const SIMULATED_PORTS: &[SimulatedPort] = &[ + SimulatedPort { + adapter_name: "mlx5_0", + port_number: 1, + state: PortState::Active, + rate: "100 Gb/sec (4X EDR)", + pattern: TrafficPattern::Burst, + max_throughput: 12_500_000_000, // 100 Gbps = 12.5 GB/s + rx_tx_ratio: 0.55, + }, + SimulatedPort { + adapter_name: "mlx5_0", + port_number: 2, + state: PortState::Down, + rate: "100 Gb/sec (4X EDR)", + pattern: TrafficPattern::Steady, + max_throughput: 12_500_000_000, + rx_tx_ratio: 0.5, + }, + SimulatedPort { + adapter_name: "mlx5_1", + port_number: 1, + state: PortState::Active, + rate: "200 Gb/sec (4X HDR)", + pattern: TrafficPattern::Steady, + max_throughput: 25_000_000_000, // 200 Gbps = 25 GB/s + rx_tx_ratio: 0.48, + }, + SimulatedPort { + adapter_name: "mlx5_2", + port_number: 1, + state: PortState::Active, + rate: "400 Gb/sec (4X NDR)", + pattern: TrafficPattern::Wave, + max_throughput: 50_000_000_000, // 400 Gbps = 50 GB/s + rx_tx_ratio: 0.52, + }, + SimulatedPort { + adapter_name: "mlx5_bond0", + port_number: 1, + state: PortState::Active, + rate: "200 Gb/sec (Bonded)", + pattern: TrafficPattern::Interactive, + max_throughput: 25_000_000_000, + rx_tx_ratio: 0.7, // More RX (receiving results) + }, + SimulatedPort { + adapter_name: "mlx5_bond0", + port_number: 2, + state: PortState::Active, + rate: "200 Gb/sec (Bonded)", + pattern: TrafficPattern::Congestion, + max_throughput: 25_000_000_000, + rx_tx_ratio: 0.3, // More TX (sending data) + }, +]; + +/// Global simulation state +static SIM_START: AtomicU64 = AtomicU64::new(0); +static CALL_COUNT: AtomicU64 = AtomicU64::new(0); + +// Cumulative counters for each port (indexed by port config index) +static COUNTERS: [PortCounterState; 6] = [ + PortCounterState::new(), + PortCounterState::new(), + PortCounterState::new(), + PortCounterState::new(), + PortCounterState::new(), + PortCounterState::new(), +]; + +struct PortCounterState { + rx_bytes: AtomicU64, + tx_bytes: AtomicU64, + rx_packets: AtomicU64, + tx_packets: AtomicU64, + rx_errors: AtomicU64, + tx_errors: AtomicU64, + rx_dropped: AtomicU64, +} + +impl PortCounterState { + const fn new() -> Self { + Self { + rx_bytes: AtomicU64::new(0), + tx_bytes: AtomicU64::new(0), + rx_packets: AtomicU64::new(0), + tx_packets: AtomicU64::new(0), + rx_errors: AtomicU64::new(0), + tx_errors: AtomicU64::new(0), + rx_dropped: AtomicU64::new(0), + } + } +} + +/// Initialize simulation with starting timestamp +#[allow(clippy::cast_possible_truncation)] +fn ensure_initialized() -> f64 { + let now_nanos = Instant::now().elapsed().as_nanos() as u64; + let _ = SIM_START.compare_exchange(0, now_nanos.max(1), Ordering::SeqCst, Ordering::SeqCst); + + let count = CALL_COUNT.fetch_add(1, Ordering::Relaxed); + // Use call count as time proxy (each call is ~250ms in real app) + count as f64 * 0.25 +} + +/// Calculate traffic multiplier based on pattern and time +fn calculate_utilization(pattern: TrafficPattern, time_secs: f64) -> f64 { + match pattern { + TrafficPattern::Burst => { + // MPI collective pattern: high bursts with gaps + // 2 second cycle: 0.5s burst at 90%, 1.5s at 10% + let cycle_pos = time_secs % 2.0; + if cycle_pos < 0.5 { + 0.85 + random_noise() * 0.1 + } else { + 0.05 + random_noise() * 0.1 + } + } + TrafficPattern::Steady => { + // Consistent high throughput with minor variations + 0.75 + random_noise() * 0.15 + } + TrafficPattern::Wave => { + // Sine wave pattern with 10 second period + let base = 0.5 + 0.4 * (time_secs * 2.0 * PI / 10.0).sin(); + base + random_noise() * 0.1 + } + TrafficPattern::Interactive => { + // Low baseline with occasional spikes + let spike = if random_noise() > 0.92 { 0.7 } else { 0.0 }; + 0.05 + random_noise() * 0.08 + spike + } + TrafficPattern::Congestion => { + // High utilization with periodic drops (packet loss) + let drop = if random_noise() > 0.85 { -0.3 } else { 0.0 }; + (0.9 + random_noise() * 0.1 + drop).max(0.3) + } + } +} + +/// Generate random noise in [0, 1) +fn random_noise() -> f64 { + rand::random::() +} + +/// Average packet size based on pattern (affects packet/byte ratio) +fn avg_packet_size(pattern: TrafficPattern) -> u64 { + match pattern { + TrafficPattern::Burst => 4096, // Large MPI messages + TrafficPattern::Steady => 65536, // Max MTU RDMA + TrafficPattern::Wave => 8192, // Mixed workload + TrafficPattern::Interactive => 512, // Small messages + TrafficPattern::Congestion => 32768, // Large but congested + } +} + +/// Calculate error rate based on pattern +fn error_probability(pattern: TrafficPattern) -> f64 { + match pattern { + TrafficPattern::Burst | TrafficPattern::Wave => 0.0001, + TrafficPattern::Steady => 0.00005, + TrafficPattern::Interactive => 0.0002, + TrafficPattern::Congestion => 0.002, // Higher errors due to congestion + } +} + +/// Generate fake adapters with sophisticated traffic simulation +pub fn generate_fake_adapters() -> Vec { + let time_secs = ensure_initialized(); + + // Group ports by adapter + let mut adapter_map: std::collections::HashMap<&str, Vec> = + std::collections::HashMap::new(); + + for (idx, port_config) in SIMULATED_PORTS.iter().enumerate() { + let counters = if port_config.state == PortState::Down { + PortCounters::default() + } else { + generate_counters(idx, port_config, time_secs) + }; + + let port_info = PortInfo { + port_number: port_config.port_number, + state: port_config.state, + rate: port_config.rate.to_string(), + counters, + }; + + adapter_map + .entry(port_config.adapter_name) + .or_default() + .push(port_info); + } + + // Convert to sorted vector of adapters + let mut adapters: Vec = adapter_map + .into_iter() + .map(|(name, ports)| AdapterInfo { + name: name.to_string(), + ports, + }) + .collect(); + + adapters.sort_by(|a, b| a.name.cmp(&b.name)); + adapters +} + +#[allow(clippy::cast_possible_truncation)] +#[allow(clippy::cast_sign_loss)] +fn generate_counters(idx: usize, config: &SimulatedPort, time_secs: f64) -> PortCounters { + let utilization = calculate_utilization(config.pattern, time_secs); + + // Calculate bytes transferred in this interval (~250ms) + let interval_secs = 0.25; + let total_bytes = (config.max_throughput as f64 * utilization * interval_secs) as u64; + + let rx_bytes = (total_bytes as f64 * config.rx_tx_ratio) as u64; + let tx_bytes = total_bytes - rx_bytes; + + let packet_size = avg_packet_size(config.pattern); + let rx_packets = rx_bytes / packet_size; + let tx_packets = tx_bytes / packet_size; + + // Error generation + let error_prob = error_probability(config.pattern); + let rx_errors = if random_noise() < error_prob { + (random_noise() * 3.0) as u64 + } else { + 0 + }; + let tx_errors = if random_noise() < error_prob { + (random_noise() * 2.0) as u64 + } else { + 0 + }; + let rx_dropped = if config.pattern == TrafficPattern::Congestion && random_noise() < 0.01 { + (random_noise() * 5.0) as u64 + } else { + 0 + }; + + // Update cumulative counters + let counter = &COUNTERS[idx]; + let total_rx = counter.rx_bytes.fetch_add(rx_bytes, Ordering::Relaxed) + rx_bytes; + let total_tx = counter.tx_bytes.fetch_add(tx_bytes, Ordering::Relaxed) + tx_bytes; + let total_rx_pkt = counter.rx_packets.fetch_add(rx_packets, Ordering::Relaxed) + rx_packets; + let total_tx_pkt = counter.tx_packets.fetch_add(tx_packets, Ordering::Relaxed) + tx_packets; + let total_rx_err = counter.rx_errors.fetch_add(rx_errors, Ordering::Relaxed) + rx_errors; + let total_tx_err = counter.tx_errors.fetch_add(tx_errors, Ordering::Relaxed) + tx_errors; + let total_dropped = counter.rx_dropped.fetch_add(rx_dropped, Ordering::Relaxed) + rx_dropped; + + PortCounters { + rx_bytes: total_rx, + tx_bytes: total_tx, + rx_packets: total_rx_pkt, + tx_packets: total_tx_pkt, + rx_errors: total_rx_err, + tx_errors: total_tx_err, + rx_dropped: total_dropped, + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_traffic_patterns_all() { + let patterns = TrafficPattern::all(); + assert_eq!(patterns.len(), 5); + } + + #[test] + fn test_pattern_names() { + assert_eq!(TrafficPattern::Burst.name(), "MPI Collective"); + assert_eq!(TrafficPattern::Steady.name(), "RDMA Stream"); + assert_eq!(TrafficPattern::Wave.name(), "Periodic Load"); + assert_eq!(TrafficPattern::Interactive.name(), "Interactive"); + assert_eq!(TrafficPattern::Congestion.name(), "Congested"); + } + + #[test] + fn test_utilization_bounds() { + for pattern in TrafficPattern::all() { + for t in 0..100 { + let util = calculate_utilization(*pattern, t as f64 * 0.1); + assert!(util >= 0.0 && util <= 1.0, "Pattern {:?} at t={}: util={}", pattern, t, util); + } + } + } + + #[test] + fn test_generate_fake_adapters() { + let adapters = generate_fake_adapters(); + assert!(!adapters.is_empty()); + + // Should have multiple adapters + assert!(adapters.len() >= 2); + + // Check that active ports have non-zero counters after a few calls + generate_fake_adapters(); + generate_fake_adapters(); + let adapters = generate_fake_adapters(); + + for adapter in &adapters { + for port in &adapter.ports { + if port.state == PortState::Active { + assert!(port.counters.rx_bytes > 0 || port.counters.tx_bytes > 0); + } + } + } + } + + #[test] + fn test_down_port_has_zero_counters() { + // Find a down port + let adapters = generate_fake_adapters(); + for adapter in adapters { + for port in adapter.ports { + if port.state == PortState::Down { + assert_eq!(port.counters.rx_bytes, 0); + assert_eq!(port.counters.tx_bytes, 0); + } + } + } + } + + #[test] + fn test_avg_packet_sizes() { + // Verify packet sizes are reasonable + assert!(avg_packet_size(TrafficPattern::Interactive) < avg_packet_size(TrafficPattern::Steady)); + assert!(avg_packet_size(TrafficPattern::Burst) < avg_packet_size(TrafficPattern::Steady)); + } +} diff --git a/src/ui.rs b/src/ui.rs index 785ed43..93d915c 100644 --- a/src/ui.rs +++ b/src/ui.rs @@ -1,159 +1,651 @@ +//! Enhanced TUI with real-time charts and sparklines +//! +//! Provides a visually stunning interface with: +//! - Sparklines in the main table view +//! - Expandable detail view with full throughput charts +//! - Color-coded status indicators +//! - Smooth visual transitions + +#![allow(clippy::cast_precision_loss)] // Acceptable for chart coordinates +#![allow(clippy::cast_possible_truncation)] // Acceptable for UI values +#![allow(clippy::cast_sign_loss)] // Values are always positive +#![allow(clippy::similar_names)] // rx/tx pairs are intentionally similar + use ratatui::{ layout::{Constraint, Direction, Layout, Rect}, style::{Color, Modifier, Style}, - widgets::{Block, Borders, Cell, Paragraph, Row, Table}, + symbols, + text::{Line, Span}, + widgets::{Axis, Block, Borders, Cell, Chart, Dataset, GraphType, Paragraph, Row, Table, Tabs}, Frame, }; +use crate::history::PortHistory; use crate::metrics::MetricsCollector; -use crate::types::AdapterInfo; +use crate::types::{AdapterInfo, PortState}; + +/// Number of sparkline samples to show in the main table +const SPARKLINE_SAMPLES: usize = 20; + +/// Application state for the UI +#[derive(Debug, Default)] +pub struct AppState { + /// Currently selected row index (for navigation) + pub selected_row: usize, + /// Whether detail view is expanded + pub detail_expanded: bool, + /// Currently selected tab in detail view + pub detail_tab: usize, + /// Scroll offset for the main table (for future scrolling support) + #[allow(dead_code)] + pub scroll_offset: usize, + /// Animation frame counter + pub frame_count: u64, + /// List of selectable items (adapter, port) or None for adapter headers + selectable_items: Vec>, +} + +impl AppState { + pub fn new() -> Self { + Self::default() + } + + /// Move selection up + pub fn select_prev(&mut self) { + if self.selected_row > 0 { + self.selected_row -= 1; + // Skip adapter header rows + while self.selected_row > 0 && self.is_header_row(self.selected_row) { + self.selected_row -= 1; + } + } + } + /// Move selection down + pub fn select_next(&mut self) { + if self.selected_row + 1 < self.selectable_items.len() { + self.selected_row += 1; + // Skip adapter header rows + while self.selected_row + 1 < self.selectable_items.len() + && self.is_header_row(self.selected_row) + { + self.selected_row += 1; + } + } + } + + /// Check if a row is a header (not selectable) + fn is_header_row(&self, row: usize) -> bool { + match self.selectable_items.get(row) { + None | Some(None) => true, + Some(Some(_)) => false, + } + } + + /// Toggle detail view + pub fn toggle_detail(&mut self) { + self.detail_expanded = !self.detail_expanded; + } + + /// Get currently selected port + pub fn selected_port(&self) -> Option<(&str, u16)> { + self.selectable_items + .get(self.selected_row)? + .as_ref() + .map(|(a, p)| (a.as_str(), *p)) + } + + /// Cycle detail tab + pub fn next_tab(&mut self) { + self.detail_tab = (self.detail_tab + 1) % 3; + } + + /// Cycle detail tab backward + pub fn prev_tab(&mut self) { + self.detail_tab = if self.detail_tab == 0 { 2 } else { self.detail_tab - 1 }; + } + + fn update_selectable_items(&mut self, adapters: &[AdapterInfo]) { + self.selectable_items.clear(); + for adapter in adapters { + self.selectable_items.push(None); // Adapter header + for port in &adapter.ports { + self.selectable_items.push(Some((adapter.name.clone(), port.port_number))); + } + } + // Ensure selection is valid + if self.selected_row >= self.selectable_items.len() { + self.selected_row = self.selectable_items.len().saturating_sub(1); + } + // Skip adapter headers + while self.selected_row < self.selectable_items.len() + && self.is_header_row(self.selected_row) + { + if self.selected_row + 1 < self.selectable_items.len() { + self.selected_row += 1; + } else { + break; + } + } + } +} + +/// Main draw function pub fn draw( frame: &mut Frame, adapters: &[AdapterInfo], metrics: &MetricsCollector, hostname: &str, + state: &mut AppState, ) { - let chunks = Layout::default() + state.frame_count += 1; + state.update_selectable_items(adapters); + + let main_layout = Layout::default() .direction(Direction::Vertical) - .constraints([ - Constraint::Length(1), - Constraint::Min(0), - Constraint::Length(1), - ]) - .split(frame.size()); + .constraints(if state.detail_expanded { + vec![ + Constraint::Percentage(50), + Constraint::Percentage(50), + ] + } else { + vec![Constraint::Min(0)] + }) + .split(frame.area()); - draw_adapters(frame, chunks[1], adapters, metrics, hostname); - draw_help_footer(frame, chunks[2]); -} + // Draw main table (always visible) + draw_main_table(frame, main_layout[0], adapters, metrics, hostname, state); -fn draw_help_footer(frame: &mut Frame, area: Rect) { - let help_text = - Paragraph::new("Controls: q=quit, Ctrl+C=quit").style(Style::default().fg(Color::DarkGray)); - frame.render_widget(help_text, area); + // Draw detail panel if expanded + if state.detail_expanded && main_layout.len() > 1 { + draw_detail_panel(frame, main_layout[1], adapters, metrics, state); + } } + +/// Draw the main table with sparklines #[allow(clippy::too_many_lines)] -fn draw_adapters( +fn draw_main_table( frame: &mut Frame, area: Rect, adapters: &[AdapterInfo], metrics: &MetricsCollector, hostname: &str, + state: &AppState, ) { + let chunks = Layout::default() + .direction(Direction::Vertical) + .constraints([Constraint::Min(0), Constraint::Length(2)]) + .split(area); + let mut rows: Vec = Vec::new(); + let mut row_idx = 0; if adapters.is_empty() { rows.push(Row::new(vec![ - Cell::from("No InfiniBand adapters found").style(Style::default().fg(Color::Yellow)) + Cell::from("").style(Style::default()), + Cell::from("No InfiniBand adapters found").style(Style::default().fg(Color::Yellow)), + Cell::from(""), + Cell::from(""), + Cell::from(""), + Cell::from(""), + Cell::from(""), + Cell::from(""), ])); } else { for adapter in adapters { - // Add adapter header row that spans the full width - rows.push(Row::new(vec![ - Cell::from("Adapter:"), - Cell::from(adapter.name.clone()).style( - Style::default() - .fg(Color::Green) - .add_modifier(Modifier::BOLD), - ), - Cell::from(""), - Cell::from(""), - Cell::from(""), - Cell::from(""), - Cell::from(""), - ])); + // Adapter header row with visual separator + let is_header_selected = state.selected_row == row_idx; + let header_style = if is_header_selected { + Style::default().fg(Color::Cyan).add_modifier(Modifier::BOLD) + } else { + Style::default().fg(Color::Green).add_modifier(Modifier::BOLD) + }; + + rows.push( + Row::new(vec![ + Cell::from(""), + Cell::from(format!(" {} ", adapter.name)).style(header_style), + Cell::from(""), + Cell::from(""), + Cell::from(""), + Cell::from(""), + Cell::from(""), + Cell::from(""), + ]) + .height(1), + ); + row_idx += 1; for port in &adapter.ports { - let state_color = match port.state { - crate::types::PortState::Active => Color::Green, - crate::types::PortState::Down => Color::Red, - crate::types::PortState::Unknown => Color::Yellow, + let is_selected = state.selected_row == row_idx; + let port_metrics = metrics.get_metrics(&adapter.name, port.port_number); + let history = metrics.get_history(&adapter.name, port.port_number); + + // State indicator with pulsing effect for active ports + let (state_str, state_color) = match port.state { + PortState::Active => { + // Subtle pulse: alternates between bright and dim dot + let pulse = if state.frame_count % 60 < 30 { "●" } else { "○" }; + (format!("{pulse}ACTIVE"), Color::Green) + } + PortState::Down => ("○DOWN".to_string(), Color::Red), + PortState::Unknown => ("?UNKN".to_string(), Color::Yellow), }; - let port_metrics = metrics.get_metrics(&adapter.name, port.port_number); - let (rx_rate, tx_rate) = if let Some(metrics) = port_metrics { + // Get throughput values + let (rx_rate, tx_rate) = if let Some(m) = port_metrics { ( - format_bytes_per_sec(metrics.rx_bytes_per_sec), - format_bytes_per_sec(metrics.tx_bytes_per_sec), + format_bytes_per_sec(m.rx_bytes_per_sec), + format_bytes_per_sec(m.tx_bytes_per_sec), ) } else { ("--".to_string(), "--".to_string()) }; - rows.push(Row::new(vec![ - Cell::from(format!("{}", port.port_number)) - .style(Style::default().fg(Color::Cyan)), - Cell::from(port.state.to_string()).style(Style::default().fg(state_color)), - Cell::from(port.rate.clone()).style(Style::default().fg(Color::White)), - Cell::from(format_bytes(port.counters.rx_bytes)) - .style(Style::default().fg(Color::Blue)), - Cell::from(format_bytes(port.counters.tx_bytes)) - .style(Style::default().fg(Color::Blue)), - Cell::from(rx_rate).style(Style::default().fg(Color::Magenta)), - Cell::from(tx_rate).style(Style::default().fg(Color::Magenta)), - ])); + // Sparkline data + let sparkline_str = if let Some(h) = history { + render_inline_sparkline(&h.combined_sparkline_data(SPARKLINE_SAMPLES)) + } else { + " ".repeat(SPARKLINE_SAMPLES) + }; + + // Throughput bar (visual indicator of utilization) + let utilization = if let Some(m) = port_metrics { + let max_rate = parse_max_rate(&port.rate); + let current_rate = m.rx_bytes_per_sec + m.tx_bytes_per_sec; + (current_rate / max_rate * 100.0).min(100.0) + } else { + 0.0 + }; + let bar = render_utilization_bar(utilization, 8); + + let row_style = if is_selected { + Style::default().bg(Color::DarkGray) + } else { + Style::default() + }; + + rows.push( + Row::new(vec![ + Cell::from(format!(" {}", port.port_number)) + .style(Style::default().fg(Color::Cyan)), + Cell::from(state_str).style(Style::default().fg(state_color)), + Cell::from(truncate_rate(&port.rate)) + .style(Style::default().fg(Color::White).add_modifier(Modifier::DIM)), + Cell::from(bar), + Cell::from(rx_rate).style(Style::default().fg(Color::Blue)), + Cell::from(tx_rate).style(Style::default().fg(Color::Magenta)), + Cell::from(sparkline_str).style(Style::default().fg(Color::Cyan)), + Cell::from(if is_selected { "◀" } else { " " }) + .style(Style::default().fg(Color::Cyan)), + ]) + .style(row_style) + .height(1), + ); + row_idx += 1; } } } let widths = [ - Constraint::Length(8), // Port - Constraint::Length(10), // State - Constraint::Length(12), // Rate - Constraint::Length(12), // RX Data - Constraint::Length(12), // TX Data - Constraint::Length(12), // RX Rate - Constraint::Length(12), // TX Rate + Constraint::Length(4), // Port + Constraint::Length(8), // State + Constraint::Length(12), // Link Rate + Constraint::Length(10), // Utilization bar + Constraint::Length(10), // RX Rate + Constraint::Length(10), // TX Rate + Constraint::Length(SPARKLINE_SAMPLES as u16 + 2), // Sparkline + Constraint::Length(2), // Selection indicator ]; + let header_style = Style::default() + .fg(Color::White) + .add_modifier(Modifier::BOLD); + let table = Table::new(rows, widths) - .header(Row::new(vec![ - Cell::from("Port").style( - Style::default() - .fg(Color::DarkGray) - .add_modifier(Modifier::UNDERLINED), - ), - Cell::from("State").style( - Style::default() - .fg(Color::DarkGray) - .add_modifier(Modifier::UNDERLINED), - ), - Cell::from("Rate").style( - Style::default() - .fg(Color::DarkGray) - .add_modifier(Modifier::UNDERLINED), - ), - Cell::from("RX Data").style( - Style::default() - .fg(Color::DarkGray) - .add_modifier(Modifier::UNDERLINED), - ), - Cell::from("TX Data").style( - Style::default() - .fg(Color::DarkGray) - .add_modifier(Modifier::UNDERLINED), - ), - Cell::from("RX Rate").style( - Style::default() - .fg(Color::DarkGray) - .add_modifier(Modifier::UNDERLINED), - ), - Cell::from("TX Rate").style( - Style::default() - .fg(Color::DarkGray) - .add_modifier(Modifier::UNDERLINED), - ), - ])) + .header( + Row::new(vec![ + Cell::from("Port").style(header_style), + Cell::from("State").style(header_style), + Cell::from("Link").style(header_style), + Cell::from("Load").style(header_style), + Cell::from("RX").style(header_style), + Cell::from("TX").style(header_style), + Cell::from("History").style(header_style), + Cell::from("").style(header_style), + ]) + .height(1) + .bottom_margin(0), + ) .block( Block::default() .borders(Borders::ALL) - .title(format!("ibtop - InfiniBand Monitor @ {hostname}")), + .border_style(Style::default().fg(Color::DarkGray)) + .title(Line::from(vec![ + Span::styled(" ibtop ", Style::default().fg(Color::Cyan).add_modifier(Modifier::BOLD)), + Span::styled("@ ", Style::default().fg(Color::DarkGray)), + Span::styled(hostname, Style::default().fg(Color::White)), + Span::styled(" ", Style::default()), + ])) + .title_style(Style::default()), + ); + + frame.render_widget(table, chunks[0]); + + // Help footer - context-sensitive + let help_spans = if state.detail_expanded { + vec![ + Span::styled(" ", Style::default().fg(Color::DarkGray)), + Span::styled("Tab", Style::default().fg(Color::Cyan)), + Span::styled(" switch tab ", Style::default().fg(Color::DarkGray)), + Span::styled("Enter", Style::default().fg(Color::Cyan)), + Span::styled(" close ", Style::default().fg(Color::DarkGray)), + Span::styled("j/k", Style::default().fg(Color::Cyan)), + Span::styled(" select port ", Style::default().fg(Color::DarkGray)), + Span::styled("q", Style::default().fg(Color::Cyan)), + Span::styled(" quit ", Style::default().fg(Color::DarkGray)), + ] + } else { + vec![ + Span::styled(" ", Style::default().fg(Color::DarkGray)), + Span::styled("j/k", Style::default().fg(Color::Cyan)), + Span::styled(" navigate ", Style::default().fg(Color::DarkGray)), + Span::styled("Enter", Style::default().fg(Color::Cyan)), + Span::styled(" details ", Style::default().fg(Color::DarkGray)), + Span::styled("q", Style::default().fg(Color::Cyan)), + Span::styled(" quit ", Style::default().fg(Color::DarkGray)), + ] + }; + + let help = Paragraph::new(Line::from(help_spans)); + frame.render_widget(help, chunks[1]); +} + +/// Draw the detail panel with charts +fn draw_detail_panel( + frame: &mut Frame, + area: Rect, + adapters: &[AdapterInfo], + metrics: &MetricsCollector, + state: &AppState, +) { + let block = Block::default() + .borders(Borders::ALL) + .border_style(Style::default().fg(Color::DarkGray)) + .title(Line::from(vec![ + Span::styled(" Detail View ", Style::default().fg(Color::Yellow).add_modifier(Modifier::BOLD)), + ])); + + // Get selected port info + let selected = state.selected_port(); + if selected.is_none() { + let msg = Paragraph::new("Select a port to view details") + .style(Style::default().fg(Color::DarkGray)) + .block(block); + frame.render_widget(msg, area); + return; + } + + let (adapter_name, port_num) = selected.unwrap(); + let port_info = adapters + .iter() + .find(|a| a.name == adapter_name) + .and_then(|a| a.ports.iter().find(|p| p.port_number == port_num)); + + let history = metrics.get_history(adapter_name, port_num); + let current_metrics = metrics.get_metrics(adapter_name, port_num); + + let inner = block.inner(area); + frame.render_widget(block, area); + + // Layout for detail panel + let detail_layout = Layout::default() + .direction(Direction::Vertical) + .constraints([ + Constraint::Length(2), // Tab bar + Constraint::Length(3), // Stats summary + Constraint::Min(0), // Chart area + ]) + .split(inner); + + // Tab bar + let tabs = Tabs::new(vec!["Throughput", "Packets", "Errors"]) + .select(state.detail_tab) + .style(Style::default().fg(Color::DarkGray)) + .highlight_style(Style::default().fg(Color::Cyan).add_modifier(Modifier::BOLD)) + .divider(Span::raw(" | ")); + + frame.render_widget(tabs, detail_layout[0]); + + // Stats summary + if let (Some(port), Some(m)) = (port_info, current_metrics) { + let stats_line = Line::from(vec![ + Span::styled(format!("{adapter_name}:"), Style::default().fg(Color::Green)), + Span::styled(format!("{port_num} ", port_num = port.port_number), Style::default().fg(Color::Cyan)), + Span::styled(format!("{} ", port.state), Style::default().fg(match port.state { + PortState::Active => Color::Green, + PortState::Down => Color::Red, + PortState::Unknown => Color::Yellow, + })), + Span::styled("| ", Style::default().fg(Color::DarkGray)), + Span::styled("RX: ", Style::default().fg(Color::DarkGray)), + Span::styled(format_bytes_per_sec(m.rx_bytes_per_sec), Style::default().fg(Color::Blue)), + Span::styled(" TX: ", Style::default().fg(Color::DarkGray)), + Span::styled(format_bytes_per_sec(m.tx_bytes_per_sec), Style::default().fg(Color::Magenta)), + ]); + + let stats_para = Paragraph::new(stats_line); + frame.render_widget(stats_para, detail_layout[1]); + } + + // Chart area + if let Some(h) = history { + draw_chart(frame, detail_layout[2], h, state.detail_tab); + } else { + let msg = Paragraph::new("Collecting data...") + .style(Style::default().fg(Color::DarkGray)); + frame.render_widget(msg, detail_layout[2]); + } +} + +/// Auto-scale throughput value and return scaled value with unit +fn auto_scale_throughput(bytes_per_sec: f64) -> (f64, &'static str) { + if bytes_per_sec >= 1_000_000_000.0 { + (bytes_per_sec / 1_000_000_000.0, "GB/s") + } else if bytes_per_sec >= 1_000_000.0 { + (bytes_per_sec / 1_000_000.0, "MB/s") + } else if bytes_per_sec >= 1_000.0 { + (bytes_per_sec / 1_000.0, "KB/s") + } else { + (bytes_per_sec, "B/s") + } +} + +/// Draw a chart based on the selected tab +#[allow(clippy::too_many_lines)] +fn draw_chart(frame: &mut Frame, area: Rect, history: &PortHistory, tab: usize) { + // First, find the max value to determine scale + let (rx_raw, tx_raw): (Vec, Vec) = match tab { + 0 => ( + history.rx_bytes_per_sec.iter().copied().collect(), + history.tx_bytes_per_sec.iter().copied().collect(), + ), + 1 => ( + history.rx_packets_per_sec.iter().copied().collect(), + history.tx_packets_per_sec.iter().copied().collect(), + ), + _ => { + let errors: Vec = history.error_rate.iter().copied().collect(); + (errors.clone(), errors) + } + }; + + if rx_raw.is_empty() { + return; + } + + let max_raw = rx_raw + .iter() + .chain(tx_raw.iter()) + .copied() + .fold(0.0_f64, f64::max) + .max(0.001); // Avoid division by zero + + // Determine scale and unit based on max value + let (divisor, y_label) = match tab { + 0 => { + // Throughput - auto-scale + let (_, unit) = auto_scale_throughput(max_raw); + let div = match unit { + "GB/s" => 1_000_000_000.0, + "MB/s" => 1_000_000.0, + "KB/s" => 1_000.0, + _ => 1.0, + }; + (div, unit) + } + 1 => { + // Packets - scale to K or M + if max_raw >= 1_000_000.0 { + (1_000_000.0, "Mpps") + } else if max_raw >= 1_000.0 { + (1_000.0, "Kpps") + } else { + (1.0, "pps") + } + } + _ => (1.0, "err/s"), + }; + + // Scale the data + let rx_data: Vec<(f64, f64)> = rx_raw + .iter() + .enumerate() + .map(|(i, v)| (i as f64, v / divisor)) + .collect(); + let tx_data: Vec<(f64, f64)> = tx_raw + .iter() + .enumerate() + .map(|(i, v)| (i as f64, v / divisor)) + .collect(); + + let max_scaled = max_raw / divisor; + let x_max = rx_data.len() as f64; + + // Colors + let (rx_color, tx_color) = match tab { + 0 => (Color::Blue, Color::Magenta), + 1 => (Color::Green, Color::Yellow), + _ => (Color::Red, Color::Red), + }; + + let datasets = if tab == 2 { + vec![Dataset::default() + .name("Errors") + .marker(symbols::Marker::Braille) + .graph_type(GraphType::Line) + .style(Style::default().fg(rx_color)) + .data(&rx_data)] + } else { + vec![ + Dataset::default() + .name("RX") + .marker(symbols::Marker::Braille) + .graph_type(GraphType::Line) + .style(Style::default().fg(rx_color)) + .data(&rx_data), + Dataset::default() + .name("TX") + .marker(symbols::Marker::Braille) + .graph_type(GraphType::Line) + .style(Style::default().fg(tx_color)) + .data(&tx_data), + ] + }; + + // Time label based on data points (4 samples/sec = 250ms each) + let time_span_secs = rx_data.len() as f64 * 0.25; + let time_label = if time_span_secs >= 60.0 { + let mins = time_span_secs / 60.0; + format!("{mins:.0}m ago") + } else { + format!("{time_span_secs:.0}s ago") + }; + + let chart = Chart::new(datasets) + .x_axis( + Axis::default() + .style(Style::default().fg(Color::DarkGray)) + .bounds([0.0, x_max]) + .labels(vec![ + Span::styled(time_label, Style::default().fg(Color::DarkGray)), + Span::styled("now", Style::default().fg(Color::White)), + ]), + ) + .y_axis( + Axis::default() + .title(y_label) + .style(Style::default().fg(Color::DarkGray)) + .bounds([0.0, max_scaled * 1.1]) + .labels(vec![ + Span::raw("0"), + Span::styled(format!("{max_scaled:.1}"), Style::default().fg(Color::White)), + ]), ); - frame.render_widget(table, area); + frame.render_widget(chart, area); +} + +/// Render an inline sparkline as Unicode characters +fn render_inline_sparkline(data: &[u64]) -> String { + const SPARK_CHARS: &[char] = &['▁', '▂', '▃', '▄', '▅', '▆', '▇', '█']; + + if data.is_empty() { + return String::new(); + } + + data.iter() + .map(|&v| SPARK_CHARS[(v as usize).min(7)]) + .collect() +} + +/// Render a utilization bar +fn render_utilization_bar(percent: f64, width: usize) -> String { + let filled = ((percent / 100.0) * width as f64).round() as usize; + let filled = filled.min(width); + + (0..width) + .map(|i| if i < filled { '█' } else { '░' }) + .collect() } -fn format_bytes(bytes: u64) -> String { +/// Parse max rate from rate string (e.g., "100 Gb/sec" -> bytes/sec) +fn parse_max_rate(rate_str: &str) -> f64 { + // Extract the number and unit + let parts: Vec<&str> = rate_str.split_whitespace().collect(); + if parts.len() >= 2 { + if let Ok(num) = parts[0].parse::() { + // Convert Gb/sec to bytes/sec + return num * 1_000_000_000.0 / 8.0; + } + } + // Default to 100 Gbps + 12_500_000_000.0 +} + +/// Truncate rate string for display +fn truncate_rate(rate: &str) -> String { + // Extract just the speed part (e.g., "100 Gb/sec") + let parts: Vec<&str> = rate.split('(').collect(); + if parts.is_empty() { + rate.to_string() + } else { + parts[0].trim().to_string() + } +} + +#[allow(dead_code)] // Kept for API completeness +pub fn format_bytes(bytes: u64) -> String { const UNITS: &[&str] = &["B", "KB", "MB", "GB", "TB", "PB"]; let mut value = bytes; let mut unit_index = 0; @@ -172,7 +664,7 @@ fn format_bytes(bytes: u64) -> String { } } -fn format_bytes_per_sec(bytes_per_sec: f64) -> String { +pub fn format_bytes_per_sec(bytes_per_sec: f64) -> String { const UNITS: &[&str] = &["B/s", "KB/s", "MB/s", "GB/s", "TB/s"]; let mut value = bytes_per_sec; let mut unit_index = 0; @@ -218,4 +710,93 @@ mod tests { "1.0TB/s" ); } + + #[test] + fn test_render_inline_sparkline() { + let data = vec![0, 1, 2, 3, 4, 5, 6, 7]; + let result = render_inline_sparkline(&data); + assert_eq!(result, "▁▂▃▄▅▆▇█"); + } + + #[test] + fn test_render_inline_sparkline_empty() { + let data: Vec = vec![]; + let result = render_inline_sparkline(&data); + assert!(result.is_empty()); + } + + #[test] + fn test_parse_max_rate() { + assert!((parse_max_rate("100 Gb/sec (4X EDR)") - 12_500_000_000.0).abs() < 1.0); + assert!((parse_max_rate("200 Gb/sec") - 25_000_000_000.0).abs() < 1.0); + assert!((parse_max_rate("invalid") - 12_500_000_000.0).abs() < 1.0); // Default + } + + #[test] + fn test_truncate_rate() { + assert_eq!(truncate_rate("100 Gb/sec (4X EDR)"), "100 Gb/sec"); + assert_eq!(truncate_rate("200 Gb/sec"), "200 Gb/sec"); + } + + #[test] + fn test_utilization_bar() { + let bar = render_utilization_bar(50.0, 10); + // Unicode chars are multi-byte, so count chars not bytes + assert_eq!(bar.chars().count(), 10); + assert!(bar.contains('█')); + assert!(bar.contains('░')); + } + + #[test] + fn test_app_state_navigation() { + let mut state = AppState::new(); + state.selectable_items = vec![ + None, + Some(("mlx5_0".to_string(), 1)), + Some(("mlx5_0".to_string(), 2)), + None, + Some(("mlx5_1".to_string(), 1)), + ]; + state.selected_row = 1; + + state.select_next(); + assert_eq!(state.selected_row, 2); + + state.select_next(); + // Should skip the None at index 3 + assert_eq!(state.selected_row, 4); + + state.select_prev(); + assert_eq!(state.selected_row, 2); + } + + #[test] + fn test_app_state_toggle_detail() { + let mut state = AppState::new(); + assert!(!state.detail_expanded); + + state.toggle_detail(); + assert!(state.detail_expanded); + + state.toggle_detail(); + assert!(!state.detail_expanded); + } + + #[test] + fn test_app_state_tab_cycling() { + let mut state = AppState::new(); + assert_eq!(state.detail_tab, 0); + + state.next_tab(); + assert_eq!(state.detail_tab, 1); + + state.next_tab(); + assert_eq!(state.detail_tab, 2); + + state.next_tab(); + assert_eq!(state.detail_tab, 0); + + state.prev_tab(); + assert_eq!(state.detail_tab, 2); + } } From ed40ebba3382ed9ede6cc9cd11d8d21a09c0c51f Mon Sep 17 00:00:00 2001 From: Jannik Straube Date: Tue, 30 Dec 2025 19:04:16 +0100 Subject: [PATCH 2/4] Fix formatting --- src/history.rs | 5 +-- src/simulation.rs | 12 ++++- src/ui.rs | 112 +++++++++++++++++++++++++++++++--------------- 3 files changed, 88 insertions(+), 41 deletions(-) diff --git a/src/history.rs b/src/history.rs index ea1628c..8ada533 100644 --- a/src/history.rs +++ b/src/history.rs @@ -403,10 +403,7 @@ mod tests { assert_eq!(collector.port_count(), 3); // Retain only mlx5_0:1 and mlx5_1:1 - collector.retain_ports(&[ - ("mlx5_0".to_string(), 1), - ("mlx5_1".to_string(), 1), - ]); + collector.retain_ports(&[("mlx5_0".to_string(), 1), ("mlx5_1".to_string(), 1)]); assert_eq!(collector.port_count(), 2); assert!(collector.get("mlx5_0", 1).is_some()); diff --git a/src/simulation.rs b/src/simulation.rs index a8de1ad..c390357 100644 --- a/src/simulation.rs +++ b/src/simulation.rs @@ -356,7 +356,13 @@ mod tests { for pattern in TrafficPattern::all() { for t in 0..100 { let util = calculate_utilization(*pattern, t as f64 * 0.1); - assert!(util >= 0.0 && util <= 1.0, "Pattern {:?} at t={}: util={}", pattern, t, util); + assert!( + util >= 0.0 && util <= 1.0, + "Pattern {:?} at t={}: util={}", + pattern, + t, + util + ); } } } @@ -400,7 +406,9 @@ mod tests { #[test] fn test_avg_packet_sizes() { // Verify packet sizes are reasonable - assert!(avg_packet_size(TrafficPattern::Interactive) < avg_packet_size(TrafficPattern::Steady)); + assert!( + avg_packet_size(TrafficPattern::Interactive) < avg_packet_size(TrafficPattern::Steady) + ); assert!(avg_packet_size(TrafficPattern::Burst) < avg_packet_size(TrafficPattern::Steady)); } } diff --git a/src/ui.rs b/src/ui.rs index 93d915c..f32122d 100644 --- a/src/ui.rs +++ b/src/ui.rs @@ -102,7 +102,11 @@ impl AppState { /// Cycle detail tab backward pub fn prev_tab(&mut self) { - self.detail_tab = if self.detail_tab == 0 { 2 } else { self.detail_tab - 1 }; + self.detail_tab = if self.detail_tab == 0 { + 2 + } else { + self.detail_tab - 1 + }; } fn update_selectable_items(&mut self, adapters: &[AdapterInfo]) { @@ -110,7 +114,8 @@ impl AppState { for adapter in adapters { self.selectable_items.push(None); // Adapter header for port in &adapter.ports { - self.selectable_items.push(Some((adapter.name.clone(), port.port_number))); + self.selectable_items + .push(Some((adapter.name.clone(), port.port_number))); } } // Ensure selection is valid @@ -144,10 +149,7 @@ pub fn draw( let main_layout = Layout::default() .direction(Direction::Vertical) .constraints(if state.detail_expanded { - vec![ - Constraint::Percentage(50), - Constraint::Percentage(50), - ] + vec![Constraint::Percentage(50), Constraint::Percentage(50)] } else { vec![Constraint::Min(0)] }) @@ -196,9 +198,13 @@ fn draw_main_table( // Adapter header row with visual separator let is_header_selected = state.selected_row == row_idx; let header_style = if is_header_selected { - Style::default().fg(Color::Cyan).add_modifier(Modifier::BOLD) + Style::default() + .fg(Color::Cyan) + .add_modifier(Modifier::BOLD) } else { - Style::default().fg(Color::Green).add_modifier(Modifier::BOLD) + Style::default() + .fg(Color::Green) + .add_modifier(Modifier::BOLD) }; rows.push( @@ -225,7 +231,11 @@ fn draw_main_table( let (state_str, state_color) = match port.state { PortState::Active => { // Subtle pulse: alternates between bright and dim dot - let pulse = if state.frame_count % 60 < 30 { "●" } else { "○" }; + let pulse = if state.frame_count % 60 < 30 { + "●" + } else { + "○" + }; (format!("{pulse}ACTIVE"), Color::Green) } PortState::Down => ("○DOWN".to_string(), Color::Red), @@ -270,8 +280,11 @@ fn draw_main_table( Cell::from(format!(" {}", port.port_number)) .style(Style::default().fg(Color::Cyan)), Cell::from(state_str).style(Style::default().fg(state_color)), - Cell::from(truncate_rate(&port.rate)) - .style(Style::default().fg(Color::White).add_modifier(Modifier::DIM)), + Cell::from(truncate_rate(&port.rate)).style( + Style::default() + .fg(Color::White) + .add_modifier(Modifier::DIM), + ), Cell::from(bar), Cell::from(rx_rate).style(Style::default().fg(Color::Blue)), Cell::from(tx_rate).style(Style::default().fg(Color::Magenta)), @@ -288,14 +301,14 @@ fn draw_main_table( } let widths = [ - Constraint::Length(4), // Port - Constraint::Length(8), // State - Constraint::Length(12), // Link Rate - Constraint::Length(10), // Utilization bar - Constraint::Length(10), // RX Rate - Constraint::Length(10), // TX Rate + Constraint::Length(4), // Port + Constraint::Length(8), // State + Constraint::Length(12), // Link Rate + Constraint::Length(10), // Utilization bar + Constraint::Length(10), // RX Rate + Constraint::Length(10), // TX Rate Constraint::Length(SPARKLINE_SAMPLES as u16 + 2), // Sparkline - Constraint::Length(2), // Selection indicator + Constraint::Length(2), // Selection indicator ]; let header_style = Style::default() @@ -322,7 +335,12 @@ fn draw_main_table( .borders(Borders::ALL) .border_style(Style::default().fg(Color::DarkGray)) .title(Line::from(vec![ - Span::styled(" ibtop ", Style::default().fg(Color::Cyan).add_modifier(Modifier::BOLD)), + Span::styled( + " ibtop ", + Style::default() + .fg(Color::Cyan) + .add_modifier(Modifier::BOLD), + ), Span::styled("@ ", Style::default().fg(Color::DarkGray)), Span::styled(hostname, Style::default().fg(Color::White)), Span::styled(" ", Style::default()), @@ -372,9 +390,12 @@ fn draw_detail_panel( let block = Block::default() .borders(Borders::ALL) .border_style(Style::default().fg(Color::DarkGray)) - .title(Line::from(vec![ - Span::styled(" Detail View ", Style::default().fg(Color::Yellow).add_modifier(Modifier::BOLD)), - ])); + .title(Line::from(vec![Span::styled( + " Detail View ", + Style::default() + .fg(Color::Yellow) + .add_modifier(Modifier::BOLD), + )])); // Get selected port info let selected = state.selected_port(); @@ -412,7 +433,11 @@ fn draw_detail_panel( let tabs = Tabs::new(vec!["Throughput", "Packets", "Errors"]) .select(state.detail_tab) .style(Style::default().fg(Color::DarkGray)) - .highlight_style(Style::default().fg(Color::Cyan).add_modifier(Modifier::BOLD)) + .highlight_style( + Style::default() + .fg(Color::Cyan) + .add_modifier(Modifier::BOLD), + ) .divider(Span::raw(" | ")); frame.render_widget(tabs, detail_layout[0]); @@ -420,18 +445,33 @@ fn draw_detail_panel( // Stats summary if let (Some(port), Some(m)) = (port_info, current_metrics) { let stats_line = Line::from(vec![ - Span::styled(format!("{adapter_name}:"), Style::default().fg(Color::Green)), - Span::styled(format!("{port_num} ", port_num = port.port_number), Style::default().fg(Color::Cyan)), - Span::styled(format!("{} ", port.state), Style::default().fg(match port.state { - PortState::Active => Color::Green, - PortState::Down => Color::Red, - PortState::Unknown => Color::Yellow, - })), + Span::styled( + format!("{adapter_name}:"), + Style::default().fg(Color::Green), + ), + Span::styled( + format!("{port_num} ", port_num = port.port_number), + Style::default().fg(Color::Cyan), + ), + Span::styled( + format!("{} ", port.state), + Style::default().fg(match port.state { + PortState::Active => Color::Green, + PortState::Down => Color::Red, + PortState::Unknown => Color::Yellow, + }), + ), Span::styled("| ", Style::default().fg(Color::DarkGray)), Span::styled("RX: ", Style::default().fg(Color::DarkGray)), - Span::styled(format_bytes_per_sec(m.rx_bytes_per_sec), Style::default().fg(Color::Blue)), + Span::styled( + format_bytes_per_sec(m.rx_bytes_per_sec), + Style::default().fg(Color::Blue), + ), Span::styled(" TX: ", Style::default().fg(Color::DarkGray)), - Span::styled(format_bytes_per_sec(m.tx_bytes_per_sec), Style::default().fg(Color::Magenta)), + Span::styled( + format_bytes_per_sec(m.tx_bytes_per_sec), + Style::default().fg(Color::Magenta), + ), ]); let stats_para = Paragraph::new(stats_line); @@ -442,8 +482,7 @@ fn draw_detail_panel( if let Some(h) = history { draw_chart(frame, detail_layout[2], h, state.detail_tab); } else { - let msg = Paragraph::new("Collecting data...") - .style(Style::default().fg(Color::DarkGray)); + let msg = Paragraph::new("Collecting data...").style(Style::default().fg(Color::DarkGray)); frame.render_widget(msg, detail_layout[2]); } } @@ -589,7 +628,10 @@ fn draw_chart(frame: &mut Frame, area: Rect, history: &PortHistory, tab: usize) .bounds([0.0, max_scaled * 1.1]) .labels(vec![ Span::raw("0"), - Span::styled(format!("{max_scaled:.1}"), Style::default().fg(Color::White)), + Span::styled( + format!("{max_scaled:.1}"), + Style::default().fg(Color::White), + ), ]), ); From c4fae56bcfafee572ee901f22b34395e7e0d7fcd Mon Sep 17 00:00:00 2001 From: Jannik Straube Date: Tue, 30 Dec 2025 21:46:55 +0100 Subject: [PATCH 3/4] add totals to header, sparkline padding, bump to v1.0.0 --- Cargo.lock | 2 +- Cargo.toml | 2 +- src/ui.rs | 143 +++++++++++++++++++++++------------------------------ 3 files changed, 64 insertions(+), 83 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 87f7067..7826ecd 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -197,7 +197,7 @@ dependencies = [ [[package]] name = "ibtop" -version = "0.1.7" +version = "1.0.0" dependencies = [ "crossterm", "hostname", diff --git a/Cargo.toml b/Cargo.toml index 333f746..abf1c8c 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "ibtop" -version = "0.1.7" +version = "1.0.0" edition = "2021" authors = ["info@jannik-straube.de"] description = "Real-time terminal monitor for InfiniBand networks" diff --git a/src/ui.rs b/src/ui.rs index f32122d..e475f5c 100644 --- a/src/ui.rs +++ b/src/ui.rs @@ -102,11 +102,7 @@ impl AppState { /// Cycle detail tab backward pub fn prev_tab(&mut self) { - self.detail_tab = if self.detail_tab == 0 { - 2 - } else { - self.detail_tab - 1 - }; + self.detail_tab = if self.detail_tab == 0 { 2 } else { self.detail_tab - 1 }; } fn update_selectable_items(&mut self, adapters: &[AdapterInfo]) { @@ -114,8 +110,7 @@ impl AppState { for adapter in adapters { self.selectable_items.push(None); // Adapter header for port in &adapter.ports { - self.selectable_items - .push(Some((adapter.name.clone(), port.port_number))); + self.selectable_items.push(Some((adapter.name.clone(), port.port_number))); } } // Ensure selection is valid @@ -149,7 +144,10 @@ pub fn draw( let main_layout = Layout::default() .direction(Direction::Vertical) .constraints(if state.detail_expanded { - vec![Constraint::Percentage(50), Constraint::Percentage(50)] + vec![ + Constraint::Percentage(50), + Constraint::Percentage(50), + ] } else { vec![Constraint::Min(0)] }) @@ -164,6 +162,21 @@ pub fn draw( } } +/// Calculate total throughput across all active ports +fn calculate_totals(adapters: &[AdapterInfo], metrics: &MetricsCollector) -> (f64, f64) { + let mut total_rx = 0.0; + let mut total_tx = 0.0; + for adapter in adapters { + for port in &adapter.ports { + if let Some(m) = metrics.get_metrics(&adapter.name, port.port_number) { + total_rx += m.rx_bytes_per_sec; + total_tx += m.tx_bytes_per_sec; + } + } + } + (total_rx, total_tx) +} + /// Draw the main table with sparklines #[allow(clippy::too_many_lines)] fn draw_main_table( @@ -174,6 +187,9 @@ fn draw_main_table( hostname: &str, state: &AppState, ) { + // Calculate totals for header + let (total_rx, total_tx) = calculate_totals(adapters, metrics); + let chunks = Layout::default() .direction(Direction::Vertical) .constraints([Constraint::Min(0), Constraint::Length(2)]) @@ -198,13 +214,9 @@ fn draw_main_table( // Adapter header row with visual separator let is_header_selected = state.selected_row == row_idx; let header_style = if is_header_selected { - Style::default() - .fg(Color::Cyan) - .add_modifier(Modifier::BOLD) + Style::default().fg(Color::Cyan).add_modifier(Modifier::BOLD) } else { - Style::default() - .fg(Color::Green) - .add_modifier(Modifier::BOLD) + Style::default().fg(Color::Green).add_modifier(Modifier::BOLD) }; rows.push( @@ -231,11 +243,7 @@ fn draw_main_table( let (state_str, state_color) = match port.state { PortState::Active => { // Subtle pulse: alternates between bright and dim dot - let pulse = if state.frame_count % 60 < 30 { - "●" - } else { - "○" - }; + let pulse = if state.frame_count % 60 < 30 { "●" } else { "○" }; (format!("{pulse}ACTIVE"), Color::Green) } PortState::Down => ("○DOWN".to_string(), Color::Red), @@ -252,11 +260,11 @@ fn draw_main_table( ("--".to_string(), "--".to_string()) }; - // Sparkline data + // Sparkline data (with padding) let sparkline_str = if let Some(h) = history { - render_inline_sparkline(&h.combined_sparkline_data(SPARKLINE_SAMPLES)) + format!(" {} ", render_inline_sparkline(&h.combined_sparkline_data(SPARKLINE_SAMPLES))) } else { - " ".repeat(SPARKLINE_SAMPLES) + " ".repeat(SPARKLINE_SAMPLES + 2) }; // Throughput bar (visual indicator of utilization) @@ -280,11 +288,8 @@ fn draw_main_table( Cell::from(format!(" {}", port.port_number)) .style(Style::default().fg(Color::Cyan)), Cell::from(state_str).style(Style::default().fg(state_color)), - Cell::from(truncate_rate(&port.rate)).style( - Style::default() - .fg(Color::White) - .add_modifier(Modifier::DIM), - ), + Cell::from(truncate_rate(&port.rate)) + .style(Style::default().fg(Color::White).add_modifier(Modifier::DIM)), Cell::from(bar), Cell::from(rx_rate).style(Style::default().fg(Color::Blue)), Cell::from(tx_rate).style(Style::default().fg(Color::Magenta)), @@ -301,14 +306,14 @@ fn draw_main_table( } let widths = [ - Constraint::Length(4), // Port - Constraint::Length(8), // State - Constraint::Length(12), // Link Rate - Constraint::Length(10), // Utilization bar - Constraint::Length(10), // RX Rate - Constraint::Length(10), // TX Rate - Constraint::Length(SPARKLINE_SAMPLES as u16 + 2), // Sparkline - Constraint::Length(2), // Selection indicator + Constraint::Length(4), // Port + Constraint::Length(8), // State + Constraint::Length(12), // Link Rate + Constraint::Length(10), // Utilization bar + Constraint::Length(10), // RX Rate + Constraint::Length(10), // TX Rate + Constraint::Length(SPARKLINE_SAMPLES as u16 + 4), // Sparkline (padded) + Constraint::Length(2), // Selection indicator ]; let header_style = Style::default() @@ -335,14 +340,14 @@ fn draw_main_table( .borders(Borders::ALL) .border_style(Style::default().fg(Color::DarkGray)) .title(Line::from(vec![ - Span::styled( - " ibtop ", - Style::default() - .fg(Color::Cyan) - .add_modifier(Modifier::BOLD), - ), + Span::styled(" ibtop ", Style::default().fg(Color::Cyan).add_modifier(Modifier::BOLD)), Span::styled("@ ", Style::default().fg(Color::DarkGray)), Span::styled(hostname, Style::default().fg(Color::White)), + Span::styled(" │ ", Style::default().fg(Color::DarkGray)), + Span::styled("▲ ", Style::default().fg(Color::Green)), + Span::styled(format_bytes_per_sec(total_rx), Style::default().fg(Color::Green)), + Span::styled(" ▼ ", Style::default().fg(Color::Blue)), + Span::styled(format_bytes_per_sec(total_tx), Style::default().fg(Color::Blue)), Span::styled(" ", Style::default()), ])) .title_style(Style::default()), @@ -390,12 +395,9 @@ fn draw_detail_panel( let block = Block::default() .borders(Borders::ALL) .border_style(Style::default().fg(Color::DarkGray)) - .title(Line::from(vec![Span::styled( - " Detail View ", - Style::default() - .fg(Color::Yellow) - .add_modifier(Modifier::BOLD), - )])); + .title(Line::from(vec![ + Span::styled(" Detail View ", Style::default().fg(Color::Yellow).add_modifier(Modifier::BOLD)), + ])); // Get selected port info let selected = state.selected_port(); @@ -433,11 +435,7 @@ fn draw_detail_panel( let tabs = Tabs::new(vec!["Throughput", "Packets", "Errors"]) .select(state.detail_tab) .style(Style::default().fg(Color::DarkGray)) - .highlight_style( - Style::default() - .fg(Color::Cyan) - .add_modifier(Modifier::BOLD), - ) + .highlight_style(Style::default().fg(Color::Cyan).add_modifier(Modifier::BOLD)) .divider(Span::raw(" | ")); frame.render_widget(tabs, detail_layout[0]); @@ -445,33 +443,18 @@ fn draw_detail_panel( // Stats summary if let (Some(port), Some(m)) = (port_info, current_metrics) { let stats_line = Line::from(vec![ - Span::styled( - format!("{adapter_name}:"), - Style::default().fg(Color::Green), - ), - Span::styled( - format!("{port_num} ", port_num = port.port_number), - Style::default().fg(Color::Cyan), - ), - Span::styled( - format!("{} ", port.state), - Style::default().fg(match port.state { - PortState::Active => Color::Green, - PortState::Down => Color::Red, - PortState::Unknown => Color::Yellow, - }), - ), + Span::styled(format!("{adapter_name}:"), Style::default().fg(Color::Green)), + Span::styled(format!("{port_num} ", port_num = port.port_number), Style::default().fg(Color::Cyan)), + Span::styled(format!("{} ", port.state), Style::default().fg(match port.state { + PortState::Active => Color::Green, + PortState::Down => Color::Red, + PortState::Unknown => Color::Yellow, + })), Span::styled("| ", Style::default().fg(Color::DarkGray)), Span::styled("RX: ", Style::default().fg(Color::DarkGray)), - Span::styled( - format_bytes_per_sec(m.rx_bytes_per_sec), - Style::default().fg(Color::Blue), - ), + Span::styled(format_bytes_per_sec(m.rx_bytes_per_sec), Style::default().fg(Color::Blue)), Span::styled(" TX: ", Style::default().fg(Color::DarkGray)), - Span::styled( - format_bytes_per_sec(m.tx_bytes_per_sec), - Style::default().fg(Color::Magenta), - ), + Span::styled(format_bytes_per_sec(m.tx_bytes_per_sec), Style::default().fg(Color::Magenta)), ]); let stats_para = Paragraph::new(stats_line); @@ -482,7 +465,8 @@ fn draw_detail_panel( if let Some(h) = history { draw_chart(frame, detail_layout[2], h, state.detail_tab); } else { - let msg = Paragraph::new("Collecting data...").style(Style::default().fg(Color::DarkGray)); + let msg = Paragraph::new("Collecting data...") + .style(Style::default().fg(Color::DarkGray)); frame.render_widget(msg, detail_layout[2]); } } @@ -628,10 +612,7 @@ fn draw_chart(frame: &mut Frame, area: Rect, history: &PortHistory, tab: usize) .bounds([0.0, max_scaled * 1.1]) .labels(vec![ Span::raw("0"), - Span::styled( - format!("{max_scaled:.1}"), - Style::default().fg(Color::White), - ), + Span::styled(format!("{max_scaled:.1}"), Style::default().fg(Color::White)), ]), ); From 612c3f4de58a80141d89c97ff35ab167cc0d3bd0 Mon Sep 17 00:00:00 2001 From: Jannik Straube Date: Tue, 30 Dec 2025 21:51:23 +0100 Subject: [PATCH 4/4] fix clippy lints in tests --- src/simulation.rs | 7 +-- src/ui.rs | 127 ++++++++++++++++++++++++++++++++-------------- 2 files changed, 91 insertions(+), 43 deletions(-) diff --git a/src/simulation.rs b/src/simulation.rs index c390357..d7c308e 100644 --- a/src/simulation.rs +++ b/src/simulation.rs @@ -355,13 +355,10 @@ mod tests { fn test_utilization_bounds() { for pattern in TrafficPattern::all() { for t in 0..100 { - let util = calculate_utilization(*pattern, t as f64 * 0.1); + let util = calculate_utilization(*pattern, f64::from(t) * 0.1); assert!( util >= 0.0 && util <= 1.0, - "Pattern {:?} at t={}: util={}", - pattern, - t, - util + "Pattern {pattern:?} at t={t}: util={util}" ); } } diff --git a/src/ui.rs b/src/ui.rs index e475f5c..3d6f3f1 100644 --- a/src/ui.rs +++ b/src/ui.rs @@ -102,7 +102,11 @@ impl AppState { /// Cycle detail tab backward pub fn prev_tab(&mut self) { - self.detail_tab = if self.detail_tab == 0 { 2 } else { self.detail_tab - 1 }; + self.detail_tab = if self.detail_tab == 0 { + 2 + } else { + self.detail_tab - 1 + }; } fn update_selectable_items(&mut self, adapters: &[AdapterInfo]) { @@ -110,7 +114,8 @@ impl AppState { for adapter in adapters { self.selectable_items.push(None); // Adapter header for port in &adapter.ports { - self.selectable_items.push(Some((adapter.name.clone(), port.port_number))); + self.selectable_items + .push(Some((adapter.name.clone(), port.port_number))); } } // Ensure selection is valid @@ -144,10 +149,7 @@ pub fn draw( let main_layout = Layout::default() .direction(Direction::Vertical) .constraints(if state.detail_expanded { - vec![ - Constraint::Percentage(50), - Constraint::Percentage(50), - ] + vec![Constraint::Percentage(50), Constraint::Percentage(50)] } else { vec![Constraint::Min(0)] }) @@ -214,9 +216,13 @@ fn draw_main_table( // Adapter header row with visual separator let is_header_selected = state.selected_row == row_idx; let header_style = if is_header_selected { - Style::default().fg(Color::Cyan).add_modifier(Modifier::BOLD) + Style::default() + .fg(Color::Cyan) + .add_modifier(Modifier::BOLD) } else { - Style::default().fg(Color::Green).add_modifier(Modifier::BOLD) + Style::default() + .fg(Color::Green) + .add_modifier(Modifier::BOLD) }; rows.push( @@ -243,7 +249,11 @@ fn draw_main_table( let (state_str, state_color) = match port.state { PortState::Active => { // Subtle pulse: alternates between bright and dim dot - let pulse = if state.frame_count % 60 < 30 { "●" } else { "○" }; + let pulse = if state.frame_count % 60 < 30 { + "●" + } else { + "○" + }; (format!("{pulse}ACTIVE"), Color::Green) } PortState::Down => ("○DOWN".to_string(), Color::Red), @@ -262,7 +272,10 @@ fn draw_main_table( // Sparkline data (with padding) let sparkline_str = if let Some(h) = history { - format!(" {} ", render_inline_sparkline(&h.combined_sparkline_data(SPARKLINE_SAMPLES))) + format!( + " {} ", + render_inline_sparkline(&h.combined_sparkline_data(SPARKLINE_SAMPLES)) + ) } else { " ".repeat(SPARKLINE_SAMPLES + 2) }; @@ -288,8 +301,11 @@ fn draw_main_table( Cell::from(format!(" {}", port.port_number)) .style(Style::default().fg(Color::Cyan)), Cell::from(state_str).style(Style::default().fg(state_color)), - Cell::from(truncate_rate(&port.rate)) - .style(Style::default().fg(Color::White).add_modifier(Modifier::DIM)), + Cell::from(truncate_rate(&port.rate)).style( + Style::default() + .fg(Color::White) + .add_modifier(Modifier::DIM), + ), Cell::from(bar), Cell::from(rx_rate).style(Style::default().fg(Color::Blue)), Cell::from(tx_rate).style(Style::default().fg(Color::Magenta)), @@ -306,14 +322,14 @@ fn draw_main_table( } let widths = [ - Constraint::Length(4), // Port - Constraint::Length(8), // State - Constraint::Length(12), // Link Rate - Constraint::Length(10), // Utilization bar - Constraint::Length(10), // RX Rate - Constraint::Length(10), // TX Rate + Constraint::Length(4), // Port + Constraint::Length(8), // State + Constraint::Length(12), // Link Rate + Constraint::Length(10), // Utilization bar + Constraint::Length(10), // RX Rate + Constraint::Length(10), // TX Rate Constraint::Length(SPARKLINE_SAMPLES as u16 + 4), // Sparkline (padded) - Constraint::Length(2), // Selection indicator + Constraint::Length(2), // Selection indicator ]; let header_style = Style::default() @@ -340,14 +356,25 @@ fn draw_main_table( .borders(Borders::ALL) .border_style(Style::default().fg(Color::DarkGray)) .title(Line::from(vec![ - Span::styled(" ibtop ", Style::default().fg(Color::Cyan).add_modifier(Modifier::BOLD)), + Span::styled( + " ibtop ", + Style::default() + .fg(Color::Cyan) + .add_modifier(Modifier::BOLD), + ), Span::styled("@ ", Style::default().fg(Color::DarkGray)), Span::styled(hostname, Style::default().fg(Color::White)), Span::styled(" │ ", Style::default().fg(Color::DarkGray)), Span::styled("▲ ", Style::default().fg(Color::Green)), - Span::styled(format_bytes_per_sec(total_rx), Style::default().fg(Color::Green)), + Span::styled( + format_bytes_per_sec(total_rx), + Style::default().fg(Color::Green), + ), Span::styled(" ▼ ", Style::default().fg(Color::Blue)), - Span::styled(format_bytes_per_sec(total_tx), Style::default().fg(Color::Blue)), + Span::styled( + format_bytes_per_sec(total_tx), + Style::default().fg(Color::Blue), + ), Span::styled(" ", Style::default()), ])) .title_style(Style::default()), @@ -395,9 +422,12 @@ fn draw_detail_panel( let block = Block::default() .borders(Borders::ALL) .border_style(Style::default().fg(Color::DarkGray)) - .title(Line::from(vec![ - Span::styled(" Detail View ", Style::default().fg(Color::Yellow).add_modifier(Modifier::BOLD)), - ])); + .title(Line::from(vec![Span::styled( + " Detail View ", + Style::default() + .fg(Color::Yellow) + .add_modifier(Modifier::BOLD), + )])); // Get selected port info let selected = state.selected_port(); @@ -435,7 +465,11 @@ fn draw_detail_panel( let tabs = Tabs::new(vec!["Throughput", "Packets", "Errors"]) .select(state.detail_tab) .style(Style::default().fg(Color::DarkGray)) - .highlight_style(Style::default().fg(Color::Cyan).add_modifier(Modifier::BOLD)) + .highlight_style( + Style::default() + .fg(Color::Cyan) + .add_modifier(Modifier::BOLD), + ) .divider(Span::raw(" | ")); frame.render_widget(tabs, detail_layout[0]); @@ -443,18 +477,33 @@ fn draw_detail_panel( // Stats summary if let (Some(port), Some(m)) = (port_info, current_metrics) { let stats_line = Line::from(vec![ - Span::styled(format!("{adapter_name}:"), Style::default().fg(Color::Green)), - Span::styled(format!("{port_num} ", port_num = port.port_number), Style::default().fg(Color::Cyan)), - Span::styled(format!("{} ", port.state), Style::default().fg(match port.state { - PortState::Active => Color::Green, - PortState::Down => Color::Red, - PortState::Unknown => Color::Yellow, - })), + Span::styled( + format!("{adapter_name}:"), + Style::default().fg(Color::Green), + ), + Span::styled( + format!("{port_num} ", port_num = port.port_number), + Style::default().fg(Color::Cyan), + ), + Span::styled( + format!("{} ", port.state), + Style::default().fg(match port.state { + PortState::Active => Color::Green, + PortState::Down => Color::Red, + PortState::Unknown => Color::Yellow, + }), + ), Span::styled("| ", Style::default().fg(Color::DarkGray)), Span::styled("RX: ", Style::default().fg(Color::DarkGray)), - Span::styled(format_bytes_per_sec(m.rx_bytes_per_sec), Style::default().fg(Color::Blue)), + Span::styled( + format_bytes_per_sec(m.rx_bytes_per_sec), + Style::default().fg(Color::Blue), + ), Span::styled(" TX: ", Style::default().fg(Color::DarkGray)), - Span::styled(format_bytes_per_sec(m.tx_bytes_per_sec), Style::default().fg(Color::Magenta)), + Span::styled( + format_bytes_per_sec(m.tx_bytes_per_sec), + Style::default().fg(Color::Magenta), + ), ]); let stats_para = Paragraph::new(stats_line); @@ -465,8 +514,7 @@ fn draw_detail_panel( if let Some(h) = history { draw_chart(frame, detail_layout[2], h, state.detail_tab); } else { - let msg = Paragraph::new("Collecting data...") - .style(Style::default().fg(Color::DarkGray)); + let msg = Paragraph::new("Collecting data...").style(Style::default().fg(Color::DarkGray)); frame.render_widget(msg, detail_layout[2]); } } @@ -612,7 +660,10 @@ fn draw_chart(frame: &mut Frame, area: Rect, history: &PortHistory, tab: usize) .bounds([0.0, max_scaled * 1.1]) .labels(vec![ Span::raw("0"), - Span::styled(format!("{max_scaled:.1}"), Style::default().fg(Color::White)), + Span::styled( + format!("{max_scaled:.1}"), + Style::default().fg(Color::White), + ), ]), );