From c31ad07d612f255df58dba5f454484ed023d7633 Mon Sep 17 00:00:00 2001 From: Nils Andreas Svee Date: Wed, 13 May 2026 22:31:11 +0700 Subject: [PATCH] feat(irtt-cli): add optional tui output mode --- Cargo.lock | 513 ++++++++++- README.md | 10 +- crates/irtt-cli/Cargo.toml | 3 + crates/irtt-cli/src/lib.rs | 7 +- crates/irtt-cli/src/main.rs | 7 + crates/irtt-cli/src/tui.rs | 1676 +++++++++++++++++++++++++++++++++++ 6 files changed, 2210 insertions(+), 6 deletions(-) create mode 100644 crates/irtt-cli/src/tui.rs diff --git a/Cargo.lock b/Cargo.lock index ff24fc1..a85d480 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -2,6 +2,12 @@ # It is not intended for manual editing. version = 3 +[[package]] +name = "allocator-api2" +version = "0.2.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "683d7910e743518b0e34f1186f92494becacb047c7b6bf616c96772180fef923" + [[package]] name = "anstream" version = "1.0.0" @@ -38,7 +44,7 @@ version = "1.1.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "40c48f72fd53cd289104fc64099abca73db4166ad86ea0b4341abe65af83dadc" dependencies = [ - "windows-sys", + "windows-sys 0.61.2", ] [[package]] @@ -49,7 +55,7 @@ checksum = "291e6a250ff86cd4a820112fb8898808a366d8f9f58ce16d1f538353ad55747d" dependencies = [ "anstyle", "once_cell_polyfill", - "windows-sys", + "windows-sys 0.61.2", ] [[package]] @@ -76,6 +82,21 @@ dependencies = [ "objc2", ] +[[package]] +name = "cassowary" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "df8670b8c7b9dae1793364eafadf7239c40d669904660c5960d74cfd80b46a53" + +[[package]] +name = "castaway" +version = "0.2.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dec551ab6e7578819132c713a93c022a05d60159dc86e7a7050223577484c55a" +dependencies = [ + "rustversion", +] + [[package]] name = "cfg-if" version = "1.0.4" @@ -140,12 +161,51 @@ version = "1.0.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1d07550c9036bf2ae0c684c4297d503f838287c83c53686d05370d0e139ae570" +[[package]] +name = "compact_str" +version = "0.8.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3b79c4069c6cad78e2e0cdfcbd26275770669fb39fd308a752dc110e83b9af32" +dependencies = [ + "castaway", + "cfg-if", + "itoa", + "rustversion", + "ryu", + "static_assertions", +] + [[package]] name = "const-oid" version = "0.10.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a6ef517f0926dd24a1582492c791b6a4818a4d94e789a334894aa15b0d12f55c" +[[package]] +name = "crossterm" +version = "0.28.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "829d955a0bb380ef178a640b91779e3987da38c9aea133b20614cfed8cdea9c6" +dependencies = [ + "bitflags", + "crossterm_winapi", + "mio", + "parking_lot", + "rustix", + "signal-hook", + "signal-hook-mio", + "winapi", +] + +[[package]] +name = "crossterm_winapi" +version = "0.9.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "acdd7c62a3665c7f6830a51635d9ac9b23ed385797f70a83bb8bafe9c572ab2b" +dependencies = [ + "winapi", +] + [[package]] name = "crypto-common" version = "0.2.2" @@ -163,7 +223,7 @@ checksum = "e0b1fab2ae45819af2d0731d60f2afe17227ebb1a1538a236da84c93e9a60162" dependencies = [ "dispatch2", "nix", - "windows-sys", + "windows-sys 0.61.2", ] [[package]] @@ -175,6 +235,40 @@ dependencies = [ "cmov", ] +[[package]] +name = "darling" +version = "0.23.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "25ae13da2f202d56bd7f91c25fba009e7717a1e4a1cc98a76d844b65ae912e9d" +dependencies = [ + "darling_core", + "darling_macro", +] + +[[package]] +name = "darling_core" +version = "0.23.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9865a50f7c335f53564bb694ef660825eb8610e0a53d3e11bf1b0d3df31e03b0" +dependencies = [ + "ident_case", + "proc-macro2", + "quote", + "strsim", + "syn", +] + +[[package]] +name = "darling_macro" +version = "0.23.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ac3984ec7bd6cfa798e62b4a642426a5be0e68f9401cfc2a01e3fa9ea2fcdb8d" +dependencies = [ + "darling_core", + "quote", + "syn", +] + [[package]] name = "digest" version = "0.11.3" @@ -199,6 +293,45 @@ dependencies = [ "objc2", ] +[[package]] +name = "either" +version = "1.15.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "48c757948c5ede0e46177b7add2e67155f70e33c07fea8284df6576da70b3719" + +[[package]] +name = "equivalent" +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 = "foldhash" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d9c4f5dac5e15c24eb999c26181a6ca40b39fe946cbe4c263c7209467bc83af2" + +[[package]] +name = "hashbrown" +version = "0.15.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9229cfe53dfd69f0609a49f65461bd93001ea1ef889cd5529dd176593f5338a1" +dependencies = [ + "allocator-api2", + "equivalent", + "foldhash", +] + [[package]] name = "heck" version = "0.5.0" @@ -223,15 +356,45 @@ dependencies = [ "typenum", ] +[[package]] +name = "ident_case" +version = "1.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b9e0384b61958566e926dc50660321d12159025e767c18e043daf26b70104c39" + +[[package]] +name = "indoc" +version = "2.0.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "79cf5c93f93228cf8efb3ba362535fb11199ac548a09ce117c9b1adc3030d706" +dependencies = [ + "rustversion", +] + +[[package]] +name = "instability" +version = "0.3.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5eb2d60ef19920a3a9193c3e371f726ec1dafc045dac788d0fb3704272458971" +dependencies = [ + "darling", + "indoc", + "proc-macro2", + "quote", + "syn", +] + [[package]] name = "irtt-cli" version = "0.2.0" dependencies = [ "clap", + "crossterm", "ctrlc", "irtt-client", "irtt-proto", "irtt-stats", + "ratatui", ] [[package]] @@ -267,12 +430,57 @@ version = "1.70.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a6cb138bb79a146c1bd460005623e142ef0181e3d0219cb493e02f7d08a35695" +[[package]] +name = "itertools" +version = "0.13.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "413ee7dfc52ee1a4949ceeb7dbc8a33f2d6c088194d9f922fb8318faf1f01186" +dependencies = [ + "either", +] + +[[package]] +name = "itoa" +version = "1.0.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8f42a60cbdf9a97f5d2305f08a87dc4e09308d1276d28c869c684d7777685682" + [[package]] name = "libc" version = "0.2.186" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "68ab91017fe16c622486840e4c83c9a37afeff978bd239b5293d61ece587de66" +[[package]] +name = "linux-raw-sys" +version = "0.4.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d26c52dbd32dccf2d10cac7725f8eae5296885fb5703b261f7d0a0739ec807ab" + +[[package]] +name = "lock_api" +version = "0.4.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "224399e74b87b5f3557511d98dff8b14089b3dadafcab6bb93eab67d3aace965" +dependencies = [ + "scopeguard", +] + +[[package]] +name = "log" +version = "0.4.29" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5e5032e24019045c762d3c0f28f5b6b8bbf38563a65908389bf7978758920897" + +[[package]] +name = "lru" +version = "0.12.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "234cf4f4a04dc1f57e24b96cc0cd600cf2af460d4161ac5ecdd0af8e1f3b2a38" +dependencies = [ + "hashbrown", +] + [[package]] name = "md-5" version = "0.11.0" @@ -283,6 +491,18 @@ dependencies = [ "digest", ] +[[package]] +name = "mio" +version = "1.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "50b7e5b27aa02a74bac8c3f23f448f8d87ff11f92d3aac1a6ed369ee08cc56c1" +dependencies = [ + "libc", + "log", + "wasi", + "windows-sys 0.61.2", +] + [[package]] name = "nix" version = "0.31.3" @@ -316,6 +536,35 @@ version = "1.70.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "384b8ab6d37215f3c5301a95a4accb5d64aa607f1fcb26a11b5303878451b4fe" +[[package]] +name = "parking_lot" +version = "0.12.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "93857453250e3077bd71ff98b6a65ea6621a19bb0f559a85248955ac12c45a1a" +dependencies = [ + "lock_api", + "parking_lot_core", +] + +[[package]] +name = "parking_lot_core" +version = "0.9.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2621685985a2ebf1c516881c026032ac7deafcda1a2c9b7850dc81e3dfcb64c1" +dependencies = [ + "cfg-if", + "libc", + "redox_syscall", + "smallvec", + "windows-link", +] + +[[package]] +name = "paste" +version = "1.0.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "57c0d7b74b563b49d38dae00a0c37d4d6de9b432382b2892f0574ddcae73fd0a" + [[package]] name = "proc-macro2" version = "1.0.106" @@ -334,6 +583,104 @@ dependencies = [ "proc-macro2", ] +[[package]] +name = "ratatui" +version = "0.29.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "eabd94c2f37801c20583fc49dd5cd6b0ba68c716787c2dd6ed18571e1e63117b" +dependencies = [ + "bitflags", + "cassowary", + "compact_str", + "crossterm", + "indoc", + "instability", + "itertools", + "lru", + "paste", + "strum", + "unicode-segmentation", + "unicode-truncate", + "unicode-width 0.2.0", +] + +[[package]] +name = "redox_syscall" +version = "0.5.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ed2bf2547551a7053d6fdfafda3f938979645c44812fbfcda098faae3f1a362d" +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", + "windows-sys 0.59.0", +] + +[[package]] +name = "rustversion" +version = "1.0.22" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b39cdef0fa800fc44525c84ccb54a029961a8215f9619753635a9c0d2538d46d" + +[[package]] +name = "ryu" +version = "1.0.23" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9774ba4a74de5f7b1c1451ed6cd5285a32eddb5cccb8cc655a4e50009e06477f" + +[[package]] +name = "scopeguard" +version = "1.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "94143f37725109f92c262ed2cf5e59bce7498c01bcc1502d7b9afe439a4e9f49" + +[[package]] +name = "signal-hook" +version = "0.3.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d881a16cf4426aa584979d30bd82cb33429027e42122b169753d6ef1085ed6e2" +dependencies = [ + "libc", + "signal-hook-registry", +] + +[[package]] +name = "signal-hook-mio" +version = "0.2.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b75a19a7a740b25bc7944bdee6172368f988763b744e3d4dfe753f6b4ece40cc" +dependencies = [ + "libc", + "mio", + "signal-hook", +] + +[[package]] +name = "signal-hook-registry" +version = "1.4.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c4db69cba1110affc0e9f7bcd48bbf87b3f4fc7c61fc9155afd4c469eb3d6c1b" +dependencies = [ + "errno", + "libc", +] + +[[package]] +name = "smallvec" +version = "1.15.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "67b1b7a3b5fe4f1376887184045fcf45c69e92af734b7aaddc05fb777b6fbd03" + [[package]] name = "socket2" version = "0.6.4" @@ -341,15 +688,43 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "52d1cfed4120b4d927bf7c0f86d2087a4a7d6027c906d9f9d525a80573b9be51" dependencies = [ "libc", - "windows-sys", + "windows-sys 0.61.2", ] +[[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" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8fec0f0aef304996cf250b31b5a10dee7980c85da9d759361292b8bca5a18f06" +dependencies = [ + "strum_macros", +] + +[[package]] +name = "strum_macros" +version = "0.26.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4c6bee85a5a24955dc440386795aa378cd9cf82acd5f764469152d2270e581be" +dependencies = [ + "heck", + "proc-macro2", + "quote", + "rustversion", + "syn", +] + [[package]] name = "subtle" version = "2.6.1" @@ -399,18 +774,84 @@ version = "1.0.24" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e6e4313cd5fcd3dad5cafa179702e2b244f760991f45397d14d4ebf38247da75" +[[package]] +name = "unicode-segmentation" +version = "1.13.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9629274872b2bfaf8d66f5f15725007f635594914870f65218920345aa11aa8c" + +[[package]] +name = "unicode-truncate" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b3644627a5af5fa321c95b9b235a72fd24cd29c648c2c379431e6628655627bf" +dependencies = [ + "itertools", + "unicode-segmentation", + "unicode-width 0.1.14", +] + +[[package]] +name = "unicode-width" +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 = "utf8parse" version = "0.2.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "06abde3611657adf66d383f00b093d7faecc7fa57071cce2578660c9f1010821" +[[package]] +name = "wasi" +version = "0.11.1+wasi-snapshot-preview1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ccf3ec651a847eb01de73ccad15eb7d99f80485de043efb2f370cd654f4ea44b" + +[[package]] +name = "winapi" +version = "0.3.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5c839a674fcd7a98952e593242ea400abe93992746761e38641405d28b00f419" +dependencies = [ + "winapi-i686-pc-windows-gnu", + "winapi-x86_64-pc-windows-gnu", +] + +[[package]] +name = "winapi-i686-pc-windows-gnu" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ac3b87c63620426dd9b991e5ce0329eff545bccbbb34f3be09ff6fb6ab51b7b6" + +[[package]] +name = "winapi-x86_64-pc-windows-gnu" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "712e227841d057c1ee1cd2fb22fa7e5a5461ae8e48fa2ca79ec42cfc1931183f" + [[package]] name = "windows-link" version = "0.2.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f0805222e57f7521d6a62e36fa9163bc891acd422f971defe97d64e70d0a4fe5" +[[package]] +name = "windows-sys" +version = "0.59.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1e38bc4d79ed67fd075bcc251a1c39b32a1776bbe92e5bef1f0bf1f8c531853b" +dependencies = [ + "windows-targets", +] + [[package]] name = "windows-sys" version = "0.61.2" @@ -419,3 +860,67 @@ checksum = "ae137229bcbd6cdf0f7b80a31df61766145077ddf49416a728b02cb3921ff3fc" dependencies = [ "windows-link", ] + +[[package]] +name = "windows-targets" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9b724f72796e036ab90c1021d4780d4d3d648aca59e491e6b98e725b84e99973" +dependencies = [ + "windows_aarch64_gnullvm", + "windows_aarch64_msvc", + "windows_i686_gnu", + "windows_i686_gnullvm", + "windows_i686_msvc", + "windows_x86_64_gnu", + "windows_x86_64_gnullvm", + "windows_x86_64_msvc", +] + +[[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.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "09ec2a7bb152e2252b53fa7803150007879548bc709c039df7627cabbd05d469" + +[[package]] +name = "windows_i686_gnu" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8e9b5ad5ab802e97eb8e295ac6720e509ee4c243f69d781394014ebfe8bbfa0b" + +[[package]] +name = "windows_i686_gnullvm" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0eee52d38c090b3caa76c563b86c3a4bd71ef1a819287c19d586d7334ae8ed66" + +[[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.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "147a5c80aabfbf0c7d901cb5895d1de30ef2907eb21fbbab29ca94c5b08b1a78" + +[[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.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "589f6da84c646204747d1270a2a5661ea66ed1cced2631d546fdfb155959f9ec" diff --git a/README.md b/README.md index 3878e87..5eb85a6 100644 --- a/README.md +++ b/README.md @@ -13,7 +13,7 @@ This repository currently provides: - `irtt-cli`, a command-line IRTT-compatible client - `irtt-client`, a Rust library for running client sessions and consuming events - finite and continuous probe runs -- human, simple, machine-readable, and RTT-only output modes +- human, simple, machine-readable, RTT-only, and optional TUI output modes - optional local summary statistics Server support is not implemented. @@ -65,6 +65,14 @@ irtt-cli --output machine irtt-cli --output rtt-us ``` +With the optional TUI feature, `--output tui` opens a live cumulative and +rolling dashboard for interactive probing. Quit with `q` or `Ctrl-C`; the client +will drain and close the session gracefully. + +```sh +cargo run -p irtt-cli --features tui -- --output tui +``` + For available options: ```sh diff --git a/crates/irtt-cli/Cargo.toml b/crates/irtt-cli/Cargo.toml index 363dff3..f68c32a 100644 --- a/crates/irtt-cli/Cargo.toml +++ b/crates/irtt-cli/Cargo.toml @@ -17,10 +17,13 @@ doc = false [features] default = ["stats"] stats = ["dep:irtt-stats"] +tui = ["stats", "dep:ratatui", "dep:crossterm"] [dependencies] clap = { version = "4.6.1", features = ["derive"] } +crossterm = { version = "0.28.1", optional = true } ctrlc = "3.5.2" irtt-client.workspace = true irtt-proto.workspace = true irtt-stats = { workspace = true, optional = true } +ratatui = { version = "0.29.0", optional = true } diff --git a/crates/irtt-cli/src/lib.rs b/crates/irtt-cli/src/lib.rs index 953f05e..d7f152d 100644 --- a/crates/irtt-cli/src/lib.rs +++ b/crates/irtt-cli/src/lib.rs @@ -89,7 +89,7 @@ pub struct CliArgs { #[arg(long)] pub loose: bool, - /// Output format: human, simple, machine, or rtt-us. + /// Output format. #[arg(long, value_enum, default_value_t = OutputMode::Human)] pub output: OutputMode, @@ -201,6 +201,9 @@ pub enum OutputMode { Simple, /// RTT microseconds only. RttUs, + /// TUI dashboard with live cumulative statistics. + #[cfg(feature = "tui")] + Tui, } impl OutputMode { @@ -320,6 +323,8 @@ pub fn format_event(event: &ClientEvent, mode: OutputMode) -> Option { OutputMode::Human => Some(format_human_event(event, None)), OutputMode::Machine => Some(format_machine(event)), OutputMode::Simple => Some(format_simple(event)), + #[cfg(feature = "tui")] + OutputMode::Tui => None, } } diff --git a/crates/irtt-cli/src/main.rs b/crates/irtt-cli/src/main.rs index de2353b..9766ac7 100644 --- a/crates/irtt-cli/src/main.rs +++ b/crates/irtt-cli/src/main.rs @@ -18,6 +18,11 @@ use irtt_client::{Client, ClientEvent, OpenOutcome, RecvBudget}; #[cfg(all(test, feature = "stats"))] use irtt_client::ClientTimestamp; +#[cfg(feature = "tui")] +mod tui; +#[cfg(feature = "tui")] +use tui::run_tui; + #[cfg(feature = "stats")] use irtt_stats::{StatsCollector, StatsConfig}; @@ -66,6 +71,8 @@ fn run(args: CliArgs, shutdown_requested: &AtomicBool) -> Result<(), Box { run_stream(args, shutdown_requested) } + #[cfg(feature = "tui")] + OutputMode::Tui => run_tui(args, shutdown_requested), } } diff --git a/crates/irtt-cli/src/tui.rs b/crates/irtt-cli/src/tui.rs new file mode 100644 index 0000000..02f0bbf --- /dev/null +++ b/crates/irtt-cli/src/tui.rs @@ -0,0 +1,1676 @@ +use std::{ + collections::VecDeque, + io::{self, Stdout}, + sync::atomic::{AtomicBool, Ordering}, + time::{Duration, Instant}, +}; + +use crossterm::{ + cursor::Show, + event::{self, Event, KeyCode, KeyEventKind, KeyModifiers}, + execute, + terminal::{disable_raw_mode, enable_raw_mode, EnterAlternateScreen, LeaveAlternateScreen}, +}; +use irtt_cli::CliArgs; +use irtt_client::{Client, ClientEvent, NegotiatedParams, SignedDuration}; +use irtt_stats::{Snapshot, StatsCollector, TimeStats}; +use ratatui::{ + backend::CrosstermBackend, + layout::{Constraint, Direction, Layout, Rect}, + style::{Color, Modifier, Style}, + symbols, + text::{Line, Span}, + widgets::{Axis, Block, Borders, Chart, Dataset, GraphType, Paragraph, Wrap}, + Frame, Terminal, +}; + +use crate::{ + final_drain_duration, is_shutdown_requested, open_event, should_continue_run, + should_drain_final, should_send_probe, stats_config, IDLE_SLEEP, RECV_BUDGET, +}; + +const RENDER_INTERVAL: Duration = Duration::from_millis(250); +const TUI_WAIT_SLICE: Duration = Duration::from_millis(20); +const HISTORY_LIMIT: usize = 240; +const RECENT_EVENT_LIMIT: usize = 80; +const MIN_CHART_POINTS: usize = 12; +const MIN_WIDTH: u16 = 56; +const MIN_HEIGHT: u16 = 18; + +pub fn run_tui( + args: CliArgs, + shutdown_requested: &AtomicBool, +) -> Result<(), Box> { + let continuous = args.is_continuous(); + let mut terminal = TuiTerminal::enter()?; + let mut state = TuiState::new(TuiConfig::from_args(&args)); + let mut stats = StatsCollector::new(stats_config(continuous)); + let mut next_render = Instant::now(); + + if is_shutdown_requested(shutdown_requested) { + return Ok(()); + } + + state.set_status(TuiStatus::Opening); + render_if_due(&mut terminal, &state, &stats, &mut next_render, true)?; + + let mut client = match Client::connect(args.to_client_config()) { + Ok(client) => client, + Err(err) => { + state.set_error(err.to_string()); + render_if_due(&mut terminal, &state, &stats, &mut next_render, true)?; + return Err(Box::new(err)); + } + }; + + if is_shutdown_requested(shutdown_requested) { + return Ok(()); + } + + let open = client.open()?; + state.process_event(open_event(&open), &mut stats); + render_if_due(&mut terminal, &state, &stats, &mut next_render, true)?; + + let mut interrupted = false; + // Keep this in lockstep with run_stream: send due probes, drain available + // replies, poll timeouts, sleep toward the next absolute send deadline, + // then perform the same final drain, timeout poll, and close sequence. + while should_continue_run(continuous, client.next_send_deadline(), shutdown_requested) { + if handle_input(&mut state, shutdown_requested)? { + render_if_due(&mut terminal, &state, &stats, &mut next_render, true)?; + } + if state.quit_requested { + interrupted = true; + break; + } + + if should_send_probe(client.next_send_deadline(), shutdown_requested) { + let events = client.send_probe()?; + state.process_events(&events, &mut stats); + } + + let events = client.recv_available(RECV_BUDGET)?; + state.process_events(&events, &mut stats); + + let events = client.poll_timeouts()?; + state.process_events(&events, &mut stats); + + if is_shutdown_requested(shutdown_requested) { + interrupted = true; + break; + } + + render_if_due(&mut terminal, &state, &stats, &mut next_render, false)?; + wait_for_tui_activity( + client.next_send_deadline(), + &mut next_render, + &mut state, + &stats, + &mut terminal, + shutdown_requested, + )?; + } + interrupted |= is_shutdown_requested(shutdown_requested); + + if interrupted { + state.set_status(TuiStatus::Interrupted); + render_if_due(&mut terminal, &state, &stats, &mut next_render, true)?; + } + + if should_drain_final(continuous, interrupted) { + drain_final_replies(&mut client, &mut state, &mut stats, &mut terminal)?; + } + + let events = client.poll_timeouts()?; + state.process_events(&events, &mut stats); + + state.set_status(TuiStatus::Closing); + render_if_due(&mut terminal, &state, &stats, &mut next_render, true)?; + + let events = client.close()?; + state.process_events(&events, &mut stats); + state.set_status(TuiStatus::Complete); + render_if_due(&mut terminal, &state, &stats, &mut next_render, true)?; + Ok(()) +} + +fn drain_final_replies( + client: &mut Client, + state: &mut TuiState, + stats: &mut StatsCollector, + terminal: &mut TuiTerminal, +) -> Result<(), Box> { + state.set_status(TuiStatus::Draining); + let deadline = Instant::now() + final_drain_duration(client.probe_timeout()); + let mut next_render = Instant::now(); + loop { + let mut received = false; + + let events = client.recv_available(RECV_BUDGET)?; + received |= !events.is_empty(); + state.process_events(&events, stats); + + let events = client.poll_timeouts()?; + received |= !events.is_empty(); + state.process_events(&events, stats); + + render_if_due(terminal, state, stats, &mut next_render, false)?; + + if client.is_run_complete() || Instant::now() >= deadline { + break; + } + + if !received { + std::thread::sleep(IDLE_SLEEP); + } + } + render_if_due(terminal, state, stats, &mut next_render, true)?; + Ok(()) +} + +fn handle_input(state: &mut TuiState, shutdown_requested: &AtomicBool) -> io::Result { + let mut force_render = false; + while event::poll(Duration::ZERO)? { + let Event::Key(key) = event::read()? else { + continue; + }; + if key.kind == KeyEventKind::Release { + continue; + } + match key.code { + KeyCode::Char('q') => { + state.quit_requested = true; + shutdown_requested.store(true, Ordering::Relaxed); + force_render = true; + } + KeyCode::Char('c') if key.modifiers.contains(KeyModifiers::CONTROL) => { + state.quit_requested = true; + shutdown_requested.store(true, Ordering::Relaxed); + force_render = true; + } + KeyCode::Char('r') => { + state.clear_visible_history(); + force_render = true; + } + KeyCode::Char('p') => { + state.toggle_pause(); + force_render = true; + } + KeyCode::Char('g') => { + state.cycle_graph_mode(); + force_render = true; + } + KeyCode::Char('f') => { + state.toggle_full_graph(); + force_render = true; + } + _ => {} + } + } + Ok(force_render) +} + +fn render_if_due( + terminal: &mut TuiTerminal, + state: &TuiState, + stats: &StatsCollector, + next_render: &mut Instant, + force: bool, +) -> io::Result<()> { + let now = Instant::now(); + if !should_render(now, *next_render, state.paused, force) { + return Ok(()); + } + terminal.draw(state, &stats.snapshot())?; + *next_render = now + RENDER_INTERVAL; + Ok(()) +} + +fn wait_for_tui_activity( + next_send_deadline: Option, + next_render: &mut Instant, + state: &mut TuiState, + stats: &StatsCollector, + terminal: &mut TuiTerminal, + shutdown_requested: &AtomicBool, +) -> io::Result<()> { + let wait_for = tui_wait_duration(next_send_deadline, *next_render, state.paused); + if wait_for.is_zero() || !event::poll(wait_for)? { + return Ok(()); + } + + if handle_input(state, shutdown_requested)? { + render_if_due(terminal, state, stats, next_render, true)?; + } + Ok(()) +} + +fn tui_wait_duration( + next_send_deadline: Option, + next_render: Instant, + paused: bool, +) -> Duration { + let now = Instant::now(); + let send_wait = next_send_deadline + .map(|deadline| deadline.saturating_duration_since(now)) + .unwrap_or(IDLE_SLEEP); + let render_wait = if paused { + send_wait + } else { + next_render.saturating_duration_since(now) + }; + send_wait.min(render_wait).min(TUI_WAIT_SLICE) +} + +fn should_render(now: Instant, next_render: Instant, paused: bool, force: bool) -> bool { + force || (!paused && now >= next_render) +} + +struct TuiTerminal { + terminal: Terminal>, +} + +impl TuiTerminal { + fn enter() -> io::Result { + enable_raw_mode()?; + let mut stdout = io::stdout(); + if let Err(err) = execute!(stdout, EnterAlternateScreen) { + let _ = disable_raw_mode(); + return Err(err); + } + + let backend = CrosstermBackend::new(stdout); + match Terminal::new(backend) { + Ok(mut terminal) => { + if let Err(err) = terminal.clear() { + let _ = disable_raw_mode(); + let _ = execute!(terminal.backend_mut(), LeaveAlternateScreen, Show); + let _ = terminal.show_cursor(); + return Err(err); + } + Ok(Self { terminal }) + } + Err(err) => { + let _ = disable_raw_mode(); + let mut stdout = io::stdout(); + let _ = execute!(stdout, LeaveAlternateScreen, Show); + Err(err) + } + } + } + + fn draw(&mut self, state: &TuiState, snapshot: &Snapshot) -> io::Result<()> { + self.terminal + .draw(|frame| draw_dashboard(frame, state, snapshot)) + .map(|_| ()) + } +} + +impl Drop for TuiTerminal { + fn drop(&mut self) { + let _ = disable_raw_mode(); + let _ = execute!(self.terminal.backend_mut(), LeaveAlternateScreen, Show); + let _ = self.terminal.show_cursor(); + } +} + +#[derive(Debug)] +struct TuiState { + remote: Option, + session: Option, + status: TuiStatus, + started_at: Instant, + config: TuiConfig, + negotiated: Option, + recent_events: VecDeque, + graph_history: VecDeque, + last_sample: Option, + last_warning: Option, + graph_mode: GraphMode, + full_graph: bool, + paused: bool, + quit_requested: bool, +} + +impl TuiState { + fn new(config: TuiConfig) -> Self { + Self { + remote: None, + session: None, + status: TuiStatus::Opening, + started_at: Instant::now(), + config, + negotiated: None, + recent_events: VecDeque::with_capacity(RECENT_EVENT_LIMIT), + graph_history: VecDeque::with_capacity(HISTORY_LIMIT), + last_sample: None, + last_warning: None, + graph_mode: GraphMode::Rtt, + full_graph: false, + paused: false, + quit_requested: false, + } + } + + fn process_events(&mut self, events: &[ClientEvent], stats: &mut StatsCollector) { + for event in events { + self.process_event(event, stats); + } + } + + fn process_event(&mut self, event: &ClientEvent, stats: &mut StatsCollector) { + stats.process(event); + match event { + ClientEvent::SessionStarted { + remote, + token, + negotiated, + .. + } => { + self.remote = Some(remote.to_string()); + self.session = Some(format!("{token:#x}")); + self.negotiated = Some(negotiated.clone()); + self.set_status(TuiStatus::Running); + self.push_event(format!("session started token={token:#x}")); + } + ClientEvent::NoTestCompleted { + remote, negotiated, .. + } => { + self.remote = Some(remote.to_string()); + self.negotiated = Some(negotiated.clone()); + self.set_status(TuiStatus::Complete); + self.push_event("no-test negotiation completed".to_owned()); + } + ClientEvent::SessionClosed { token, .. } => { + self.session = Some(format!("{token:#x}")); + self.set_status(TuiStatus::Complete); + self.push_event(format!("session closed token={token:#x}")); + } + ClientEvent::EchoSent { seq, bytes, .. } => { + self.push_event(format!("sent seq={} bytes={bytes}", format_seq(*seq))); + } + ClientEvent::EchoReply { + seq, + rtt, + one_way, + server_timing, + .. + } => { + let client_to_server_ns = one_way + .and_then(|sample| sample.client_to_server) + .map(SignedDuration::as_nanos); + let server_to_client_ns = one_way + .and_then(|sample| sample.server_to_client) + .map(SignedDuration::as_nanos); + let server_processing_ns = server_timing + .and_then(|timing| timing.processing) + .map(duration_ns); + self.push_graph_sample(GraphSample { + seq: *seq, + effective_ns: rtt.effective.as_nanos(), + raw_ns: duration_ns(rtt.raw), + adjusted_ns: rtt.adjusted.map(SignedDuration::as_nanos), + client_to_server_ns, + server_to_client_ns, + server_processing_ns, + }); + self.last_sample = Some(LastSample { + seq: *seq, + raw_ns: duration_ns(rtt.raw), + adjusted_ns: rtt.adjusted.map(SignedDuration::as_nanos), + effective_ns: rtt.effective.as_nanos(), + client_to_server_ns, + server_to_client_ns, + server_processing_ns, + }); + self.push_event(format!( + "reply seq={} effective={}", + format_seq(*seq), + format_ns_i128(Some(rtt.effective.as_nanos())) + )); + } + ClientEvent::EchoLoss { seq, .. } => { + self.push_event(format!("loss seq={}", format_seq(*seq))); + } + ClientEvent::DuplicateReply { seq, remote, .. } => { + self.push_event(format!("duplicate seq={} from {remote}", format_seq(*seq))); + } + ClientEvent::LateReply { + seq, + highest_seen, + rtt, + .. + } => { + let timing = rtt + .map(|sample| { + format!( + " effective={}", + format_ns_i128(Some(sample.effective.as_nanos())) + ) + }) + .unwrap_or_default(); + self.push_event(format!( + "late seq={} highest_seen={}{}", + format_seq(*seq), + format_seq(*highest_seen), + timing + )); + } + ClientEvent::Warning { kind, message, .. } => { + let warning = format!("{kind:?}: {message}"); + self.last_warning = Some(warning.clone()); + self.push_event(format!("warning {warning}")); + } + } + } + + fn set_status(&mut self, status: TuiStatus) { + self.status = status; + } + + fn set_error(&mut self, message: String) { + self.status = TuiStatus::Error; + self.last_warning = Some(message.clone()); + self.push_event(format!("error {message}")); + } + + fn clear_visible_history(&mut self) { + self.graph_history.clear(); + self.push_event("visible graph history reset".to_owned()); + } + + fn toggle_pause(&mut self) { + self.paused = !self.paused; + } + + fn cycle_graph_mode(&mut self) { + self.graph_mode = self.graph_mode.next(); + } + + fn toggle_full_graph(&mut self) { + self.full_graph = !self.full_graph; + } + + fn push_graph_sample(&mut self, sample: GraphSample) { + push_bounded(&mut self.graph_history, sample, HISTORY_LIMIT); + } + + fn push_event(&mut self, event: String) { + push_bounded(&mut self.recent_events, event, RECENT_EVENT_LIMIT); + } +} + +impl Default for TuiState { + fn default() -> Self { + Self::new(TuiConfig::default()) + } +} + +#[derive(Debug, Clone)] +struct TuiConfig { + interval: Duration, + duration: Option, + timeout: Duration, + target_probes: Option, +} + +impl TuiConfig { + fn from_args(args: &CliArgs) -> Self { + Self { + interval: args.interval, + duration: (!args.is_continuous()).then_some(args.duration), + timeout: args.to_client_config().probe_timeout, + target_probes: (!args.is_continuous()) + .then(|| expected_probe_count(args.duration, args.interval)), + } + } +} + +impl Default for TuiConfig { + fn default() -> Self { + Self { + interval: Duration::from_secs(1), + duration: Some(Duration::from_secs(10)), + timeout: Duration::from_secs(2), + target_probes: Some(10), + } + } +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +enum TuiStatus { + Opening, + Running, + Draining, + Interrupted, + Closing, + Complete, + Error, +} + +impl TuiStatus { + fn label(self) -> &'static str { + match self { + Self::Opening => "opening", + Self::Running => "running", + Self::Draining => "draining", + Self::Interrupted => "interrupted", + Self::Closing => "closing", + Self::Complete => "complete", + Self::Error => "error", + } + } +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +enum GraphMode { + Rtt, + OneWay, + Combined, + Split, +} + +impl GraphMode { + fn next(self) -> Self { + match self { + Self::Rtt => Self::OneWay, + Self::OneWay => Self::Combined, + Self::Combined => Self::Split, + Self::Split => Self::Rtt, + } + } + + fn label(self) -> &'static str { + match self { + Self::Rtt => "RTT", + Self::OneWay => "one-way", + Self::Combined => "combined", + Self::Split => "split", + } + } +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +struct GraphSample { + seq: u32, + effective_ns: i128, + raw_ns: i128, + adjusted_ns: Option, + client_to_server_ns: Option, + server_to_client_ns: Option, + server_processing_ns: Option, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +struct LastSample { + seq: u32, + raw_ns: i128, + adjusted_ns: Option, + effective_ns: i128, + client_to_server_ns: Option, + server_to_client_ns: Option, + server_processing_ns: Option, +} + +fn push_bounded(items: &mut VecDeque, item: T, limit: usize) { + if items.len() == limit { + items.pop_front(); + } + items.push_back(item); +} + +fn expected_probe_count(duration: Duration, interval: Duration) -> u64 { + let interval_nanos = interval.as_nanos(); + if interval_nanos == 0 { + return u64::MAX; + } + + let expected = duration + .as_nanos() + .saturating_add(interval_nanos.saturating_sub(1)) + / interval_nanos; + expected.min(u128::from(u64::MAX)) as u64 +} + +fn draw_dashboard(frame: &mut Frame<'_>, state: &TuiState, snapshot: &Snapshot) { + let area = frame.area(); + if area.width < MIN_WIDTH || area.height < MIN_HEIGHT { + frame.render_widget(too_small(), area); + return; + } + + if state.full_graph { + draw_full_graph(frame, area, state, snapshot); + return; + } + + if area.width >= 110 && area.height >= 32 { + draw_large(frame, area, state, snapshot); + } else { + draw_compact(frame, area, state, snapshot); + } +} + +fn draw_full_graph(frame: &mut Frame<'_>, area: Rect, state: &TuiState, snapshot: &Snapshot) { + let rows = Layout::default() + .direction(Direction::Vertical) + .constraints([ + Constraint::Length(3), + Constraint::Min(10), + Constraint::Length(3), + ]) + .split(area); + + frame.render_widget(full_graph_header(state, snapshot), rows[0]); + render_graph_area(frame, rows[1], state); + frame.render_widget(status_line(state), rows[2]); +} + +fn draw_large(frame: &mut Frame<'_>, area: Rect, state: &TuiState, snapshot: &Snapshot) { + let rows = Layout::default() + .direction(Direction::Vertical) + .constraints([ + Constraint::Length(8), + Constraint::Min(12), + Constraint::Length(9), + Constraint::Length(3), + ]) + .split(area); + let top = Layout::default() + .direction(Direction::Horizontal) + .constraints([Constraint::Percentage(40), Constraint::Percentage(60)]) + .split(rows[0]); + let middle = Layout::default() + .direction(Direction::Horizontal) + .constraints([Constraint::Percentage(36), Constraint::Percentage(64)]) + .split(rows[1]); + let bottom = Layout::default() + .direction(Direction::Horizontal) + .constraints([Constraint::Percentage(55), Constraint::Percentage(45)]) + .split(rows[2]); + + frame.render_widget(header(state, HeaderDensity::Large), top[0]); + frame.render_widget(packet_panel(state, snapshot), top[1]); + frame.render_widget(timing_panel(snapshot), middle[0]); + render_graph_area(frame, middle[1], state); + frame.render_widget(recent_events_panel(state, bottom[0].height), bottom[0]); + frame.render_widget(sample_panel(state, snapshot), bottom[1]); + frame.render_widget(status_line(state), rows[3]); +} + +fn draw_compact(frame: &mut Frame<'_>, area: Rect, state: &TuiState, snapshot: &Snapshot) { + let rows = Layout::default() + .direction(Direction::Vertical) + .constraints([ + Constraint::Length(6), + Constraint::Length(7), + Constraint::Min(7), + Constraint::Length(3), + ]) + .split(area); + + frame.render_widget(header(state, HeaderDensity::Compact), rows[0]); + frame.render_widget(packet_panel(state, snapshot), rows[1]); + render_graph_area(frame, rows[2], state); + frame.render_widget(status_line(state), rows[3]); +} + +fn too_small() -> Paragraph<'static> { + Paragraph::new(vec![ + Line::from("terminal too small"), + Line::from("resize, or press q / Ctrl-C to quit gracefully"), + ]) + .block(Block::default().title("irtt-rs").borders(Borders::ALL)) + .wrap(Wrap { trim: true }) +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +enum HeaderDensity { + Large, + Compact, +} + +fn header(state: &TuiState, density: HeaderDensity) -> Paragraph<'_> { + let remote = state.remote.as_deref().unwrap_or("-"); + let session = state.session.as_deref().unwrap_or("-"); + let elapsed = format_duration(state.started_at.elapsed()); + let duration = format_optional_duration(state.config.duration); + let mode = if state.config.duration.is_some() { + "finite" + } else { + "continuous" + }; + let negotiated = state + .negotiated + .as_ref() + .map(|params| format!("negotiated: {}", format_negotiated(params))) + .unwrap_or_else(|| "negotiated: -".to_owned()); + + let mut lines = vec![ + Line::from(vec![ + Span::styled("irtt-rs", Style::default().add_modifier(Modifier::BOLD)), + Span::raw(format!(" status: {}", state.status.label())), + ]), + Line::from(format!("remote: {remote}")), + ]; + match density { + HeaderDensity::Large => { + lines.extend([ + Line::from(format!("session: {session}")), + Line::from(format!( + "mode: {mode} elapsed: {elapsed} duration: {duration}" + )), + Line::from(format!( + "interval: {} timeout: {}", + format_duration(state.config.interval), + format_duration(state.config.timeout) + )), + Line::from(negotiated), + ]); + } + HeaderDensity::Compact => { + lines.extend([ + Line::from(format!("session: {session}")), + Line::from(format!( + "{mode} elapsed: {elapsed} interval: {}", + format_duration(state.config.interval) + )), + ]); + } + } + + Paragraph::new(lines) + .block(Block::default().title("session").borders(Borders::ALL)) + .wrap(Wrap { trim: true }) +} + +fn full_graph_header(state: &TuiState, snapshot: &Snapshot) -> Paragraph<'static> { + let remote = state.remote.as_deref().unwrap_or("-"); + let elapsed = format_duration(state.started_at.elapsed()); + let packets = snapshot.packets; + let last = state + .last_sample + .map(|sample| format_ns_i128(Some(sample.effective_ns))) + .unwrap_or_else(|| "-".to_owned()); + + Paragraph::new(Line::from(format!( + "irtt-rs {} remote {remote} elapsed {elapsed} sent {} replies {} last {last}", + state.status.label(), + format_count(packets.packets_sent), + format_count(packets.unique_replies) + ))) + .block(Block::default().title("session").borders(Borders::ALL)) +} + +fn packet_panel(state: &TuiState, snapshot: &Snapshot) -> Paragraph<'static> { + let packets = snapshot.packets; + let loss = snapshot.loss; + let progress = state + .config + .target_probes + .map(|target| { + format!( + "{}/{} ({})", + packets.packets_sent, + target, + format_percent_ratio(packets.packets_sent, target) + ) + }) + .unwrap_or_else(|| "-".to_owned()); + let elapsed = state.started_at.elapsed(); + let recv_rate = format_rate(packets.unique_replies, elapsed); + + Paragraph::new(vec![ + Line::from(format!( + "sent {:>8} received {:>8} unique {:>8}", + format_count(packets.packets_sent), + format_count(packets.packets_received), + format_count(packets.unique_replies) + )), + Line::from(format!( + "lost {:>8} duplicates {:>6} late {:>10}", + format_count(loss.lost_packets), + format_count(packets.duplicates), + format_count(packets.late_packets) + )), + Line::from(format!( + "loss {:>8} reply rate {:>10} progress {progress}", + format_percent(loss.packet_loss_percent), + recv_rate + )), + Line::from(format!( + "bytes sent {} received {}", + format_count(packets.bytes_sent), + format_count(packets.bytes_received) + )), + Line::from(format!( + "server received {} window {}", + format_optional_u64(packets.server_packets_received), + format_optional_hex(packets.server_received_window) + )), + ]) + .block(Block::default().title("packets").borders(Borders::ALL)) + .wrap(Wrap { trim: true }) +} + +fn timing_panel(snapshot: &Snapshot) -> Paragraph<'_> { + let mut lines = vec![Line::from(format!( + "{:<18} {:>5} {:>9} {:>9} {:>9} {:>9}", + "metric", "n", "min", "mean", "max", "stddev" + ))]; + push_time_line(&mut lines, "effective RTT", &snapshot.rtt.primary); + push_time_line(&mut lines, "raw RTT", &snapshot.rtt.raw); + push_time_line(&mut lines, "adjusted RTT", &snapshot.rtt.adjusted); + push_time_line(&mut lines, "IPDV/jitter", &snapshot.ipdv.round_trip); + push_time_line(&mut lines, "send IPDV", &snapshot.ipdv.send); + push_time_line(&mut lines, "receive IPDV", &snapshot.ipdv.receive); + push_time_line(&mut lines, "send delay", &snapshot.one_way_delay.send_delay); + push_time_line( + &mut lines, + "receive delay", + &snapshot.one_way_delay.receive_delay, + ); + push_time_line( + &mut lines, + "server process", + &snapshot.server_processing.processing, + ); + push_time_line(&mut lines, "send call", &snapshot.send_call); + push_time_line(&mut lines, "timer error", &snapshot.timer_error); + + Paragraph::new(lines) + .block(Block::default().title("timing").borders(Borders::ALL)) + .wrap(Wrap { trim: false }) +} + +fn render_graph_area(frame: &mut Frame<'_>, area: Rect, state: &TuiState) { + match state.graph_mode { + GraphMode::Split => render_split_graph(frame, area, state), + mode => { + let visible = visible_history_window(&state.graph_history, area.width); + let series = graph_series(mode, &visible); + render_chart(frame, area, mode.title(), &visible, &series); + } + } +} + +fn render_split_graph(frame: &mut Frame<'_>, area: Rect, state: &TuiState) { + let rows = Layout::default() + .direction(Direction::Vertical) + .constraints([Constraint::Percentage(50), Constraint::Percentage(50)]) + .split(area); + let visible = visible_history_window(&state.graph_history, area.width); + let rtt = graph_series(GraphMode::Rtt, &visible); + let one_way = graph_series(GraphMode::OneWay, &visible); + + render_chart(frame, rows[0], "RTT history", &visible, &rtt); + render_chart(frame, rows[1], "one-way delay history", &visible, &one_way); +} + +fn render_chart( + frame: &mut Frame<'_>, + area: Rect, + title: &'static str, + visible: &[&GraphSample], + series: &[ChartSeries], +) { + if series.is_empty() { + let note = if visible.is_empty() { + "waiting for primary replies" + } else { + "n/a for negotiated timestamps/stats" + }; + frame.render_widget( + Paragraph::new(note) + .block(Block::default().title(title).borders(Borders::ALL)) + .wrap(Wrap { trim: true }), + area, + ); + return; + } + + let (min_y, max_y) = chart_y_bounds(series); + let x_max = visible.len().saturating_sub(1).max(1) as f64; + let datasets = chart_datasets(series); + let chart = Chart::new(datasets) + .block(Block::default().title(title).borders(Borders::ALL)) + .x_axis( + Axis::default() + .bounds([0.0, x_max]) + .labels(x_axis_labels(visible)) + .style(Style::default().fg(Color::Gray)), + ) + .y_axis( + Axis::default() + .bounds([min_y, max_y]) + .labels(vec![ + Span::raw(format_ms_label(min_y)), + Span::raw(format_ms_label(max_y)), + ]) + .style(Style::default().fg(Color::Gray)), + ); + frame.render_widget(chart, area); +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +enum GraphValue { + EffectiveRtt, + RawRtt, + AdjustedRtt, + ClientToServer, + ServerToClient, + ServerProcessing, +} + +impl GraphValue { + fn name(self) -> &'static str { + match self { + Self::EffectiveRtt => "eff RTT", + Self::RawRtt => "raw RTT", + Self::AdjustedRtt => "adj RTT", + Self::ClientToServer => "c2s", + Self::ServerToClient => "s2c", + Self::ServerProcessing => "srv proc", + } + } + + fn style(self) -> Style { + match self { + Self::EffectiveRtt => Style::default() + .fg(Color::Cyan) + .add_modifier(Modifier::BOLD), + Self::RawRtt => Style::default().fg(Color::Yellow), + Self::AdjustedRtt => Style::default().fg(Color::Green), + Self::ClientToServer => Style::default().fg(Color::Magenta), + Self::ServerToClient => Style::default().fg(Color::LightBlue), + Self::ServerProcessing => Style::default().fg(Color::LightRed), + } + } + + fn value_ns(self, sample: &GraphSample) -> Option { + match self { + Self::EffectiveRtt => Some(sample.effective_ns), + Self::RawRtt => Some(sample.raw_ns), + Self::AdjustedRtt => sample.adjusted_ns, + Self::ClientToServer => sample.client_to_server_ns, + Self::ServerToClient => sample.server_to_client_ns, + Self::ServerProcessing => sample.server_processing_ns, + } + } +} + +impl GraphMode { + fn title(self) -> &'static str { + match self { + Self::Rtt => "RTT history", + Self::OneWay => "one-way delay history", + Self::Combined => "RTT + one-way history", + Self::Split => "split history", + } + } +} + +#[derive(Debug, Clone, PartialEq)] +struct ChartSeries { + value: GraphValue, + data: Vec<(f64, f64)>, +} + +fn visible_sample_capacity(chart_width: u16) -> usize { + let derived = usize::from(chart_width.saturating_sub(10)).max(MIN_CHART_POINTS); + derived.min(HISTORY_LIMIT) +} + +fn visible_history_window(history: &VecDeque, chart_width: u16) -> Vec<&GraphSample> { + let capacity = visible_sample_capacity(chart_width); + let start = history.len().saturating_sub(capacity); + history.iter().skip(start).collect() +} + +fn graph_series(mode: GraphMode, visible: &[&GraphSample]) -> Vec { + let values: &[GraphValue] = match mode { + GraphMode::Rtt => &[ + GraphValue::EffectiveRtt, + GraphValue::RawRtt, + GraphValue::AdjustedRtt, + ], + GraphMode::OneWay => &[ + GraphValue::ClientToServer, + GraphValue::ServerToClient, + GraphValue::ServerProcessing, + ], + GraphMode::Combined => &[ + GraphValue::EffectiveRtt, + GraphValue::ClientToServer, + GraphValue::ServerToClient, + ], + GraphMode::Split => &[], + }; + values + .iter() + .filter_map(|value| chart_series(*value, visible)) + .collect() +} + +fn chart_series(value: GraphValue, visible: &[&GraphSample]) -> Option { + let data: Vec<(f64, f64)> = visible + .iter() + .enumerate() + .filter_map(|(idx, sample)| { + value + .value_ns(sample) + .map(|ns| (idx as f64, ns as f64 / 1_000_000.0)) + }) + .collect(); + + (!data.is_empty()).then_some(ChartSeries { value, data }) +} + +fn chart_datasets(series: &[ChartSeries]) -> Vec> { + series + .iter() + .map(|series| { + Dataset::default() + .name(series.value.name()) + .marker(symbols::Marker::Braille) + .graph_type(GraphType::Line) + .style(series.value.style()) + .data(&series.data) + }) + .collect() +} + +fn chart_y_bounds(series: &[ChartSeries]) -> (f64, f64) { + let mut values = series.iter().flat_map(|series| { + series + .data + .iter() + .map(|(_, value)| *value) + .filter(|value| value.is_finite()) + }); + let Some(first) = values.next() else { + return (0.0, 1.0); + }; + + let (mut min_y, mut max_y) = (first, first); + for value in values { + min_y = min_y.min(value); + max_y = max_y.max(value); + } + + let span = max_y - min_y; + let pad = if span <= f64::EPSILON { + (max_y.abs() * 0.05).max(1.0) + } else { + (span * 0.1).max(1.0) + }; + min_y -= pad; + max_y += pad; + if min_y >= max_y { + (min_y - 1.0, max_y + 1.0) + } else { + (min_y, max_y) + } +} + +fn x_axis_labels(visible: &[&GraphSample]) -> Vec> { + let Some(first) = visible.first() else { + return Vec::new(); + }; + let last = visible.last().unwrap_or(first); + vec![ + Span::raw(format!("#{}", first.seq)), + Span::raw(format!("#{}", last.seq)), + ] +} + +fn format_ms_label(value: f64) -> String { + if value.abs() < 10.0 { + format!("{value:.2}ms") + } else if value.abs() < 100.0 { + format!("{value:.1}ms") + } else { + format!("{value:.0}ms") + } +} + +fn recent_events_panel(state: &TuiState, panel_height: u16) -> Paragraph<'_> { + let lines: Vec> = state + .recent_events + .iter() + .rev() + .take(recent_events_visible_count(panel_height)) + .map(|event| Line::from(event.as_str())) + .collect(); + Paragraph::new(lines) + .block( + Block::default() + .title("recent events") + .borders(Borders::ALL), + ) + .wrap(Wrap { trim: true }) +} + +fn recent_events_visible_count(panel_height: u16) -> usize { + usize::from(panel_height.saturating_sub(2)) +} + +fn sample_panel(state: &TuiState, snapshot: &Snapshot) -> Paragraph<'static> { + let last = state.last_sample; + let warning = state.last_warning.clone().unwrap_or_else(|| "-".to_owned()); + Paragraph::new(vec![ + Line::from(format!( + "last seq: {}", + last.map(|sample| format_seq(sample.seq)) + .unwrap_or_else(|| "-".to_owned()) + )), + Line::from(format!( + "raw / adjusted / effective: {} / {} / {}", + last.map(|sample| format_ns_i128(Some(sample.raw_ns))) + .unwrap_or_else(|| "-".to_owned()), + last.map(|sample| format_ns_i128(sample.adjusted_ns)) + .unwrap_or_else(|| "-".to_owned()), + last.map(|sample| format_ns_i128(Some(sample.effective_ns))) + .unwrap_or_else(|| "-".to_owned()) + )), + Line::from(format!( + "one-way c2s / s2c: {} / {}", + last.map(|sample| format_ns_i128(sample.client_to_server_ns)) + .unwrap_or_else(|| "-".to_owned()), + last.map(|sample| format_ns_i128(sample.server_to_client_ns)) + .unwrap_or_else(|| "-".to_owned()) + )), + Line::from(format!( + "server processing: {}", + last.map(|sample| format_ns_i128(sample.server_processing_ns)) + .unwrap_or_else(|| "-".to_owned()) + )), + Line::from(format!( + "events sent={} replies={} losses={} warnings={}", + format_count(snapshot.events.sent_events), + format_count(snapshot.events.echo_replies), + format_count(snapshot.events.loss_events), + format_count(snapshot.events.warning_events) + )), + Line::from(format!("last warning: {warning}")), + ]) + .block(Block::default().title("sample").borders(Borders::ALL)) + .wrap(Wrap { trim: true }) +} + +fn status_line(state: &TuiState) -> Paragraph<'_> { + let paused = if state.paused { " display paused" } else { "" }; + let quitting = if state.quit_requested { + " quit requested" + } else { + "" + }; + Paragraph::new(format!( + "{}{}{} | graph {}{} | q quit | Ctrl-C quit | r reset | p pause | g graph | f full", + state.status.label(), + paused, + quitting, + state.graph_mode.label(), + if state.full_graph { " full" } else { "" } + )) + .block(Block::default().borders(Borders::ALL)) +} + +fn push_time_line(lines: &mut Vec>, label: &str, stats: &TimeStats) { + if stats.count == 0 { + lines.push(Line::from(format!( + "{label:<18} {:>5} {:>9} {:>9} {:>9} {:>9}", + 0, "-", "-", "-", "-" + ))); + return; + } + + lines.push(Line::from(format!( + "{label:<18} {:>5} {:>9} {:>9} {:>9} {:>9}", + format_count(stats.count), + format_ns_i128(stats.min_ns), + format_ns_f64(stats.mean_ns), + format_ns_i128(stats.max_ns), + format_ns_f64(stats.stddev_ns()) + ))); +} + +fn format_negotiated(negotiated: &NegotiatedParams) -> String { + let params = &negotiated.params; + let duration = if params.duration_ns == 0 { + "-".to_owned() + } else { + format_ns_i128(Some(i128::from(params.duration_ns))) + }; + let restrictions = if negotiated.restrictions.is_empty() { + "none".to_owned() + } else { + negotiated.restrictions.len().to_string() + }; + format!( + "duration={} interval={} length={} clock={:?} timestamps={:?} stats={:?} restrictions={}", + duration, + format_ns_i128(Some(i128::from(params.interval_ns))), + params.length, + params.clock, + params.stamp_at, + params.received_stats, + restrictions + ) +} + +fn format_optional_duration(value: Option) -> String { + value.map(format_duration).unwrap_or_else(|| "-".to_owned()) +} + +fn format_duration(value: Duration) -> String { + if value.is_zero() { + return "0s".to_owned(); + } + let nanos = value.as_nanos(); + if nanos < 1_000 { + format!("{nanos}ns") + } else if nanos < 1_000_000 { + format!("{:.1}us", nanos as f64 / 1_000.0) + } else if nanos < 1_000_000_000 { + format!("{:.1}ms", nanos as f64 / 1_000_000.0) + } else if nanos < 60_000_000_000 { + format!("{:.1}s", nanos as f64 / 1_000_000_000.0) + } else { + let secs = value.as_secs(); + format!("{}m{:02}s", secs / 60, secs % 60) + } +} + +fn format_ns_i128(value: Option) -> String { + value.map(format_ns_value).unwrap_or_else(|| "-".to_owned()) +} + +fn format_ns_f64(value: f64) -> String { + format_ns_value(value.round() as i128) +} + +fn format_ns_value(value: i128) -> String { + let sign = if value < 0 { "-" } else { "" }; + let value = value.saturating_abs() as f64; + if value < 1_000.0 { + format!("{sign}{value:.0}ns") + } else if value < 1_000_000.0 { + format!("{sign}{:.1}us", value / 1_000.0) + } else if value < 1_000_000_000.0 { + format!("{sign}{:.1}ms", value / 1_000_000.0) + } else { + format!("{sign}{:.3}s", value / 1_000_000_000.0) + } +} + +fn format_percent(value: f64) -> String { + if value.is_finite() { + format!("{value:.2}%") + } else { + "-".to_owned() + } +} + +fn format_percent_ratio(value: u64, total: u64) -> String { + if total == 0 { + "-".to_owned() + } else { + format_percent((value as f64 / total as f64 * 100.0).min(100.0)) + } +} + +fn format_rate(count: u64, elapsed: Duration) -> String { + let secs = elapsed.as_secs_f64(); + if secs <= f64::EPSILON { + "-".to_owned() + } else { + format!("{:.2}/s", count as f64 / secs) + } +} + +fn format_count(value: u64) -> String { + value.to_string() +} + +fn format_seq(value: u32) -> String { + value.to_string() +} + +fn format_optional_u64(value: Option) -> String { + value.map(format_count).unwrap_or_else(|| "-".to_owned()) +} + +fn format_optional_hex(value: Option) -> String { + value + .map(|value| format!("0x{value:x}")) + .unwrap_or_else(|| "-".to_owned()) +} + +fn duration_ns(value: Duration) -> i128 { + i128::try_from(value.as_nanos()).unwrap_or(i128::MAX) +} + +#[cfg(test)] +mod tests { + use super::*; + use irtt_client::{ + ClientTimestamp, OneWayDelaySample, PacketMeta, RttSample, ServerTiming, WarningKind, + }; + use std::{ + net::{IpAddr, Ipv4Addr, SocketAddr}, + time::UNIX_EPOCH, + }; + + fn remote() -> SocketAddr { + SocketAddr::new(IpAddr::V4(Ipv4Addr::LOCALHOST), 2112) + } + + fn ts(offset: Duration) -> ClientTimestamp { + ClientTimestamp { + wall: UNIX_EPOCH + offset, + mono: Instant::now() + offset, + } + } + + fn rtt(effective_ns: i128) -> RttSample { + RttSample { + raw: Duration::from_micros(1_500), + adjusted: Some(SignedDuration::from_nanos(effective_ns)), + effective: SignedDuration::from_nanos(effective_ns), + } + } + + fn reply(seq: u32, effective_ns: i128) -> ClientEvent { + ClientEvent::EchoReply { + seq, + remote: remote(), + sent_at: ts(Duration::from_millis(seq as u64)), + received_at: ts(Duration::from_millis(seq as u64) + Duration::from_micros(1500)), + rtt: rtt(effective_ns), + server_timing: Some(ServerTiming { + receive_wall_ns: None, + receive_mono_ns: None, + send_wall_ns: None, + send_mono_ns: None, + midpoint_wall_ns: None, + midpoint_mono_ns: None, + processing: Some(Duration::from_micros(100)), + }), + one_way: Some(OneWayDelaySample { + client_to_server: Some(SignedDuration::from_nanos(-20_000)), + server_to_client: Some(SignedDuration::from_nanos(30_000)), + }), + received_stats: None, + bytes: 64, + packet_meta: PacketMeta::default(), + } + } + + fn graph_sample(seq: u32, effective_ns: i128) -> GraphSample { + GraphSample { + seq, + effective_ns, + raw_ns: effective_ns + 1_000, + adjusted_ns: Some(effective_ns), + client_to_server_ns: None, + server_to_client_ns: None, + server_processing_ns: None, + } + } + + fn series(data: Vec<(f64, f64)>) -> ChartSeries { + ChartSeries { + value: GraphValue::EffectiveRtt, + data, + } + } + + #[test] + fn formats_signed_durations_and_missing_values() { + assert_eq!(format_ns_i128(Some(-1_500_000)), "-1.5ms"); + assert_eq!(format_ns_i128(Some(750)), "750ns"); + assert_eq!(format_ns_i128(None), "-"); + assert_eq!(format_optional_duration(None), "-"); + assert_eq!(format_duration(Duration::from_millis(25)), "25.0ms"); + assert_eq!(format_percent_ratio(1, 4), "25.00%"); + assert_eq!(format_optional_hex(Some(0x1f)), "0x1f"); + } + + #[test] + fn session_started_sets_remote_session_and_running_status() { + let mut state = TuiState::default(); + let mut stats = StatsCollector::new(irtt_stats::StatsConfig::finite()); + state.process_event( + &ClientEvent::SessionStarted { + remote: remote(), + token: 0xabc, + negotiated: NegotiatedParams { + params: irtt_proto::Params::default(), + restrictions: Vec::new(), + }, + at: ts(Duration::ZERO), + }, + &mut stats, + ); + + assert_eq!(state.remote.as_deref(), Some("127.0.0.1:2112")); + assert_eq!(state.session.as_deref(), Some("0xabc")); + assert_eq!(state.status, TuiStatus::Running); + assert!(state.negotiated.is_some()); + } + + #[test] + fn echo_reply_appends_bounded_primary_history() { + let mut state = TuiState::default(); + let mut stats = StatsCollector::new(irtt_stats::StatsConfig::finite()); + for seq in 0..(HISTORY_LIMIT as u32 + 3) { + state.process_event(&reply(seq, i128::from(seq) * 1_000), &mut stats); + } + + assert_eq!(state.graph_history.len(), HISTORY_LIMIT); + assert_eq!(state.graph_history.front().unwrap().seq, 3); + assert_eq!( + state.graph_history.back().unwrap().effective_ns, + i128::from(HISTORY_LIMIT as u32 + 2) * 1_000 + ); + assert_eq!( + stats.snapshot().packets.unique_replies, + HISTORY_LIMIT as u64 + 3 + ); + } + + #[test] + fn duplicate_and_late_replies_do_not_append_primary_history() { + let mut state = TuiState::default(); + let mut stats = StatsCollector::new(irtt_stats::StatsConfig::finite()); + state.process_event( + &ClientEvent::DuplicateReply { + seq: 7, + remote: remote(), + received_at: ts(Duration::from_secs(1)), + bytes: 64, + }, + &mut stats, + ); + state.process_event( + &ClientEvent::LateReply { + seq: 8, + highest_seen: 9, + remote: remote(), + sent_at: Some(ts(Duration::from_secs(1))), + received_at: ts(Duration::from_secs(2)), + rtt: Some(rtt(2_000_000)), + server_timing: None, + one_way: None, + received_stats: None, + bytes: 64, + packet_meta: PacketMeta::default(), + }, + &mut stats, + ); + + assert!(state.graph_history.is_empty()); + assert!(state + .recent_events + .iter() + .any(|event| event.contains("duplicate seq=7"))); + assert!(state + .recent_events + .iter() + .any(|event| event.contains("late seq=8"))); + } + + #[test] + fn warning_updates_recent_events_and_last_warning() { + let mut state = TuiState::default(); + let mut stats = StatsCollector::new(irtt_stats::StatsConfig::finite()); + state.process_event( + &ClientEvent::Warning { + kind: WarningKind::WrongToken, + message: "wrong token".to_owned(), + at: ts(Duration::ZERO), + }, + &mut stats, + ); + + assert_eq!( + state.last_warning.as_deref(), + Some("WrongToken: wrong token") + ); + assert!(state + .recent_events + .back() + .unwrap() + .contains("warning WrongToken: wrong token")); + } + + #[test] + fn recent_event_buffer_stays_bounded() { + let mut state = TuiState::default(); + for seq in 0..(RECENT_EVENT_LIMIT as u32 + 5) { + state.push_event(format!("event {seq}")); + } + + assert_eq!(state.recent_events.len(), RECENT_EVENT_LIMIT); + assert_eq!(state.recent_events.front().unwrap(), "event 5"); + } + + #[test] + fn recent_event_visible_count_tracks_panel_inner_height() { + assert_eq!(recent_events_visible_count(0), 0); + assert_eq!(recent_events_visible_count(2), 0); + assert_eq!(recent_events_visible_count(9), 7); + } + + #[test] + fn visible_window_length_scales_with_chart_width_and_remains_bounded() { + let history: VecDeque<_> = (0..300).map(|seq| graph_sample(seq, seq.into())).collect(); + + let narrow = visible_history_window(&history, 20); + let wide = visible_history_window(&history, 80); + let huge = visible_history_window(&history, 1_000); + + assert_eq!(narrow.len(), MIN_CHART_POINTS); + assert!(wide.len() > narrow.len(), "wide={}", wide.len()); + assert_eq!(huge.len(), HISTORY_LIMIT); + assert_eq!(huge.first().unwrap().seq, 60); + } + + #[test] + fn chart_bounds_use_visible_positive_cluster_without_zero() { + let series = [series(vec![(0.0, 42.0), (1.0, 43.0), (2.0, 44.0)])]; + let (min_y, max_y) = chart_y_bounds(&series); + + assert!(min_y > 0.0, "min_y={min_y}"); + assert!(min_y < 42.0, "min_y={min_y}"); + assert!(max_y > 44.0, "max_y={max_y}"); + } + + #[test] + fn chart_bounds_preserve_negative_values() { + let series = [series(vec![(0.0, -3.0), (1.0, -1.5), (2.0, 0.5)])]; + let (min_y, max_y) = chart_y_bounds(&series); + + assert!(min_y < -3.0, "min_y={min_y}"); + assert!(max_y > 0.5, "max_y={max_y}"); + } + + #[test] + fn chart_bounds_handle_empty_and_flat_series() { + assert_eq!(chart_y_bounds(&[]), (0.0, 1.0)); + + let flat = [series(vec![(0.0, 12.0), (1.0, 12.0)])]; + let (min_y, max_y) = chart_y_bounds(&flat); + assert!(min_y < 12.0, "min_y={min_y}"); + assert!(max_y > 12.0, "max_y={max_y}"); + assert!(min_y > 0.0, "min_y={min_y}"); + } + + #[test] + fn optional_missing_one_way_series_are_omitted_not_zero_filled() { + let visible_samples = [graph_sample(1, 2_000_000)]; + let visible: Vec<_> = visible_samples.iter().collect(); + + let one_way = graph_series(GraphMode::OneWay, &visible); + let rtt = graph_series(GraphMode::Rtt, &visible); + + assert!(one_way.is_empty()); + assert_eq!( + rtt.iter().map(|series| series.value).collect::>(), + vec![ + GraphValue::EffectiveRtt, + GraphValue::RawRtt, + GraphValue::AdjustedRtt + ] + ); + } + + #[test] + fn graph_mode_cycling_walks_all_modes() { + let mut state = TuiState::default(); + + assert_eq!(state.graph_mode, GraphMode::Rtt); + state.cycle_graph_mode(); + assert_eq!(state.graph_mode, GraphMode::OneWay); + state.cycle_graph_mode(); + assert_eq!(state.graph_mode, GraphMode::Combined); + state.cycle_graph_mode(); + assert_eq!(state.graph_mode, GraphMode::Split); + state.cycle_graph_mode(); + assert_eq!(state.graph_mode, GraphMode::Rtt); + } + + #[test] + fn pause_suppresses_scheduled_renders_but_allows_forced_controls() { + let now = Instant::now(); + let due = now - Duration::from_millis(1); + + assert!(!should_render(now, due, true, false)); + assert!(should_render(now, due, true, true)); + + let mut state = TuiState::default(); + state.toggle_pause(); + assert!(state.paused); + } + + #[test] + fn sample_details_preserve_signed_values() { + let mut state = TuiState::default(); + let mut stats = StatsCollector::new(irtt_stats::StatsConfig::finite()); + state.process_event(&reply(4, -1_250_000), &mut stats); + + let sample = state.last_sample.unwrap(); + assert_eq!(sample.seq, 4); + assert_eq!(sample.effective_ns, -1_250_000); + assert_eq!(sample.client_to_server_ns, Some(-20_000)); + assert_eq!(sample.server_processing_ns, Some(100_000)); + + let graph = state.graph_history.back().unwrap(); + assert_eq!(graph.seq, 4); + assert_eq!(graph.effective_ns, -1_250_000); + assert_eq!(graph.client_to_server_ns, Some(-20_000)); + assert_eq!(graph.server_to_client_ns, Some(30_000)); + assert_eq!(graph.server_processing_ns, Some(100_000)); + } +}