From 3c8293f71c77739b12ddde170bcc93270d012a87 Mon Sep 17 00:00:00 2001 From: Mehul Arora Date: Thu, 22 Jan 2026 17:12:38 -0500 Subject: [PATCH 01/31] . --- Cargo.lock | 396 +++++++++++++- Cargo.toml | 5 + src/cli.rs | 6 +- src/main.rs | 18 +- src/tui/app.rs | 1147 ++++++++++++++++++++++++++++++++++++++++ src/tui/event.rs | 41 ++ src/tui/mod.rs | 50 ++ src/tui/screens/mod.rs | 2 + src/tui/ui.rs | 912 ++++++++++++++++++++++++++++++++ src/tui/widgets/mod.rs | 2 + 10 files changed, 2553 insertions(+), 26 deletions(-) create mode 100644 src/tui/app.rs create mode 100644 src/tui/event.rs create mode 100644 src/tui/mod.rs create mode 100644 src/tui/screens/mod.rs create mode 100644 src/tui/ui.rs create mode 100644 src/tui/widgets/mod.rs diff --git a/Cargo.lock b/Cargo.lock index 3b161e2..88c3385 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -26,6 +26,21 @@ dependencies = [ "memchr", ] +[[package]] +name = "allocator-api2" +version = "0.2.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "683d7910e743518b0e34f1186f92494becacb047c7b6bf616c96772180fef923" + +[[package]] +name = "android_system_properties" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "819e7219dbd41043ac279b19830f2efc897156490d7fd6ea916720117ee66311" +dependencies = [ + "libc", +] + [[package]] name = "anstream" version = "0.6.21" @@ -291,6 +306,12 @@ version = "1.11.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b35204fbdc0b3f4446b89fc1ac2cf84a8a68971995d0bf2e925ec7cd960f9cb3" +[[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" @@ -330,6 +351,19 @@ version = "0.2.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "613afe47fcd5fac7ccf1db93babcb082c5994d996f20b8b159f2ad1658eb5724" +[[package]] +name = "chrono" +version = "0.4.43" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fac4744fb15ae8337dc853fee7fb3f4e48c0fbaa23d0afe49c447b4fab126118" +dependencies = [ + "iana-time-zone", + "js-sys", + "num-traits", + "wasm-bindgen", + "windows-link", +] + [[package]] name = "clap" version = "4.5.54" @@ -425,6 +459,20 @@ dependencies = [ "memchr", ] +[[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 = "compact_str" version = "0.9.0" @@ -488,7 +536,7 @@ dependencies = [ "encode_unicode", "libc", "once_cell", - "unicode-width 0.2.2", + "unicode-width 0.2.0", "windows-sys 0.61.2", ] @@ -561,6 +609,31 @@ dependencies = [ "cfg-if", ] +[[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 0.38.44", + "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 = "crunchy" version = "0.2.4" @@ -583,8 +656,18 @@ version = "0.21.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9cdf337090841a411e2a7f3deb9187445851f91b309c0c0a29e05f74a00a48c0" dependencies = [ - "darling_core", - "darling_macro", + "darling_core 0.21.3", + "darling_macro 0.21.3", +] + +[[package]] +name = "darling" +version = "0.23.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "25ae13da2f202d56bd7f91c25fba009e7717a1e4a1cc98a76d844b65ae912e9d" +dependencies = [ + "darling_core 0.23.0", + "darling_macro 0.23.0", ] [[package]] @@ -600,13 +683,37 @@ dependencies = [ "syn", ] +[[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.21.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d38308df82d1080de0afee5d069fa14b0326a88c14f15c5ccda35b4a6c414c81" dependencies = [ - "darling_core", + "darling_core 0.21.3", + "quote", + "syn", +] + +[[package]] +name = "darling_macro" +version = "0.23.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ac3984ec7bd6cfa798e62b4a642426a5be0e68f9401cfc2a01e3fa9ea2fcdb8d" +dependencies = [ + "darling_core 0.23.0", "quote", "syn", ] @@ -740,7 +847,7 @@ version = "0.14.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f43e744e4ea338060faee68ed933e46e722fb7f3617e722a5772d7e856d8b3ce" dependencies = [ - "darling", + "darling 0.21.3", "proc-macro2", "quote", "syn", @@ -987,6 +1094,8 @@ version = "0.15.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9229cfe53dfd69f0609a49f65461bd93001ea1ef889cd5529dd176593f5338a1" dependencies = [ + "allocator-api2", + "equivalent", "foldhash", ] @@ -1117,6 +1226,30 @@ dependencies = [ "tracing", ] +[[package]] +name = "iana-time-zone" +version = "0.1.64" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "33e57f83510bb73707521ebaffa789ec8caf86f9657cad665b092b581d40e9fb" +dependencies = [ + "android_system_properties", + "core-foundation-sys", + "iana-time-zone-haiku", + "js-sys", + "log", + "wasm-bindgen", + "windows-core", +] + +[[package]] +name = "iana-time-zone-haiku" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f31827a206f56af32e590ba56d5d2d085f558508192593743f16b2306495269f" +dependencies = [ + "cc", +] + [[package]] name = "icu_collections" version = "2.1.1" @@ -1243,11 +1376,33 @@ checksum = "9375e112e4b463ec1b1c6c011953545c65a30164fbab5b581df32b3abf0dcb88" dependencies = [ "console", "portable-atomic", - "unicode-width 0.2.2", + "unicode-width 0.2.0", "unit-prefix", "web-time", ] +[[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.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "357b7205c6cd18dd2c86ed312d1e70add149aea98e7ef72b9fdf0270e555c11d" +dependencies = [ + "darling 0.23.0", + "indoc", + "proc-macro2", + "quote", + "syn", +] + [[package]] name = "ipnet" version = "2.11.0" @@ -1276,6 +1431,15 @@ 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 = "itertools" version = "0.14.0" @@ -1376,6 +1540,12 @@ dependencies = [ "libc", ] +[[package]] +name = "linux-raw-sys" +version = "0.4.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d26c52dbd32dccf2d10cac7725f8eae5296885fb5703b261f7d0a0739ec807ab" + [[package]] name = "linux-raw-sys" version = "0.11.0" @@ -1403,6 +1573,15 @@ 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 0.15.5", +] + [[package]] name = "lru-slab" version = "0.1.2" @@ -1483,6 +1662,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a69bcab0ad47271a0234d9422b131806bf3968021e5dc9328caf2d4cd58557fc" dependencies = [ "libc", + "log", "wasi", "windows-sys 0.61.2", ] @@ -1584,7 +1764,7 @@ checksum = "6978128c8b51d8f4080631ceb2302ab51e32cc6e8615f735ee2f83fd269ae3f1" dependencies = [ "bytecount", "fnv", - "unicode-width 0.2.2", + "unicode-width 0.2.0", ] [[package]] @@ -1610,6 +1790,12 @@ dependencies = [ "windows-link", ] +[[package]] +name = "paste" +version = "1.0.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "57c0d7b74b563b49d38dae00a0c37d4d6de9b432382b2892f0574ddcae73fd0a" + [[package]] name = "pathdiff" version = "0.2.3" @@ -1820,7 +2006,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "27c6023962132f4b30eb4c172c91ce92d933da334c59c23cddee82358ddafb0b" dependencies = [ "anyhow", - "itertools", + "itertools 0.14.0", "proc-macro2", "quote", "syn", @@ -1926,6 +2112,27 @@ dependencies = [ "getrandom 0.3.4", ] +[[package]] +name = "ratatui" +version = "0.29.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "eabd94c2f37801c20583fc49dd5cd6b0ba68c716787c2dd6ed18571e1e63117b" +dependencies = [ + "bitflags", + "cassowary", + "compact_str 0.8.1", + "crossterm", + "indoc", + "instability", + "itertools 0.13.0", + "lru", + "paste", + "strum 0.26.3", + "unicode-segmentation", + "unicode-truncate", + "unicode-width 0.2.0", +] + [[package]] name = "redox_syscall" version = "0.5.18" @@ -2110,6 +2317,19 @@ dependencies = [ "semver", ] +[[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.52.0", +] + [[package]] name = "rustix" version = "1.1.3" @@ -2119,7 +2339,7 @@ dependencies = [ "bitflags", "errno", "libc", - "linux-raw-sys", + "linux-raw-sys 0.11.0", "windows-sys 0.61.2", ] @@ -2218,18 +2438,18 @@ checksum = "7dd1415b83de9008573bfc3764c73a6e92167f521ca39e389889a22aa251b679" dependencies = [ "base64ct", "bytes", - "compact_str", + "compact_str 0.9.0", "enum-ordinalize", "flate2", "futures", "http", - "itertools", + "itertools 0.14.0", "mime", "prost", "s2-common", "serde", "serde_json", - "strum", + "strum 0.27.2", "thiserror 2.0.18", "time", "tokio-util", @@ -2244,10 +2464,12 @@ dependencies = [ "async-stream", "base64ct", "bytes", + "chrono", "clap", "color-print", "colored", "config", + "crossterm", "dirs", "futures", "http", @@ -2257,12 +2479,13 @@ dependencies = [ "miette", "predicates", "rand", + "ratatui", "rstest", "s2-sdk", "serde", "serde_json", "serial_test", - "strum", + "strum 0.27.2", "tabled", "tempfile", "thiserror 2.0.18", @@ -2283,12 +2506,12 @@ checksum = "d62d7b095f290ced3bec82814b129efd5fb1d974b950c3ce1e7879e439947d67" dependencies = [ "blake3", "bytes", - "compact_str", + "compact_str 0.9.0", "enum-ordinalize", "enumset", "http", "serde", - "strum", + "strum 0.27.2", "thiserror 2.0.18", "time", ] @@ -2531,6 +2754,27 @@ version = "1.3.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0fda2ff0d084019ba4d7c6f371c95d8fd75ce3524c3cb8fb653a3023f6323e64" +[[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" @@ -2587,13 +2831,35 @@ 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 0.26.4", +] + [[package]] name = "strum" version = "0.27.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "af23d6f6c1a224baef9d3f61e287d2761385a5b88fdab4eb4c6f11aeb54c4bcf" dependencies = [ - "strum_macros", + "strum_macros 0.27.2", +] + +[[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]] @@ -2699,7 +2965,7 @@ dependencies = [ "fastrand", "getrandom 0.3.4", "once_cell", - "rustix", + "rustix 1.1.3", "windows-sys 0.61.2", ] @@ -2709,7 +2975,7 @@ version = "0.4.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "60b8cb979cb11c32ce1603f8137b22262a9d131aaa5c37b5678025f22b8becd0" dependencies = [ - "rustix", + "rustix 1.1.3", "windows-sys 0.60.2", ] @@ -2725,7 +2991,7 @@ version = "0.3.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0f8daae29995a24f65619e19d8d31dea5b389f3d853d8bf297bbf607cd0014cc" dependencies = [ - "unicode-width 0.2.2", + "unicode-width 0.2.0", ] [[package]] @@ -2735,7 +3001,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c13547615a44dc9c452a8a534638acdf07120d4b6847c8178705da06306a3057" dependencies = [ "unicode-linebreak", - "unicode-width 0.2.2", + "unicode-width 0.2.0", ] [[package]] @@ -3129,6 +3395,17 @@ version = "1.12.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f6ccf251212114b54433ec949fd6a7841275f9ada20dddd2f29e9ceea4501493" +[[package]] +name = "unicode-truncate" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b3644627a5af5fa321c95b9b235a72fd24cd29c648c2c379431e6628655627bf" +dependencies = [ + "itertools 0.13.0", + "unicode-segmentation", + "unicode-width 0.1.14", +] + [[package]] name = "unicode-width" version = "0.1.14" @@ -3137,9 +3414,9 @@ checksum = "7dd6e30e90baa6f72411720665d41d89b9a3d039dc45b8faea1ddd07f617f6af" [[package]] name = "unicode-width" -version = "0.2.2" +version = "0.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b4ac048d71ede7ee76d585517add45da530660ef4390e49b098733c6e897f254" +checksum = "1fc81956842c57dac11422a97c3b8195a1ff727f06e85c84ed2e8aa277c9a0fd" [[package]] name = "unit-prefix" @@ -3351,6 +3628,22 @@ dependencies = [ "rustls-pki-types", ] +[[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-util" version = "0.1.11" @@ -3360,12 +3653,71 @@ dependencies = [ "windows-sys 0.61.2", ] +[[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-core" +version = "0.62.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b8e83a14d34d0623b51dce9581199302a221863196a1dde71a7663a4c2be9deb" +dependencies = [ + "windows-implement", + "windows-interface", + "windows-link", + "windows-result", + "windows-strings", +] + +[[package]] +name = "windows-implement" +version = "0.60.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "053e2e040ab57b9dc951b72c264860db7eb3b0200ba345b4e4c3b14f67855ddf" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "windows-interface" +version = "0.59.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3f316c4a2570ba26bbec722032c4099d8c8bc095efccdc15688708623367e358" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + [[package]] name = "windows-link" version = "0.2.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f0805222e57f7521d6a62e36fa9163bc891acd422f971defe97d64e70d0a4fe5" +[[package]] +name = "windows-result" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7781fa89eaf60850ac3d2da7af8e5242a5ea78d1a11c49bf2910bb5a73853eb5" +dependencies = [ + "windows-link", +] + +[[package]] +name = "windows-strings" +version = "0.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7837d08f69c77cf6b07689544538e017c1bfcf57e34b4c0ff58e6c2cd3b37091" +dependencies = [ + "windows-link", +] + [[package]] name = "windows-sys" version = "0.45.0" diff --git a/Cargo.toml b/Cargo.toml index 48ab618..3583bcb 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -14,6 +14,7 @@ path = "src/main.rs" [dependencies] async-stream = "0.3.6" +chrono = "0.4" base64ct = { version = "1.8.3", features = ["alloc"] } bytes = "1.11.0" clap = { version = "4.5.54", features = ["derive"] } @@ -42,6 +43,10 @@ tracing-subscriber = { version = "0.3.22", features = ["env-filter"] } uuid = { version = "1.20.0", features = ["v4"] } xxhash-rust = { version = "0.8.15", features = ["xxh3"] } +# TUI +ratatui = "0.29" +crossterm = "0.28" + [dev-dependencies] assert_cmd = "2.1" predicates = "3.1" diff --git a/src/cli.rs b/src/cli.rs index 4c9c233..97d3f4c 100644 --- a/src/cli.rs +++ b/src/cli.rs @@ -31,7 +31,11 @@ const GENERAL_USAGE: &str = color_print::cstr!( #[command(name = "s2", version, override_usage = GENERAL_USAGE, styles = STYLES)] pub struct Cli { #[command(subcommand)] - pub command: Command, + pub command: Option, + + /// Launch interactive TUI mode. + #[arg(short = 'i', long = "interactive")] + pub interactive: bool, } #[derive(Subcommand, Debug)] diff --git a/src/main.rs b/src/main.rs index c7bd43f..8910a96 100644 --- a/src/main.rs +++ b/src/main.rs @@ -4,6 +4,7 @@ mod config; mod error; mod ops; mod record_format; +mod tui; mod types; use std::pin::Pin; @@ -45,7 +46,7 @@ async fn main() -> miette::Result<()> { } async fn run() -> Result<(), CliError> { - let commands = Cli::try_parse().unwrap_or_else(|e| { + let cli = Cli::try_parse().unwrap_or_else(|e| { // Customize error message for metric commands to say "metric" instead of "subcommand" let msg = e.to_string(); if msg.contains("requires a subcommand") && msg.contains("get-") && msg.contains("-metrics") @@ -59,6 +60,17 @@ async fn run() -> Result<(), CliError> { e.exit() }); + // Launch interactive TUI mode + if cli.interactive { + return tui::run().await; + } + + // Require a command when not in interactive mode + let Some(command) = cli.command else { + eprintln!("No command specified. Use --help for usage or -i for interactive mode."); + std::process::exit(1); + }; + tracing_subscriber::registry() .with( tracing_subscriber::fmt::layer() @@ -70,7 +82,7 @@ async fn run() -> Result<(), CliError> { .with(tracing_subscriber::EnvFilter::from_default_env()) .init(); - if let Command::Config(config_cmd) = &commands.command { + if let Command::Config(config_cmd) = &command { match config_cmd { ConfigCommand::List => { let config = load_config_file()?; @@ -112,7 +124,7 @@ async fn run() -> Result<(), CliError> { let sdk_config = sdk_config(&cli_config)?; let s2 = S2::new(sdk_config.clone()).map_err(CliError::SdkInit)?; - match commands.command { + match command { Command::Config(..) => unreachable!(), Command::Ls(args) => { diff --git a/src/tui/app.rs b/src/tui/app.rs new file mode 100644 index 0000000..c439247 --- /dev/null +++ b/src/tui/app.rs @@ -0,0 +1,1147 @@ +use std::collections::VecDeque; +use std::time::Duration; + +use crossterm::event::{self, Event as CrosstermEvent, KeyCode, KeyEvent, KeyModifiers}; +use futures::StreamExt; +use ratatui::{Terminal, prelude::Backend}; +use s2_sdk::types::{BasinInfo, BasinName, StreamInfo, StreamName, StreamPosition}; +use tokio::sync::mpsc; + +use crate::cli::{CreateBasinArgs, CreateStreamArgs, ListBasinsArgs, ListStreamsArgs, TailArgs}; +use crate::error::CliError; +use crate::ops; +use crate::record_format::{RecordFormat, RecordsOut}; +use crate::types::{BasinConfig, S2BasinAndMaybeStreamUri, S2BasinAndStreamUri, S2BasinUri, StreamConfig}; + +use super::event::Event; +use super::ui; + +/// Maximum records to keep in read view buffer +const MAX_RECORDS_BUFFER: usize = 1000; + +/// Current screen being displayed +#[derive(Debug, Clone)] +pub enum Screen { + Basins(BasinsState), + Streams(StreamsState), + StreamDetail(StreamDetailState), + ReadView(ReadViewState), +} + +/// State for the basins list screen +#[derive(Debug, Clone, Default)] +pub struct BasinsState { + pub basins: Vec, + pub selected: usize, + pub loading: bool, + pub filter: String, + pub filter_active: bool, +} + +/// State for the streams list screen +#[derive(Debug, Clone)] +pub struct StreamsState { + pub basin_name: BasinName, + pub streams: Vec, + pub selected: usize, + pub loading: bool, + pub filter: String, + pub filter_active: bool, +} + +/// State for the stream detail screen +#[derive(Debug, Clone)] +pub struct StreamDetailState { + pub basin_name: BasinName, + pub stream_name: StreamName, + pub config: Option, + pub tail_position: Option, + pub selected_action: usize, + pub loading: bool, +} + +/// State for the read/tail view +#[derive(Debug, Clone)] +pub struct ReadViewState { + pub basin_name: BasinName, + pub stream_name: StreamName, + pub records: VecDeque, + pub is_tailing: bool, + pub scroll_offset: usize, + pub paused: bool, + pub loading: bool, +} + +/// Status message level +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum MessageLevel { + Info, + Success, + Error, +} + +/// Status message to display +#[derive(Debug, Clone)] +pub struct StatusMessage { + pub text: String, + pub level: MessageLevel, +} + +/// Input mode for text input dialogs +#[derive(Debug, Clone)] +pub enum InputMode { + /// Not in input mode + Normal, + /// Creating a new basin + CreateBasin { input: String }, + /// Creating a new stream + CreateStream { basin: BasinName, input: String }, + /// Confirming basin deletion + ConfirmDeleteBasin { basin: BasinName }, + /// Confirming stream deletion + ConfirmDeleteStream { basin: BasinName, stream: StreamName }, +} + +impl Default for InputMode { + fn default() -> Self { + Self::Normal + } +} + +/// Main application state +pub struct App { + pub screen: Screen, + pub s2: s2_sdk::S2, + pub message: Option, + pub show_help: bool, + pub input_mode: InputMode, + should_quit: bool, +} + +impl App { + pub fn new(s2: s2_sdk::S2) -> Self { + Self { + screen: Screen::Basins(BasinsState { + loading: true, + ..Default::default() + }), + s2, + message: None, + show_help: false, + input_mode: InputMode::Normal, + should_quit: false, + } + } + + pub async fn run(mut self, terminal: &mut Terminal) -> Result<(), CliError> { + let (tx, mut rx) = mpsc::unbounded_channel(); + + // Initial data load + self.load_basins(tx.clone()); + + loop { + // Render + terminal + .draw(|f| ui::draw(f, &self)) + .map_err(|e| CliError::RecordWrite(format!("Failed to draw: {e}")))?; + + // Handle events + tokio::select! { + // Handle async events from background tasks + Some(event) = rx.recv() => { + self.handle_event(event); + } + + // Handle keyboard input + _ = tokio::time::sleep(Duration::from_millis(50)) => { + if event::poll(Duration::from_millis(0)) + .map_err(|e| CliError::RecordWrite(format!("Failed to poll events: {e}")))? + { + if let CrosstermEvent::Key(key) = event::read() + .map_err(|e| CliError::RecordWrite(format!("Failed to read event: {e}")))? + { + self.handle_key(key, tx.clone()); + } + } + } + } + + if self.should_quit { + break; + } + } + + Ok(()) + } + + fn handle_event(&mut self, event: Event) { + match event { + Event::BasinsLoaded(result) => { + if let Screen::Basins(state) = &mut self.screen { + state.loading = false; + match result { + Ok(basins) => { + state.basins = basins; + self.message = Some(StatusMessage { + text: format!("Loaded {} basins", state.basins.len()), + level: MessageLevel::Success, + }); + } + Err(e) => { + self.message = Some(StatusMessage { + text: format!("Failed to load basins: {e}"), + level: MessageLevel::Error, + }); + } + } + } + } + + Event::StreamsLoaded(result) => { + if let Screen::Streams(state) = &mut self.screen { + state.loading = false; + match result { + Ok(streams) => { + state.streams = streams; + self.message = Some(StatusMessage { + text: format!("Loaded {} streams", state.streams.len()), + level: MessageLevel::Success, + }); + } + Err(e) => { + self.message = Some(StatusMessage { + text: format!("Failed to load streams: {e}"), + level: MessageLevel::Error, + }); + } + } + } + } + + Event::StreamConfigLoaded(result) => { + if let Screen::StreamDetail(state) = &mut self.screen { + state.loading = false; + match result { + Ok(config) => { + state.config = Some(config); + } + Err(e) => { + self.message = Some(StatusMessage { + text: format!("Failed to load config: {e}"), + level: MessageLevel::Error, + }); + } + } + } + } + + Event::TailPositionLoaded(result) => { + if let Screen::StreamDetail(state) = &mut self.screen { + match result { + Ok(pos) => { + state.tail_position = Some(pos); + } + Err(e) => { + self.message = Some(StatusMessage { + text: format!("Failed to load tail position: {e}"), + level: MessageLevel::Error, + }); + } + } + } + } + + Event::RecordReceived(result) => { + if let Screen::ReadView(state) = &mut self.screen { + state.loading = false; + match result { + Ok(record) => { + if !state.paused { + state.records.push_back(record); + // Keep buffer bounded + while state.records.len() > MAX_RECORDS_BUFFER { + state.records.pop_front(); + } + } + } + Err(e) => { + self.message = Some(StatusMessage { + text: format!("Read error: {e}"), + level: MessageLevel::Error, + }); + } + } + } + } + + Event::ReadEnded => { + if let Screen::ReadView(state) = &mut self.screen { + state.loading = false; + if !state.is_tailing { + self.message = Some(StatusMessage { + text: "Read complete".to_string(), + level: MessageLevel::Info, + }); + } + } + } + + Event::BasinCreated(result) => { + self.input_mode = InputMode::Normal; + match result { + Ok(basin) => { + self.message = Some(StatusMessage { + text: format!("Created basin '{}'", basin.name), + level: MessageLevel::Success, + }); + // Refresh basins list + if let Screen::Basins(state) = &mut self.screen { + state.loading = true; + } + } + Err(e) => { + self.message = Some(StatusMessage { + text: format!("Failed to create basin: {e}"), + level: MessageLevel::Error, + }); + } + } + } + + Event::BasinDeleted(result) => { + self.input_mode = InputMode::Normal; + match result { + Ok(name) => { + self.message = Some(StatusMessage { + text: format!("Deleted basin '{}'", name), + level: MessageLevel::Success, + }); + // Refresh basins list + if let Screen::Basins(state) = &mut self.screen { + state.loading = true; + } + } + Err(e) => { + self.message = Some(StatusMessage { + text: format!("Failed to delete basin: {e}"), + level: MessageLevel::Error, + }); + } + } + } + + Event::StreamCreated(result) => { + self.input_mode = InputMode::Normal; + match result { + Ok(stream) => { + self.message = Some(StatusMessage { + text: format!("Created stream '{}'", stream.name), + level: MessageLevel::Success, + }); + // Refresh streams list + if let Screen::Streams(state) = &mut self.screen { + state.loading = true; + } + } + Err(e) => { + self.message = Some(StatusMessage { + text: format!("Failed to create stream: {e}"), + level: MessageLevel::Error, + }); + } + } + } + + Event::StreamDeleted(result) => { + self.input_mode = InputMode::Normal; + match result { + Ok(name) => { + self.message = Some(StatusMessage { + text: format!("Deleted stream '{}'", name), + level: MessageLevel::Success, + }); + // Refresh streams list + if let Screen::Streams(state) = &mut self.screen { + state.loading = true; + } + } + Err(e) => { + self.message = Some(StatusMessage { + text: format!("Failed to delete stream: {e}"), + level: MessageLevel::Error, + }); + } + } + } + + Event::Error(e) => { + self.message = Some(StatusMessage { + text: e.to_string(), + level: MessageLevel::Error, + }); + } + } + } + + fn handle_key(&mut self, key: KeyEvent, tx: mpsc::UnboundedSender) { + // Clear message on any keypress + self.message = None; + + // Handle input mode first + if !matches!(self.input_mode, InputMode::Normal) { + self.handle_input_key(key, tx); + return; + } + + // Global keys + match key.code { + KeyCode::Char('q') | KeyCode::Esc if self.show_help => { + self.show_help = false; + return; + } + KeyCode::Char('?') => { + self.show_help = !self.show_help; + return; + } + KeyCode::Char('c') if key.modifiers.contains(KeyModifiers::CONTROL) => { + self.should_quit = true; + return; + } + KeyCode::Char('q') if !matches!(self.screen, Screen::Basins(_)) => { + // q goes back except on basins screen where it quits + } + KeyCode::Char('q') => { + self.should_quit = true; + return; + } + _ => {} + } + + if self.show_help { + return; + } + + // Screen-specific keys - handle in place to avoid borrow issues + match &self.screen { + Screen::Basins(_) => self.handle_basins_key(key, tx), + Screen::Streams(_) => self.handle_streams_key(key, tx), + Screen::StreamDetail(_) => self.handle_stream_detail_key(key, tx), + Screen::ReadView(_) => self.handle_read_view_key(key), + } + } + + fn handle_input_key(&mut self, key: KeyEvent, tx: mpsc::UnboundedSender) { + match &mut self.input_mode { + InputMode::Normal => {} + + InputMode::CreateBasin { input } => { + match key.code { + KeyCode::Esc => { + self.input_mode = InputMode::Normal; + } + KeyCode::Enter => { + if !input.is_empty() { + let name = input.clone(); + self.create_basin(name, tx.clone()); + } + } + KeyCode::Backspace => { + input.pop(); + } + KeyCode::Char(c) => { + // Basin names: lowercase letters, numbers, hyphens + if c.is_ascii_lowercase() || c.is_ascii_digit() || c == '-' { + input.push(c); + } + } + _ => {} + } + } + + InputMode::CreateStream { basin, input } => { + match key.code { + KeyCode::Esc => { + self.input_mode = InputMode::Normal; + } + KeyCode::Enter => { + if !input.is_empty() { + let name = input.clone(); + let basin = basin.clone(); + self.create_stream(basin, name, tx.clone()); + } + } + KeyCode::Backspace => { + input.pop(); + } + KeyCode::Char(c) => { + input.push(c); + } + _ => {} + } + } + + InputMode::ConfirmDeleteBasin { basin } => { + match key.code { + KeyCode::Esc | KeyCode::Char('n') | KeyCode::Char('N') => { + self.input_mode = InputMode::Normal; + } + KeyCode::Char('y') | KeyCode::Char('Y') | KeyCode::Enter => { + let basin = basin.clone(); + self.delete_basin(basin, tx.clone()); + } + _ => {} + } + } + + InputMode::ConfirmDeleteStream { basin, stream } => { + match key.code { + KeyCode::Esc | KeyCode::Char('n') | KeyCode::Char('N') => { + self.input_mode = InputMode::Normal; + } + KeyCode::Char('y') | KeyCode::Char('Y') | KeyCode::Enter => { + let basin = basin.clone(); + let stream = stream.clone(); + self.delete_stream(basin, stream, tx.clone()); + } + _ => {} + } + } + } + } + + fn handle_basins_key(&mut self, key: KeyEvent, tx: mpsc::UnboundedSender) { + let Screen::Basins(state) = &mut self.screen else { + return; + }; + + // Handle filter mode + if state.filter_active { + match key.code { + KeyCode::Esc => { + state.filter_active = false; + state.filter.clear(); + state.selected = 0; + } + KeyCode::Enter => { + state.filter_active = false; + } + KeyCode::Backspace => { + state.filter.pop(); + state.selected = 0; + } + KeyCode::Char(c) => { + state.filter.push(c); + state.selected = 0; + } + _ => {} + } + return; + } + + // Get filtered list length for bounds checking + let filtered: Vec<_> = state.basins.iter() + .filter(|b| state.filter.is_empty() || b.name.to_string().contains(&state.filter)) + .collect(); + let filtered_len = filtered.len(); + + match key.code { + KeyCode::Char('/') => { + state.filter_active = true; + } + KeyCode::Up | KeyCode::Char('k') => { + if state.selected > 0 { + state.selected -= 1; + } + } + KeyCode::Down | KeyCode::Char('j') => { + if filtered_len > 0 && state.selected < filtered_len - 1 { + state.selected += 1; + } + } + KeyCode::Char('g') => { + state.selected = 0; + } + KeyCode::Char('G') => { + if filtered_len > 0 { + state.selected = filtered_len - 1; + } + } + KeyCode::Enter => { + if let Some(basin) = filtered.get(state.selected) { + let basin_name = basin.name.clone(); + self.screen = Screen::Streams(StreamsState { + basin_name: basin_name.clone(), + streams: Vec::new(), + selected: 0, + loading: true, + filter: String::new(), + filter_active: false, + }); + self.load_streams(basin_name, tx); + } + } + KeyCode::Char('r') => { + state.loading = true; + state.filter.clear(); + state.selected = 0; + self.load_basins(tx); + } + KeyCode::Char('c') => { + self.input_mode = InputMode::CreateBasin { input: String::new() }; + } + KeyCode::Char('d') => { + if let Some(basin) = filtered.get(state.selected) { + self.input_mode = InputMode::ConfirmDeleteBasin { + basin: basin.name.clone(), + }; + } + } + KeyCode::Esc => { + if !state.filter.is_empty() { + state.filter.clear(); + state.selected = 0; + } + } + _ => {} + } + } + + fn handle_streams_key(&mut self, key: KeyEvent, tx: mpsc::UnboundedSender) { + let Screen::Streams(state) = &mut self.screen else { + return; + }; + + // Handle filter mode + if state.filter_active { + match key.code { + KeyCode::Esc => { + state.filter_active = false; + state.filter.clear(); + state.selected = 0; + } + KeyCode::Enter => { + state.filter_active = false; + } + KeyCode::Backspace => { + state.filter.pop(); + state.selected = 0; + } + KeyCode::Char(c) => { + state.filter.push(c); + state.selected = 0; + } + _ => {} + } + return; + } + + // Get filtered list length for bounds checking + let filtered: Vec<_> = state.streams.iter() + .filter(|s| state.filter.is_empty() || s.name.to_string().contains(&state.filter)) + .collect(); + let filtered_len = filtered.len(); + + match key.code { + KeyCode::Char('/') => { + state.filter_active = true; + } + KeyCode::Esc => { + if !state.filter.is_empty() { + state.filter.clear(); + state.selected = 0; + } else { + self.screen = Screen::Basins(BasinsState { + loading: true, + ..Default::default() + }); + self.load_basins(tx); + } + } + KeyCode::Char('q') => { + self.screen = Screen::Basins(BasinsState { + loading: true, + ..Default::default() + }); + self.load_basins(tx); + } + KeyCode::Up | KeyCode::Char('k') => { + if state.selected > 0 { + state.selected -= 1; + } + } + KeyCode::Down | KeyCode::Char('j') => { + if filtered_len > 0 && state.selected < filtered_len - 1 { + state.selected += 1; + } + } + KeyCode::Char('g') => { + state.selected = 0; + } + KeyCode::Char('G') => { + if filtered_len > 0 { + state.selected = filtered_len - 1; + } + } + KeyCode::Enter => { + if let Some(stream) = filtered.get(state.selected) { + let stream_name = stream.name.clone(); + let basin_name = state.basin_name.clone(); + self.screen = Screen::StreamDetail(StreamDetailState { + basin_name: basin_name.clone(), + stream_name: stream_name.clone(), + config: None, + tail_position: None, + selected_action: 0, + loading: true, + }); + self.load_stream_detail(basin_name, stream_name, tx); + } + } + KeyCode::Char('r') => { + let basin_name = state.basin_name.clone(); + state.loading = true; + state.filter.clear(); + state.selected = 0; + self.load_streams(basin_name, tx); + } + KeyCode::Char('c') => { + self.input_mode = InputMode::CreateStream { + basin: state.basin_name.clone(), + input: String::new(), + }; + } + KeyCode::Char('d') => { + if let Some(stream) = filtered.get(state.selected) { + self.input_mode = InputMode::ConfirmDeleteStream { + basin: state.basin_name.clone(), + stream: stream.name.clone(), + }; + } + } + _ => {} + } + } + + fn handle_stream_detail_key(&mut self, key: KeyEvent, tx: mpsc::UnboundedSender) { + let Screen::StreamDetail(state) = &mut self.screen else { + return; + }; + + match key.code { + KeyCode::Esc | KeyCode::Char('q') => { + let basin_name = state.basin_name.clone(); + self.screen = Screen::Streams(StreamsState { + basin_name: basin_name.clone(), + streams: Vec::new(), + selected: 0, + loading: true, + }); + self.load_streams(basin_name, tx); + } + KeyCode::Up | KeyCode::Char('k') => { + if state.selected_action > 0 { + state.selected_action -= 1; + } + } + KeyCode::Down | KeyCode::Char('j') => { + if state.selected_action < 1 { + // 2 actions: read, tail + state.selected_action += 1; + } + } + KeyCode::Enter => { + let basin_name = state.basin_name.clone(); + let stream_name = state.stream_name.clone(); + match state.selected_action { + 0 => { + // Read from start + self.start_read(basin_name, stream_name, false, tx); + } + 1 => { + // Tail + self.start_read(basin_name, stream_name, true, tx); + } + _ => {} + } + } + KeyCode::Char('r') => { + let basin_name = state.basin_name.clone(); + let stream_name = state.stream_name.clone(); + self.start_read(basin_name, stream_name, false, tx); + } + KeyCode::Char('t') => { + let basin_name = state.basin_name.clone(); + let stream_name = state.stream_name.clone(); + self.start_read(basin_name, stream_name, true, tx); + } + _ => {} + } + } + + fn handle_read_view_key(&mut self, key: KeyEvent) { + let Screen::ReadView(state) = &mut self.screen else { + return; + }; + + match key.code { + KeyCode::Esc | KeyCode::Char('q') => { + // Go back to stream detail + self.screen = Screen::StreamDetail(StreamDetailState { + basin_name: state.basin_name.clone(), + stream_name: state.stream_name.clone(), + config: None, + tail_position: None, + selected_action: 0, + loading: false, + }); + } + KeyCode::Char(' ') => { + state.paused = !state.paused; + self.message = Some(StatusMessage { + text: if state.paused { + "Paused".to_string() + } else { + "Resumed".to_string() + }, + level: MessageLevel::Info, + }); + } + KeyCode::Up | KeyCode::Char('k') => { + if state.scroll_offset > 0 { + state.scroll_offset -= 1; + } + } + KeyCode::Down | KeyCode::Char('j') => { + let max_offset = state.records.len().saturating_sub(1); + if state.scroll_offset < max_offset { + state.scroll_offset += 1; + } + } + KeyCode::Char('g') => { + state.scroll_offset = 0; + } + KeyCode::Char('G') => { + state.scroll_offset = state.records.len().saturating_sub(1); + } + _ => {} + } + } + + fn load_basins(&self, tx: mpsc::UnboundedSender) { + let s2 = self.s2.clone(); + tokio::spawn(async move { + let args = ListBasinsArgs { + prefix: None, + start_after: None, + limit: Some(100), + no_auto_paginate: false, + }; + match ops::list_basins(&s2, args).await { + Ok(stream) => { + let basins: Vec<_> = stream + .take(100) + .filter_map(|r| async { r.ok() }) + .collect() + .await; + let _ = tx.send(Event::BasinsLoaded(Ok(basins))); + } + Err(e) => { + let _ = tx.send(Event::BasinsLoaded(Err(e))); + } + } + }); + } + + fn load_streams(&self, basin_name: BasinName, tx: mpsc::UnboundedSender) { + let s2 = self.s2.clone(); + tokio::spawn(async move { + let args = ListStreamsArgs { + uri: S2BasinAndMaybeStreamUri { + basin: basin_name, + stream: None, + }, + prefix: None, + start_after: None, + limit: Some(100), + no_auto_paginate: false, + }; + match ops::list_streams(&s2, args).await { + Ok(stream) => { + let streams: Vec<_> = stream + .take(100) + .filter_map(|r| async { r.ok() }) + .collect() + .await; + let _ = tx.send(Event::StreamsLoaded(Ok(streams))); + } + Err(e) => { + let _ = tx.send(Event::StreamsLoaded(Err(e))); + } + } + }); + } + + fn load_stream_detail( + &self, + basin_name: BasinName, + stream_name: StreamName, + tx: mpsc::UnboundedSender, + ) { + let s2 = self.s2.clone(); + let uri = S2BasinAndStreamUri { + basin: basin_name.clone(), + stream: stream_name.clone(), + }; + + // Load config + let tx_config = tx.clone(); + let uri_config = uri.clone(); + let s2_config = s2.clone(); + tokio::spawn(async move { + match ops::get_stream_config(&s2_config, uri_config).await { + Ok(config) => { + let _ = tx_config.send(Event::StreamConfigLoaded(Ok(config.into()))); + } + Err(e) => { + let _ = tx_config.send(Event::StreamConfigLoaded(Err(e))); + } + } + }); + + // Load tail position + let tx_tail = tx; + tokio::spawn(async move { + match ops::check_tail(&s2, uri).await { + Ok(pos) => { + let _ = tx_tail.send(Event::TailPositionLoaded(Ok(pos))); + } + Err(e) => { + let _ = tx_tail.send(Event::TailPositionLoaded(Err(e))); + } + } + }); + } + + fn create_basin(&mut self, name: String, tx: mpsc::UnboundedSender) { + let s2 = self.s2.clone(); + let tx_refresh = tx.clone(); + tokio::spawn(async move { + // Parse basin name + let basin_name: BasinName = match name.parse() { + Ok(n) => n, + Err(e) => { + let _ = tx.send(Event::BasinCreated(Err(CliError::RecordWrite(format!("Invalid basin name: {e}"))))); + return; + } + }; + let args = CreateBasinArgs { + basin: S2BasinUri(basin_name), + config: BasinConfig { + default_stream_config: StreamConfig::default(), + create_stream_on_append: false, + create_stream_on_read: false, + }, + }; + match ops::create_basin(&s2, args).await { + Ok(info) => { + let _ = tx.send(Event::BasinCreated(Ok(info))); + // Trigger refresh + let args = ListBasinsArgs { + prefix: None, + start_after: None, + limit: Some(100), + no_auto_paginate: false, + }; + if let Ok(stream) = ops::list_basins(&s2, args).await { + let basins: Vec<_> = stream + .take(100) + .filter_map(|r| async { r.ok() }) + .collect() + .await; + let _ = tx_refresh.send(Event::BasinsLoaded(Ok(basins))); + } + } + Err(e) => { + let _ = tx.send(Event::BasinCreated(Err(e))); + } + } + }); + } + + fn delete_basin(&mut self, basin: BasinName, tx: mpsc::UnboundedSender) { + let s2 = self.s2.clone(); + let tx_refresh = tx.clone(); + let name = basin.to_string(); + tokio::spawn(async move { + match ops::delete_basin(&s2, &basin).await { + Ok(()) => { + let _ = tx.send(Event::BasinDeleted(Ok(name))); + // Trigger refresh + let args = ListBasinsArgs { + prefix: None, + start_after: None, + limit: Some(100), + no_auto_paginate: false, + }; + if let Ok(stream) = ops::list_basins(&s2, args).await { + let basins: Vec<_> = stream + .take(100) + .filter_map(|r| async { r.ok() }) + .collect() + .await; + let _ = tx_refresh.send(Event::BasinsLoaded(Ok(basins))); + } + } + Err(e) => { + let _ = tx.send(Event::BasinDeleted(Err(e))); + } + } + }); + } + + fn create_stream(&mut self, basin: BasinName, name: String, tx: mpsc::UnboundedSender) { + let s2 = self.s2.clone(); + let tx_refresh = tx.clone(); + let basin_clone = basin.clone(); + tokio::spawn(async move { + // Parse stream name + let stream_name: StreamName = match name.parse() { + Ok(n) => n, + Err(e) => { + let _ = tx.send(Event::StreamCreated(Err(CliError::RecordWrite(format!("Invalid stream name: {e}"))))); + return; + } + }; + let args = CreateStreamArgs { + uri: S2BasinAndStreamUri { + basin: basin.clone(), + stream: stream_name, + }, + config: StreamConfig::default(), + }; + match ops::create_stream(&s2, args).await { + Ok(info) => { + let _ = tx.send(Event::StreamCreated(Ok(info))); + // Trigger refresh + let args = ListStreamsArgs { + uri: S2BasinAndMaybeStreamUri { + basin: basin_clone, + stream: None, + }, + prefix: None, + start_after: None, + limit: Some(100), + no_auto_paginate: false, + }; + if let Ok(stream) = ops::list_streams(&s2, args).await { + let streams: Vec<_> = stream + .take(100) + .filter_map(|r| async { r.ok() }) + .collect() + .await; + let _ = tx_refresh.send(Event::StreamsLoaded(Ok(streams))); + } + } + Err(e) => { + let _ = tx.send(Event::StreamCreated(Err(e))); + } + } + }); + } + + fn delete_stream(&mut self, basin: BasinName, stream: StreamName, tx: mpsc::UnboundedSender) { + let s2 = self.s2.clone(); + let tx_refresh = tx.clone(); + let name = stream.to_string(); + let basin_clone = basin.clone(); + tokio::spawn(async move { + let uri = S2BasinAndStreamUri { + basin: basin.clone(), + stream, + }; + match ops::delete_stream(&s2, uri).await { + Ok(()) => { + let _ = tx.send(Event::StreamDeleted(Ok(name))); + // Trigger refresh + let args = ListStreamsArgs { + uri: S2BasinAndMaybeStreamUri { + basin: basin_clone, + stream: None, + }, + prefix: None, + start_after: None, + limit: Some(100), + no_auto_paginate: false, + }; + if let Ok(stream) = ops::list_streams(&s2, args).await { + let streams: Vec<_> = stream + .take(100) + .filter_map(|r| async { r.ok() }) + .collect() + .await; + let _ = tx_refresh.send(Event::StreamsLoaded(Ok(streams))); + } + } + Err(e) => { + let _ = tx.send(Event::StreamDeleted(Err(e))); + } + } + }); + } + + fn start_read( + &mut self, + basin_name: BasinName, + stream_name: StreamName, + is_tailing: bool, + tx: mpsc::UnboundedSender, + ) { + self.screen = Screen::ReadView(ReadViewState { + basin_name: basin_name.clone(), + stream_name: stream_name.clone(), + records: VecDeque::new(), + is_tailing, + scroll_offset: 0, + paused: false, + loading: true, + }); + + let s2 = self.s2.clone(); + let uri = S2BasinAndStreamUri { + basin: basin_name, + stream: stream_name, + }; + + tokio::spawn(async move { + let args = TailArgs { + uri, + lines: if is_tailing { 10 } else { 100 }, + follow: is_tailing, + format: RecordFormat::Text, + output: RecordsOut::Stdout, + }; + + match ops::tail(&s2, &args).await { + Ok(mut stream) => { + while let Some(result) = stream.next().await { + match result { + Ok(record) => { + if tx.send(Event::RecordReceived(Ok(record))).is_err() { + break; + } + } + Err(e) => { + let _ = tx.send(Event::RecordReceived(Err(e))); + break; + } + } + } + let _ = tx.send(Event::ReadEnded); + } + Err(e) => { + let _ = tx.send(Event::Error(e)); + } + } + }); + } +} diff --git a/src/tui/event.rs b/src/tui/event.rs new file mode 100644 index 0000000..30bb272 --- /dev/null +++ b/src/tui/event.rs @@ -0,0 +1,41 @@ +use s2_sdk::types::{BasinInfo, SequencedRecord, StreamInfo, StreamPosition}; + +use crate::error::CliError; +use crate::types::StreamConfig; + +/// Events that can occur in the TUI +#[derive(Debug)] +pub enum Event { + /// Basins have been loaded from the API + BasinsLoaded(Result, CliError>), + + /// Streams have been loaded from the API + StreamsLoaded(Result, CliError>), + + /// Stream configuration loaded + StreamConfigLoaded(Result), + + /// Tail position loaded + TailPositionLoaded(Result), + + /// A record was received during read/tail + RecordReceived(Result), + + /// Read stream ended + ReadEnded, + + /// Basin created successfully + BasinCreated(Result), + + /// Basin deleted successfully + BasinDeleted(Result), + + /// Stream created successfully + StreamCreated(Result), + + /// Stream deleted successfully + StreamDeleted(Result), + + /// An error occurred in a background task + Error(CliError), +} diff --git a/src/tui/mod.rs b/src/tui/mod.rs new file mode 100644 index 0000000..09e1912 --- /dev/null +++ b/src/tui/mod.rs @@ -0,0 +1,50 @@ +mod app; +mod event; +mod screens; +mod ui; +mod widgets; + +use std::io; + +use crossterm::{ + event::{DisableMouseCapture, EnableMouseCapture}, + execute, + terminal::{EnterAlternateScreen, LeaveAlternateScreen, disable_raw_mode, enable_raw_mode}, +}; +use ratatui::{Terminal, prelude::CrosstermBackend}; + +use crate::config::{load_cli_config, sdk_config}; +use crate::error::CliError; +use app::App; + +pub async fn run() -> Result<(), CliError> { + // Load config and create SDK client + let cli_config = load_cli_config()?; + let sdk_config = sdk_config(&cli_config)?; + let s2 = s2_sdk::S2::new(sdk_config).map_err(CliError::SdkInit)?; + + // Setup terminal + enable_raw_mode().map_err(|e| CliError::RecordWrite(format!("Failed to enable raw mode: {e}")))?; + let mut stdout = io::stdout(); + execute!(stdout, EnterAlternateScreen, EnableMouseCapture) + .map_err(|e| CliError::RecordWrite(format!("Failed to setup terminal: {e}")))?; + let backend = CrosstermBackend::new(stdout); + let mut terminal = Terminal::new(backend) + .map_err(|e| CliError::RecordWrite(format!("Failed to create terminal: {e}")))?; + + // Create and run app + let app = App::new(s2); + let result = app.run(&mut terminal).await; + + // Restore terminal + disable_raw_mode().ok(); + execute!( + terminal.backend_mut(), + LeaveAlternateScreen, + DisableMouseCapture + ) + .ok(); + terminal.show_cursor().ok(); + + result +} diff --git a/src/tui/screens/mod.rs b/src/tui/screens/mod.rs new file mode 100644 index 0000000..5219e29 --- /dev/null +++ b/src/tui/screens/mod.rs @@ -0,0 +1,2 @@ +// Screen state types are defined in app.rs +// This module is reserved for future screen-specific logic diff --git a/src/tui/ui.rs b/src/tui/ui.rs new file mode 100644 index 0000000..88c08cd --- /dev/null +++ b/src/tui/ui.rs @@ -0,0 +1,912 @@ +use ratatui::{ + Frame, + layout::{Alignment, Constraint, Direction, Layout, Rect}, + style::{Color, Style, Stylize}, + text::{Line, Span}, + widgets::{Block, Borders, Clear, Padding, Paragraph, Wrap}, +}; + +use super::app::{App, BasinsState, InputMode, MessageLevel, ReadViewState, Screen, StreamDetailState, StreamsState}; + +// S2 Console dark theme +const GREEN: Color = Color::Rgb(34, 197, 94); // Active green +const GREEN_DIM: Color = Color::Rgb(22, 163, 74); // Dimmer green +const YELLOW: Color = Color::Rgb(250, 204, 21); // Warning yellow +const RED: Color = Color::Rgb(239, 68, 68); // Error red +const WHITE: Color = Color::Rgb(255, 255, 255); // Pure white +const GRAY_100: Color = Color::Rgb(243, 244, 246); // Near white +const GRAY_500: Color = Color::Rgb(107, 114, 128); // Muted gray +const BG_DARK: Color = Color::Rgb(17, 17, 17); // Main background +const BG_PANEL: Color = Color::Rgb(24, 24, 27); // Panel background +const BORDER: Color = Color::Rgb(63, 63, 70); // Border gray + +// Semantic aliases +const ACCENT: Color = WHITE; +const SUCCESS: Color = GREEN; +const WARNING: Color = YELLOW; +const ERROR: Color = RED; +const TEXT_PRIMARY: Color = WHITE; +const TEXT_SECONDARY: Color = GRAY_100; +const TEXT_MUTED: Color = GRAY_500; + +pub fn draw(f: &mut Frame, app: &App) { + // Clear with dark CRT background + let area = f.area(); + f.render_widget(Block::default().style(Style::default().bg(BG_DARK)), area); + + let chunks = Layout::default() + .direction(Direction::Vertical) + .margin(1) + .constraints([ + Constraint::Min(3), // Main content + Constraint::Length(1), // Status bar (slimmer) + ]) + .split(area); + + // Draw main content based on screen + match &app.screen { + Screen::Basins(state) => draw_basins(f, chunks[0], state), + Screen::Streams(state) => draw_streams(f, chunks[0], state), + Screen::StreamDetail(state) => draw_stream_detail(f, chunks[0], state), + Screen::ReadView(state) => draw_read_view(f, chunks[0], state), + } + + // Draw status bar + draw_status_bar(f, chunks[1], app); + + // Draw help overlay if visible + if app.show_help { + draw_help_overlay(f, &app.screen); + } + + // Draw input dialog if in input mode + if !matches!(app.input_mode, InputMode::Normal) { + draw_input_dialog(f, &app.input_mode); + } +} + +fn draw_basins(f: &mut Frame, area: Rect, state: &BasinsState) { + // Layout: Search bar, Header, Table rows + let chunks = Layout::default() + .direction(Direction::Vertical) + .constraints([ + Constraint::Length(3), // Search bar + Constraint::Length(2), // Header + Constraint::Min(1), // Table rows + ]) + .split(area); + + // === Search Bar === + let search_block = Block::default() + .borders(Borders::ALL) + .border_style(Style::default().fg(if state.filter_active { GREEN } else { BORDER })) + .style(Style::default().bg(BG_PANEL)); + + let search_text = if state.filter_active { + Line::from(vec![ + Span::styled(" 🔍 ", Style::default().fg(TEXT_MUTED)), + Span::styled(&state.filter, Style::default().fg(TEXT_PRIMARY)), + Span::styled("_", Style::default().fg(GREEN)), + ]) + } else if state.filter.is_empty() { + Line::from(vec![ + Span::styled(" 🔍 Filter by prefix... ", Style::default().fg(TEXT_MUTED)), + Span::styled("(press /)", Style::default().fg(BORDER)), + ]) + } else { + Line::from(vec![ + Span::styled(" 🔍 ", Style::default().fg(TEXT_MUTED)), + Span::styled(&state.filter, Style::default().fg(TEXT_PRIMARY)), + Span::styled(" (esc to clear)", Style::default().fg(BORDER)), + ]) + }; + + let search = Paragraph::new(search_text).block(search_block); + f.render_widget(search, chunks[0]); + + // === Table Header === + let header_area = chunks[1]; + // Calculate column widths: Name takes most space, State and Scope are fixed + let total_width = header_area.width as usize; + let state_col = 12; + let scope_col = 16; + let name_col = total_width.saturating_sub(state_col + scope_col + 4); + + let header = Line::from(vec![ + Span::styled(format!(" {: = state.basins.iter() + .filter(|b| state.filter.is_empty() || b.name.to_string().to_lowercase().contains(&state.filter.to_lowercase())) + .collect(); + + // === Table Rows === + let table_area = chunks[2]; + + if filtered.is_empty() && !state.loading { + let msg = if state.filter.is_empty() { + "No basins found. Press c to create one." + } else { + "No basins match the filter." + }; + let text = Paragraph::new(Span::styled(msg, Style::default().fg(TEXT_MUTED))) + .alignment(Alignment::Center); + f.render_widget(text, Rect::new(table_area.x, table_area.y + 2, table_area.width, 1)); + return; + } + + if state.loading { + let text = Paragraph::new(Span::styled("Loading basins...", Style::default().fg(TEXT_MUTED))) + .alignment(Alignment::Center); + f.render_widget(text, Rect::new(table_area.x, table_area.y + 2, table_area.width, 1)); + return; + } + + let visible_height = table_area.height as usize; + let total = filtered.len(); + let selected = state.selected.min(total.saturating_sub(1)); + + // Scroll offset + let scroll_offset = if selected >= visible_height { + selected - visible_height + 1 + } else { + 0 + }; + + // Draw rows + for (view_idx, basin) in filtered.iter().enumerate().skip(scroll_offset).take(visible_height) { + let y = table_area.y + (view_idx - scroll_offset) as u16; + if y >= table_area.y + table_area.height { + break; + } + + let is_selected = view_idx == selected; + let row_area = Rect::new(table_area.x, y, table_area.width, 1); + + // Selection highlight + if is_selected { + f.render_widget( + Block::default().style(Style::default().bg(Color::Rgb(39, 39, 42))), + row_area, + ); + } + + // Name column + let name = basin.name.to_string(); + let display_name = if name.len() > name_col - 2 { + format!("{}…", &name[..name_col - 3]) + } else { + name + }; + + // State badge + let (state_text, state_bg) = match basin.state { + s2_sdk::types::BasinState::Active => ("Active", Color::Rgb(22, 101, 52)), + s2_sdk::types::BasinState::Creating => ("Creating", Color::Rgb(113, 63, 18)), + s2_sdk::types::BasinState::Deleting => ("Deleting", Color::Rgb(127, 29, 29)), + }; + + // Scope + let scope = basin.scope.as_ref() + .map(|s| match s { s2_sdk::types::BasinScope::AwsUsEast1 => "aws:us-east-1" }) + .unwrap_or("—"); + + // Render name + let name_style = if is_selected { + Style::default().fg(TEXT_PRIMARY).bold() + } else { + Style::default().fg(TEXT_SECONDARY) + }; + f.render_widget( + Paragraph::new(Span::styled(format!(" {}", display_name), name_style)), + Rect::new(row_area.x, y, name_col as u16, 1), + ); + + // Render state badge + let badge_x = row_area.x + name_col as u16; + f.render_widget( + Paragraph::new(Span::styled( + format!(" {} ", state_text), + Style::default().fg(WHITE).bg(state_bg), + )), + Rect::new(badge_x, y, state_col as u16, 1), + ); + + // Render scope + let scope_x = badge_x + state_col as u16; + f.render_widget( + Paragraph::new(Span::styled(scope, Style::default().fg(TEXT_MUTED))), + Rect::new(scope_x, y, scope_col as u16, 1), + ); + } +} + +fn draw_streams(f: &mut Frame, area: Rect, state: &StreamsState) { + // Layout: Title bar, Search bar, Header, Table rows + let chunks = Layout::default() + .direction(Direction::Vertical) + .constraints([ + Constraint::Length(2), // Title bar with basin name + Constraint::Length(3), // Search bar + Constraint::Length(2), // Header + Constraint::Min(1), // Table rows + ]) + .split(area); + + // === Title Bar === + let count_text = if state.loading { + " loading...".to_string() + } else { + let filtered_count = state.streams.iter() + .filter(|s| state.filter.is_empty() || s.name.to_string().to_lowercase().contains(&state.filter.to_lowercase())) + .count(); + if filtered_count != state.streams.len() { + format!(" ({}/{} streams)", filtered_count, state.streams.len()) + } else { + format!(" ({} streams)", state.streams.len()) + } + }; + + let title = Line::from(vec![ + Span::styled(" ← ", Style::default().fg(TEXT_MUTED)), + Span::styled(&state.basin_name.to_string(), Style::default().fg(GREEN).bold()), + Span::styled(count_text, Style::default().fg(TEXT_MUTED)), + ]); + f.render_widget(Paragraph::new(title), chunks[0]); + + // === Search Bar === + let search_block = Block::default() + .borders(Borders::ALL) + .border_style(Style::default().fg(if state.filter_active { GREEN } else { BORDER })) + .style(Style::default().bg(BG_PANEL)); + + let search_text = if state.filter_active { + Line::from(vec![ + Span::styled(" 🔍 ", Style::default().fg(TEXT_MUTED)), + Span::styled(&state.filter, Style::default().fg(TEXT_PRIMARY)), + Span::styled("_", Style::default().fg(GREEN)), + ]) + } else if state.filter.is_empty() { + Line::from(vec![ + Span::styled(" 🔍 Filter by prefix... ", Style::default().fg(TEXT_MUTED)), + Span::styled("(press /)", Style::default().fg(BORDER)), + ]) + } else { + Line::from(vec![ + Span::styled(" 🔍 ", Style::default().fg(TEXT_MUTED)), + Span::styled(&state.filter, Style::default().fg(TEXT_PRIMARY)), + Span::styled(" (esc to clear)", Style::default().fg(BORDER)), + ]) + }; + + let search = Paragraph::new(search_text).block(search_block); + f.render_widget(search, chunks[1]); + + // === Table Header === + let header_area = chunks[2]; + let total_width = header_area.width as usize; + let created_col = 24; + let name_col = total_width.saturating_sub(created_col + 4); + + let header = Line::from(vec![ + Span::styled(format!(" {: = state.streams.iter() + .filter(|s| state.filter.is_empty() || s.name.to_string().to_lowercase().contains(&state.filter.to_lowercase())) + .collect(); + + // === Table Rows === + let table_area = chunks[3]; + + if filtered.is_empty() && !state.loading { + let msg = if state.filter.is_empty() { + "No streams found. Press c to create one." + } else { + "No streams match the filter." + }; + let text = Paragraph::new(Span::styled(msg, Style::default().fg(TEXT_MUTED))) + .alignment(Alignment::Center); + f.render_widget(text, Rect::new(table_area.x, table_area.y + 2, table_area.width, 1)); + return; + } + + if state.loading { + let text = Paragraph::new(Span::styled("Loading streams...", Style::default().fg(TEXT_MUTED))) + .alignment(Alignment::Center); + f.render_widget(text, Rect::new(table_area.x, table_area.y + 2, table_area.width, 1)); + return; + } + + let visible_height = table_area.height as usize; + let total = filtered.len(); + let selected = state.selected.min(total.saturating_sub(1)); + + // Scroll offset + let scroll_offset = if selected >= visible_height { + selected - visible_height + 1 + } else { + 0 + }; + + // Draw rows + for (view_idx, stream) in filtered.iter().enumerate().skip(scroll_offset).take(visible_height) { + let y = table_area.y + (view_idx - scroll_offset) as u16; + if y >= table_area.y + table_area.height { + break; + } + + let is_selected = view_idx == selected; + let row_area = Rect::new(table_area.x, y, table_area.width, 1); + + // Selection highlight + if is_selected { + f.render_widget( + Block::default().style(Style::default().bg(Color::Rgb(39, 39, 42))), + row_area, + ); + } + + // Name column + let name = stream.name.to_string(); + let display_name = if name.len() > name_col - 2 { + format!("{}…", &name[..name_col - 3]) + } else { + name + }; + + // Created timestamp + let created = stream.created_at + .map(|ts| { + // Convert milliseconds timestamp to readable format + let secs = ts / 1000; + let datetime = chrono::DateTime::from_timestamp(secs as i64, 0) + .map(|dt| dt.format("%Y-%m-%d %H:%M:%S").to_string()) + .unwrap_or_else(|| format!("{}", ts)); + datetime + }) + .unwrap_or_else(|| "—".to_string()); + + // Render name + let name_style = if is_selected { + Style::default().fg(TEXT_PRIMARY).bold() + } else { + Style::default().fg(TEXT_SECONDARY) + }; + f.render_widget( + Paragraph::new(Span::styled(format!(" {}", display_name), name_style)), + Rect::new(row_area.x, y, name_col as u16, 1), + ); + + // Render created timestamp + let created_x = row_area.x + name_col as u16; + f.render_widget( + Paragraph::new(Span::styled(created, Style::default().fg(TEXT_MUTED))), + Rect::new(created_x, y, created_col as u16, 1), + ); + } +} + +fn draw_stream_detail(f: &mut Frame, area: Rect, state: &StreamDetailState) { + let chunks = Layout::default() + .direction(Direction::Horizontal) + .constraints([ + Constraint::Percentage(50), // Info + Constraint::Percentage(50), // Actions + ]) + .split(area); + + // Left: Info panel + let uri = format!("s2://{}/{}", state.basin_name, state.stream_name); + let info_block = Block::default() + .title(Line::from(vec![ + Span::styled(" ", Style::default()), + Span::styled(&uri, Style::default().fg(GREEN).bold()), + Span::styled(" ", Style::default()), + ])) + .borders(Borders::ALL) + .border_style(Style::default().fg(BORDER)) + .style(Style::default().bg(BG_PANEL)) + .padding(Padding::new(2, 2, 1, 1)); + + let mut info_lines = vec![ + Line::from(""), + Line::from(vec![ + Span::styled("Stream", Style::default().fg(TEXT_MUTED)), + ]), + Line::from(vec![ + Span::styled(state.stream_name.to_string(), Style::default().fg(TEXT_PRIMARY).bold()), + ]), + Line::from(""), + Line::from(vec![ + Span::styled("Basin", Style::default().fg(TEXT_MUTED)), + ]), + Line::from(vec![ + Span::styled(state.basin_name.to_string(), Style::default().fg(TEXT_SECONDARY)), + ]), + Line::from(""), + ]; + + // Tail position + if let Some(pos) = &state.tail_position { + info_lines.push(Line::from(vec![ + Span::styled("Tail Position", Style::default().fg(TEXT_MUTED)), + ])); + info_lines.push(Line::from(vec![ + Span::styled(format!("{}", pos.seq_num), Style::default().fg(TEXT_PRIMARY).bold()), + Span::styled(" seq", Style::default().fg(TEXT_MUTED)), + ])); + info_lines.push(Line::from("")); + info_lines.push(Line::from(vec![ + Span::styled("Last Timestamp", Style::default().fg(TEXT_MUTED)), + ])); + info_lines.push(Line::from(vec![ + Span::styled(format!("{}", pos.timestamp), Style::default().fg(TEXT_SECONDARY)), + Span::styled(" ms", Style::default().fg(TEXT_MUTED)), + ])); + } else if state.loading { + info_lines.push(Line::from(vec![ + Span::styled("Loading...", Style::default().fg(TEXT_MUTED)), + ])); + } + + info_lines.push(Line::from("")); + + // Config + if let Some(config) = &state.config { + let storage = config + .storage_class + .as_ref() + .map(|s| format!("{:?}", s).to_lowercase()) + .unwrap_or_else(|| "default".to_string()); + + info_lines.push(Line::from(vec![ + Span::styled("Storage Class", Style::default().fg(TEXT_MUTED)), + ])); + info_lines.push(Line::from(vec![ + Span::styled(storage, Style::default().fg(TEXT_SECONDARY)), + ])); + } + + let info = Paragraph::new(info_lines).block(info_block); + f.render_widget(info, chunks[0]); + + // Right: Actions panel + let actions_block = Block::default() + .title(Line::from(Span::styled(" Actions ", Style::default().fg(TEXT_PRIMARY).bold()))) + .borders(Borders::ALL) + .border_style(Style::default().fg(BORDER)) + .padding(Padding::new(2, 2, 1, 1)); + + let actions = vec![ + ("r", "Read from beginning", "Fetch historical records from seq 0"), + ("t", "Tail stream", "Follow new records in real-time"), + ]; + + let mut action_lines = vec![Line::from("")]; + + for (i, (key, title, desc)) in actions.iter().enumerate() { + let is_selected = i == state.selected_action; + let indicator = if is_selected { ">" } else { " " }; + + action_lines.push(Line::from(vec![ + Span::styled(indicator, Style::default().fg(GREEN).bold()), + Span::raw(" "), + Span::styled( + format!("[{}]", key), + Style::default().fg(if is_selected { GREEN } else { GREEN_DIM }).bold(), + ), + Span::raw(" "), + Span::styled( + *title, + Style::default().fg(if is_selected { TEXT_PRIMARY } else { TEXT_SECONDARY }), + ), + ])); + action_lines.push(Line::from(vec![ + Span::styled( + format!(" {}", desc), + Style::default().fg(TEXT_MUTED), + ), + ])); + action_lines.push(Line::from("")); + } + + let actions_paragraph = Paragraph::new(action_lines).block(actions_block); + f.render_widget(actions_paragraph, chunks[1]); +} + +fn draw_read_view(f: &mut Frame, area: Rect, state: &ReadViewState) { + let (mode_text, mode_color) = if state.is_tailing { + if state.paused { + ("PAUSED", WARNING) + } else { + ("LIVE", SUCCESS) + } + } else { + ("READING", ACCENT) + }; + + let uri = format!("s2://{}/{}", state.basin_name, state.stream_name); + + let block = Block::default() + .title(Line::from(vec![ + Span::styled(" ", Style::default()), + Span::styled(mode_text, Style::default().fg(mode_color).bold()), + Span::styled(" ", Style::default()), + Span::styled(&uri, Style::default().fg(TEXT_SECONDARY)), + Span::styled( + format!(" {} records ", state.records.len()), + Style::default().fg(TEXT_MUTED), + ), + ])) + .borders(Borders::ALL) + .border_style(Style::default().fg(if state.is_tailing && !state.paused { GREEN } else { BORDER })) + .padding(Padding::horizontal(1)); + + if state.records.is_empty() { + let text = if state.loading { + Line::from(Span::styled("Waiting for records...", Style::default().fg(TEXT_MUTED))) + } else { + Line::from(Span::styled("No records", Style::default().fg(TEXT_MUTED))) + }; + let para = Paragraph::new(text).block(block); + f.render_widget(para, area); + return; + } + + // Calculate visible records + let inner_height = area.height.saturating_sub(2) as usize; + let total_records = state.records.len(); + let records_per_view = inner_height / 3; + + // Auto-scroll to bottom when tailing + let scroll_offset = if state.is_tailing && !state.paused { + total_records.saturating_sub(records_per_view) + } else { + state.scroll_offset.min(total_records.saturating_sub(1)) + }; + + let lines: Vec = state + .records + .iter() + .skip(scroll_offset) + .take(records_per_view + 1) + .flat_map(|record| { + let body = String::from_utf8_lossy(&record.body); + let body_preview: String = body.chars().take(200).collect(); + + vec![ + Line::from(vec![ + Span::styled( + format!("#{}", record.seq_num), + Style::default().fg(GREEN).bold(), + ), + Span::styled( + format!(" ts={}", record.timestamp), + Style::default().fg(TEXT_MUTED), + ), + ]), + Line::from(Span::styled(body_preview, Style::default().fg(TEXT_SECONDARY))), + Line::from(""), + ] + }) + .collect(); + + let para = Paragraph::new(lines) + .block(block) + .wrap(Wrap { trim: true }); + f.render_widget(para, area); +} + +fn draw_status_bar(f: &mut Frame, area: Rect, app: &App) { + let hints = match &app.screen { + Screen::Basins(_) => "/ search j/k navigate enter select c create d delete r refresh ? help q quit", + Screen::Streams(_) => "/ search j/k navigate enter select c create d delete r refresh esc back", + Screen::StreamDetail(_) => "j/k navigate enter select r read t tail esc back", + Screen::ReadView(s) => { + if s.is_tailing { + "space pause j/k scroll esc back" + } else { + "j/k scroll g/G top/bottom esc back" + } + } + }; + + let message_span = app + .message + .as_ref() + .map(|m| { + let color = match m.level { + MessageLevel::Info => ACCENT, + MessageLevel::Success => SUCCESS, + MessageLevel::Error => ERROR, + }; + Span::styled(&m.text, Style::default().fg(color)) + }); + + let line = if let Some(msg) = message_span { + Line::from(vec![ + msg, + Span::styled(" ", Style::default()), + Span::styled(hints, Style::default().fg(TEXT_MUTED)), + ]) + } else { + Line::from(Span::styled(hints, Style::default().fg(TEXT_MUTED))) + }; + + let status = Paragraph::new(line); + f.render_widget(status, area); +} + +fn draw_help_overlay(f: &mut Frame, screen: &Screen) { + let area = centered_rect(50, 50, f.area()); + + let help_text = match screen { + Screen::Basins(_) => vec![ + Line::from(""), + Line::from(vec![ + Span::styled(" j/k ", Style::default().fg(GREEN).bold()), + Span::styled("Navigate", Style::default().fg(TEXT_SECONDARY)), + ]), + Line::from(vec![ + Span::styled(" g/G ", Style::default().fg(GREEN).bold()), + Span::styled("Top / Bottom", Style::default().fg(TEXT_SECONDARY)), + ]), + Line::from(vec![ + Span::styled("enter ", Style::default().fg(GREEN).bold()), + Span::styled("Select basin", Style::default().fg(TEXT_SECONDARY)), + ]), + Line::from(vec![ + Span::styled(" c ", Style::default().fg(GREEN).bold()), + Span::styled("Create basin", Style::default().fg(TEXT_SECONDARY)), + ]), + Line::from(vec![ + Span::styled(" d ", Style::default().fg(GREEN).bold()), + Span::styled("Delete basin", Style::default().fg(TEXT_SECONDARY)), + ]), + Line::from(vec![ + Span::styled(" r ", Style::default().fg(GREEN).bold()), + Span::styled("Refresh", Style::default().fg(TEXT_SECONDARY)), + ]), + Line::from(vec![ + Span::styled(" q ", Style::default().fg(GREEN).bold()), + Span::styled("Quit", Style::default().fg(TEXT_SECONDARY)), + ]), + Line::from(""), + ], + Screen::Streams(_) => vec![ + Line::from(""), + Line::from(vec![ + Span::styled(" j/k ", Style::default().fg(GREEN).bold()), + Span::styled("Navigate", Style::default().fg(TEXT_SECONDARY)), + ]), + Line::from(vec![ + Span::styled("enter ", Style::default().fg(GREEN).bold()), + Span::styled("Select stream", Style::default().fg(TEXT_SECONDARY)), + ]), + Line::from(vec![ + Span::styled(" c ", Style::default().fg(GREEN).bold()), + Span::styled("Create stream", Style::default().fg(TEXT_SECONDARY)), + ]), + Line::from(vec![ + Span::styled(" d ", Style::default().fg(GREEN).bold()), + Span::styled("Delete stream", Style::default().fg(TEXT_SECONDARY)), + ]), + Line::from(vec![ + Span::styled(" r ", Style::default().fg(GREEN).bold()), + Span::styled("Refresh", Style::default().fg(TEXT_SECONDARY)), + ]), + Line::from(vec![ + Span::styled(" esc ", Style::default().fg(GREEN).bold()), + Span::styled("Back", Style::default().fg(TEXT_SECONDARY)), + ]), + Line::from(""), + ], + Screen::StreamDetail(_) => vec![ + Line::from(""), + Line::from(vec![ + Span::styled(" j/k ", Style::default().fg(GREEN).bold()), + Span::styled("Navigate actions", Style::default().fg(TEXT_SECONDARY)), + ]), + Line::from(vec![ + Span::styled("enter ", Style::default().fg(GREEN).bold()), + Span::styled("Execute action", Style::default().fg(TEXT_SECONDARY)), + ]), + Line::from(vec![ + Span::styled(" r ", Style::default().fg(GREEN).bold()), + Span::styled("Read from start", Style::default().fg(TEXT_SECONDARY)), + ]), + Line::from(vec![ + Span::styled(" t ", Style::default().fg(GREEN).bold()), + Span::styled("Tail (live follow)", Style::default().fg(TEXT_SECONDARY)), + ]), + Line::from(vec![ + Span::styled(" esc ", Style::default().fg(GREEN).bold()), + Span::styled("Back", Style::default().fg(TEXT_SECONDARY)), + ]), + Line::from(""), + ], + Screen::ReadView(_) => vec![ + Line::from(""), + Line::from(vec![ + Span::styled(" j/k ", Style::default().fg(GREEN).bold()), + Span::styled("Scroll", Style::default().fg(TEXT_SECONDARY)), + ]), + Line::from(vec![ + Span::styled(" g/G ", Style::default().fg(GREEN).bold()), + Span::styled("Top / Bottom", Style::default().fg(TEXT_SECONDARY)), + ]), + Line::from(vec![ + Span::styled("space ", Style::default().fg(GREEN).bold()), + Span::styled("Pause / Resume", Style::default().fg(TEXT_SECONDARY)), + ]), + Line::from(vec![ + Span::styled(" esc ", Style::default().fg(GREEN).bold()), + Span::styled("Back", Style::default().fg(TEXT_SECONDARY)), + ]), + Line::from(""), + ], + }; + + let block = Block::default() + .title(Line::from(Span::styled(" Help ", Style::default().fg(TEXT_PRIMARY).bold()))) + .borders(Borders::ALL) + .border_style(Style::default().fg(ACCENT)) + .style(Style::default().bg(BG_DARK)); + + let help = Paragraph::new(help_text).block(block); + + f.render_widget(Clear, area); + f.render_widget(help, area); +} + +fn centered_rect(percent_x: u16, percent_y: u16, area: Rect) -> Rect { + let popup_layout = Layout::default() + .direction(Direction::Vertical) + .constraints([ + Constraint::Percentage((100 - percent_y) / 2), + Constraint::Percentage(percent_y), + Constraint::Percentage((100 - percent_y) / 2), + ]) + .split(area); + + Layout::default() + .direction(Direction::Horizontal) + .constraints([ + Constraint::Percentage((100 - percent_x) / 2), + Constraint::Percentage(percent_x), + Constraint::Percentage((100 - percent_x) / 2), + ]) + .split(popup_layout[1])[1] +} + +fn draw_input_dialog(f: &mut Frame, mode: &InputMode) { + let (title, content, hint) = match mode { + InputMode::Normal => return, + + InputMode::CreateBasin { input } => ( + " Create Basin ", + vec![ + Line::from(""), + Line::from(vec![ + Span::styled("Name: ", Style::default().fg(TEXT_MUTED)), + Span::styled(input, Style::default().fg(TEXT_PRIMARY)), + Span::styled("_", Style::default().fg(GREEN)), + ]), + Line::from(""), + Line::from(vec![ + Span::styled("8-48 chars: lowercase, numbers, hyphens", Style::default().fg(TEXT_MUTED)), + ]), + ], + "enter confirm esc cancel", + ), + + InputMode::CreateStream { basin, input } => ( + " Create Stream ", + vec![ + Line::from(""), + Line::from(vec![ + Span::styled("Basin: ", Style::default().fg(TEXT_MUTED)), + Span::styled(basin.to_string(), Style::default().fg(TEXT_SECONDARY)), + ]), + Line::from(""), + Line::from(vec![ + Span::styled("Stream: ", Style::default().fg(TEXT_MUTED)), + Span::styled(input, Style::default().fg(TEXT_PRIMARY)), + Span::styled("_", Style::default().fg(GREEN)), + ]), + ], + "enter confirm esc cancel", + ), + + InputMode::ConfirmDeleteBasin { basin } => ( + " Delete Basin ", + vec![ + Line::from(""), + Line::from(vec![ + Span::styled("Delete basin ", Style::default().fg(TEXT_SECONDARY)), + Span::styled(basin.to_string(), Style::default().fg(ERROR).bold()), + Span::styled("?", Style::default().fg(TEXT_SECONDARY)), + ]), + Line::from(""), + Line::from(vec![ + Span::styled("This will delete all streams in this basin.", Style::default().fg(TEXT_MUTED)), + ]), + Line::from(vec![ + Span::styled("This action cannot be undone.", Style::default().fg(ERROR)), + ]), + ], + "y confirm n/esc cancel", + ), + + InputMode::ConfirmDeleteStream { basin, stream } => ( + " Delete Stream ", + vec![ + Line::from(""), + Line::from(vec![ + Span::styled("Delete stream ", Style::default().fg(TEXT_SECONDARY)), + Span::styled(stream.to_string(), Style::default().fg(ERROR).bold()), + Span::styled("?", Style::default().fg(TEXT_SECONDARY)), + ]), + Line::from(""), + Line::from(vec![ + Span::styled("from basin ", Style::default().fg(TEXT_MUTED)), + Span::styled(basin.to_string(), Style::default().fg(TEXT_SECONDARY)), + ]), + Line::from(""), + Line::from(vec![ + Span::styled("This action cannot be undone.", Style::default().fg(ERROR)), + ]), + ], + "y confirm n/esc cancel", + ), + }; + + let area = centered_rect(50, 40, f.area()); + + let block = Block::default() + .title(Line::from(Span::styled(title, Style::default().fg(TEXT_PRIMARY).bold()))) + .borders(Borders::ALL) + .border_style(Style::default().fg(GREEN)) + .style(Style::default().bg(BG_DARK)) + .padding(Padding::horizontal(2)); + + // Split area for content and hint + let chunks = Layout::default() + .direction(Direction::Vertical) + .constraints([ + Constraint::Min(1), + Constraint::Length(1), + ]) + .split(area); + + f.render_widget(Clear, area); + + let dialog = Paragraph::new(content).block(block); + f.render_widget(dialog, chunks[0]); + + let hint_line = Line::from(Span::styled(hint, Style::default().fg(TEXT_MUTED))); + let hint_para = Paragraph::new(hint_line).alignment(Alignment::Center); + f.render_widget(hint_para, chunks[1]); +} diff --git a/src/tui/widgets/mod.rs b/src/tui/widgets/mod.rs new file mode 100644 index 0000000..1208784 --- /dev/null +++ b/src/tui/widgets/mod.rs @@ -0,0 +1,2 @@ +// Widget types are currently implemented inline in ui.rs +// This module is reserved for future reusable widgets From b1807438680175a05b1a44f0fa22eba7bd4e52f5 Mon Sep 17 00:00:00 2001 From: Mehul Arora Date: Thu, 22 Jan 2026 22:14:18 -0500 Subject: [PATCH 02/31] . --- Cargo.lock | 100 ----- Cargo.toml | 1 - src/ops.rs | 6 +- src/tui/app.rs | 1063 ++++++++++++++++++++++++++++++++++++++++++++-- src/tui/event.rs | 35 +- src/tui/ui.rs | 389 +++++++++++++++-- 6 files changed, 1426 insertions(+), 168 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 88c3385..2f23258 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -32,15 +32,6 @@ version = "0.2.21" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "683d7910e743518b0e34f1186f92494becacb047c7b6bf616c96772180fef923" -[[package]] -name = "android_system_properties" -version = "0.1.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "819e7219dbd41043ac279b19830f2efc897156490d7fd6ea916720117ee66311" -dependencies = [ - "libc", -] - [[package]] name = "anstream" version = "0.6.21" @@ -351,19 +342,6 @@ version = "0.2.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "613afe47fcd5fac7ccf1db93babcb082c5994d996f20b8b159f2ad1658eb5724" -[[package]] -name = "chrono" -version = "0.4.43" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "fac4744fb15ae8337dc853fee7fb3f4e48c0fbaa23d0afe49c447b4fab126118" -dependencies = [ - "iana-time-zone", - "js-sys", - "num-traits", - "wasm-bindgen", - "windows-link", -] - [[package]] name = "clap" version = "4.5.54" @@ -1226,30 +1204,6 @@ dependencies = [ "tracing", ] -[[package]] -name = "iana-time-zone" -version = "0.1.64" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "33e57f83510bb73707521ebaffa789ec8caf86f9657cad665b092b581d40e9fb" -dependencies = [ - "android_system_properties", - "core-foundation-sys", - "iana-time-zone-haiku", - "js-sys", - "log", - "wasm-bindgen", - "windows-core", -] - -[[package]] -name = "iana-time-zone-haiku" -version = "0.1.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f31827a206f56af32e590ba56d5d2d085f558508192593743f16b2306495269f" -dependencies = [ - "cc", -] - [[package]] name = "icu_collections" version = "2.1.1" @@ -2464,7 +2418,6 @@ dependencies = [ "async-stream", "base64ct", "bytes", - "chrono", "clap", "color-print", "colored", @@ -3659,65 +3612,12 @@ version = "0.4.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "712e227841d057c1ee1cd2fb22fa7e5a5461ae8e48fa2ca79ec42cfc1931183f" -[[package]] -name = "windows-core" -version = "0.62.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b8e83a14d34d0623b51dce9581199302a221863196a1dde71a7663a4c2be9deb" -dependencies = [ - "windows-implement", - "windows-interface", - "windows-link", - "windows-result", - "windows-strings", -] - -[[package]] -name = "windows-implement" -version = "0.60.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "053e2e040ab57b9dc951b72c264860db7eb3b0200ba345b4e4c3b14f67855ddf" -dependencies = [ - "proc-macro2", - "quote", - "syn", -] - -[[package]] -name = "windows-interface" -version = "0.59.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3f316c4a2570ba26bbec722032c4099d8c8bc095efccdc15688708623367e358" -dependencies = [ - "proc-macro2", - "quote", - "syn", -] - [[package]] name = "windows-link" version = "0.2.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f0805222e57f7521d6a62e36fa9163bc891acd422f971defe97d64e70d0a4fe5" -[[package]] -name = "windows-result" -version = "0.4.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7781fa89eaf60850ac3d2da7af8e5242a5ea78d1a11c49bf2910bb5a73853eb5" -dependencies = [ - "windows-link", -] - -[[package]] -name = "windows-strings" -version = "0.5.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7837d08f69c77cf6b07689544538e017c1bfcf57e34b4c0ff58e6c2cd3b37091" -dependencies = [ - "windows-link", -] - [[package]] name = "windows-sys" version = "0.45.0" diff --git a/Cargo.toml b/Cargo.toml index 3583bcb..aaadc1c 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -14,7 +14,6 @@ path = "src/main.rs" [dependencies] async-stream = "0.3.6" -chrono = "0.4" base64ct = { version = "1.8.3", features = ["alloc"] } bytes = "1.11.0" clap = { version = "4.5.54", features = ["derive"] } diff --git a/src/ops.rs b/src/ops.rs index 8bd03f4..d4adfdc 100644 --- a/src/ops.rs +++ b/src/ops.rs @@ -562,7 +562,11 @@ pub async fn tail( let uri = args.uri.clone(); let stream = s2.basin(uri.basin).stream(uri.stream); - let start = ReadStart::new().with_from(ReadFrom::TailOffset(args.lines)); + // Use clamp_to_tail to handle empty streams gracefully - if we ask for + // TailOffset(10) but there are fewer records, clamp to the actual start + let start = ReadStart::new() + .with_from(ReadFrom::TailOffset(args.lines)) + .with_clamp_to_tail(true); let stop = if args.follow { ReadStop::new() } else { diff --git a/src/tui/app.rs b/src/tui/app.rs index c439247..d521692 100644 --- a/src/tui/app.rs +++ b/src/tui/app.rs @@ -7,13 +7,13 @@ use ratatui::{Terminal, prelude::Backend}; use s2_sdk::types::{BasinInfo, BasinName, StreamInfo, StreamName, StreamPosition}; use tokio::sync::mpsc; -use crate::cli::{CreateBasinArgs, CreateStreamArgs, ListBasinsArgs, ListStreamsArgs, TailArgs}; +use crate::cli::{CreateBasinArgs, CreateStreamArgs, ListBasinsArgs, ListStreamsArgs, ReadArgs, ReconfigureBasinArgs, ReconfigureStreamArgs}; use crate::error::CliError; use crate::ops; use crate::record_format::{RecordFormat, RecordsOut}; -use crate::types::{BasinConfig, S2BasinAndMaybeStreamUri, S2BasinAndStreamUri, S2BasinUri, StreamConfig}; +use crate::types::{BasinConfig, S2BasinAndMaybeStreamUri, S2BasinAndStreamUri, S2BasinUri, StorageClass, StreamConfig, TimestampingMode}; -use super::event::Event; +use super::event::{BasinConfigInfo, Event, StreamConfigInfo}; use super::ui; /// Maximum records to keep in read view buffer @@ -100,6 +100,158 @@ pub enum InputMode { ConfirmDeleteBasin { basin: BasinName }, /// Confirming stream deletion ConfirmDeleteStream { basin: BasinName, stream: StreamName }, + /// Reconfiguring a basin + ReconfigureBasin { + basin: BasinName, + // Basin-level settings + create_stream_on_append: Option, + create_stream_on_read: Option, + // Default stream config + storage_class: Option, + retention_policy: RetentionPolicyOption, + retention_age_secs: u64, + timestamping_mode: Option, + timestamping_uncapped: Option, + // UI state + selected: usize, + editing_age: bool, + age_input: String, + }, + /// Reconfiguring a stream + ReconfigureStream { + basin: BasinName, + stream: StreamName, + storage_class: Option, + retention_policy: RetentionPolicyOption, + retention_age_secs: u64, + timestamping_mode: Option, + timestamping_uncapped: Option, + // UI state + selected: usize, + editing_age: bool, + age_input: String, + }, + /// Custom read configuration + CustomRead { + basin: BasinName, + stream: StreamName, + // Start position + start_from: ReadStartFrom, + seq_num_value: String, + timestamp_value: String, + ago_value: String, + ago_unit: AgoUnit, + tail_offset_value: String, + // Limits + count_limit: String, + byte_limit: String, + // Options + until_timestamp: String, + // UI state + selected: usize, + editing: bool, + }, +} + +/// Retention policy option for UI +#[derive(Debug, Clone, Copy, PartialEq)] +pub enum RetentionPolicyOption { + Infinite, + Age, +} + +impl Default for RetentionPolicyOption { + fn default() -> Self { + Self::Infinite + } +} + +/// Start position for read operation +#[derive(Debug, Clone, Copy, PartialEq)] +pub enum ReadStartFrom { + /// From current tail (live follow, no historical) + Tail, + /// From specific sequence number + SeqNum, + /// From specific timestamp (ms) + Timestamp, + /// From N time ago + Ago, + /// From N records before tail + TailOffset, +} + +impl Default for ReadStartFrom { + fn default() -> Self { + Self::Tail + } +} + +/// Time unit for "ago" option +#[derive(Debug, Clone, Copy, PartialEq)] +pub enum AgoUnit { + Seconds, + Minutes, + Hours, + Days, +} + +impl AgoUnit { + pub fn as_str(&self) -> &'static str { + match self { + AgoUnit::Seconds => "s", + AgoUnit::Minutes => "m", + AgoUnit::Hours => "h", + AgoUnit::Days => "d", + } + } + + pub fn to_seconds(&self, value: u64) -> u64 { + match self { + AgoUnit::Seconds => value, + AgoUnit::Minutes => value * 60, + AgoUnit::Hours => value * 3600, + AgoUnit::Days => value * 86400, + } + } + + pub fn next(&self) -> Self { + match self { + AgoUnit::Seconds => AgoUnit::Minutes, + AgoUnit::Minutes => AgoUnit::Hours, + AgoUnit::Hours => AgoUnit::Days, + AgoUnit::Days => AgoUnit::Seconds, + } + } +} + +impl Default for AgoUnit { + fn default() -> Self { + Self::Minutes + } +} + + +/// Config for basin reconfiguration +#[derive(Debug, Clone)] +pub struct BasinReconfigureConfig { + pub create_stream_on_append: Option, + pub create_stream_on_read: Option, + pub storage_class: Option, + pub retention_policy: RetentionPolicyOption, + pub retention_age_secs: u64, + pub timestamping_mode: Option, + pub timestamping_uncapped: Option, +} + +/// Config for stream reconfiguration +#[derive(Debug, Clone)] +pub struct StreamReconfigureConfig { + pub storage_class: Option, + pub retention_policy: RetentionPolicyOption, + pub retention_age_secs: u64, + pub timestamping_mode: Option, + pub timestamping_uncapped: Option, } impl Default for InputMode { @@ -374,6 +526,114 @@ impl App { } } + Event::BasinConfigLoaded(result) => { + if let InputMode::ReconfigureBasin { + create_stream_on_append, + create_stream_on_read, + storage_class, + retention_policy, + retention_age_secs, + timestamping_mode, + timestamping_uncapped, + age_input, + .. + } = &mut self.input_mode { + match result { + Ok(info) => { + *create_stream_on_append = Some(info.create_stream_on_append); + *create_stream_on_read = Some(info.create_stream_on_read); + *storage_class = info.storage_class; + if let Some(age) = info.retention_age_secs { + *retention_policy = RetentionPolicyOption::Age; + *retention_age_secs = age; + *age_input = age.to_string(); + } else { + *retention_policy = RetentionPolicyOption::Infinite; + } + *timestamping_mode = info.timestamping_mode; + *timestamping_uncapped = Some(info.timestamping_uncapped); + } + Err(e) => { + self.input_mode = InputMode::Normal; + self.message = Some(StatusMessage { + text: format!("Failed to load basin config: {e}"), + level: MessageLevel::Error, + }); + } + } + } + } + + Event::StreamConfigForReconfigLoaded(result) => { + if let InputMode::ReconfigureStream { + storage_class, + retention_policy, + retention_age_secs, + timestamping_mode, + timestamping_uncapped, + age_input, + .. + } = &mut self.input_mode { + match result { + Ok(info) => { + *storage_class = info.storage_class; + if let Some(age) = info.retention_age_secs { + *retention_policy = RetentionPolicyOption::Age; + *retention_age_secs = age; + *age_input = age.to_string(); + } else { + *retention_policy = RetentionPolicyOption::Infinite; + } + *timestamping_mode = info.timestamping_mode; + *timestamping_uncapped = Some(info.timestamping_uncapped); + } + Err(e) => { + self.input_mode = InputMode::Normal; + self.message = Some(StatusMessage { + text: format!("Failed to load stream config: {e}"), + level: MessageLevel::Error, + }); + } + } + } + } + + Event::BasinReconfigured(result) => { + self.input_mode = InputMode::Normal; + match result { + Ok(()) => { + self.message = Some(StatusMessage { + text: "Basin reconfigured".to_string(), + level: MessageLevel::Success, + }); + } + Err(e) => { + self.message = Some(StatusMessage { + text: format!("Failed to reconfigure basin: {e}"), + level: MessageLevel::Error, + }); + } + } + } + + Event::StreamReconfigured(result) => { + self.input_mode = InputMode::Normal; + match result { + Ok(()) => { + self.message = Some(StatusMessage { + text: "Stream reconfigured".to_string(), + level: MessageLevel::Success, + }); + } + Err(e) => { + self.message = Some(StatusMessage { + text: format!("Failed to reconfigure stream: {e}"), + level: MessageLevel::Error, + }); + } + } + } + Event::Error(e) => { self.message = Some(StatusMessage { text: e.to_string(), @@ -506,6 +766,345 @@ impl App { _ => {} } } + + InputMode::ReconfigureBasin { + basin, + create_stream_on_append, + create_stream_on_read, + storage_class, + retention_policy, + retention_age_secs, + timestamping_mode, + timestamping_uncapped, + selected, + editing_age, + age_input, + } => { + // If editing age, handle number input + if *editing_age { + match key.code { + KeyCode::Esc | KeyCode::Enter => { + // Parse and apply the age + if let Ok(secs) = age_input.parse::() { + *retention_age_secs = secs; + } + *editing_age = false; + } + KeyCode::Backspace => { + age_input.pop(); + } + KeyCode::Char(c) if c.is_ascii_digit() => { + age_input.push(c); + } + _ => {} + } + return; + } + + // Basin has 7 rows: append, read, storage, retention_type, retention_age, ts_mode, ts_uncapped + const BASIN_MAX_ROW: usize = 6; + + match key.code { + KeyCode::Esc => { + self.input_mode = InputMode::Normal; + } + KeyCode::Up | KeyCode::Char('k') => { + if *selected > 0 { + *selected -= 1; + } + } + KeyCode::Down | KeyCode::Char('j') => { + if *selected < BASIN_MAX_ROW { + *selected += 1; + } + } + KeyCode::Char(' ') | KeyCode::Enter => { + match *selected { + 0 => *create_stream_on_append = Some(!create_stream_on_append.unwrap_or(false)), + 1 => *create_stream_on_read = Some(!create_stream_on_read.unwrap_or(false)), + 2 => { + // Cycle storage class + *storage_class = match storage_class { + None => Some(StorageClass::Express), + Some(StorageClass::Express) => Some(StorageClass::Standard), + Some(StorageClass::Standard) => None, + }; + } + 3 => { + // Toggle retention policy + *retention_policy = match retention_policy { + RetentionPolicyOption::Infinite => RetentionPolicyOption::Age, + RetentionPolicyOption::Age => RetentionPolicyOption::Infinite, + }; + } + 4 => { + // Edit retention age + if *retention_policy == RetentionPolicyOption::Age { + *editing_age = true; + *age_input = retention_age_secs.to_string(); + } + } + 5 => { + // Cycle timestamping mode + *timestamping_mode = match timestamping_mode { + None => Some(TimestampingMode::ClientPrefer), + Some(TimestampingMode::ClientPrefer) => Some(TimestampingMode::ClientRequire), + Some(TimestampingMode::ClientRequire) => Some(TimestampingMode::Arrival), + Some(TimestampingMode::Arrival) => None, + }; + } + 6 => *timestamping_uncapped = Some(!timestamping_uncapped.unwrap_or(false)), + _ => {} + } + } + KeyCode::Char('s') => { + let b = basin.clone(); + let config = BasinReconfigureConfig { + create_stream_on_append: *create_stream_on_append, + create_stream_on_read: *create_stream_on_read, + storage_class: storage_class.clone(), + retention_policy: *retention_policy, + retention_age_secs: *retention_age_secs, + timestamping_mode: timestamping_mode.clone(), + timestamping_uncapped: *timestamping_uncapped, + }; + self.reconfigure_basin(b, config, tx.clone()); + } + _ => {} + } + } + + InputMode::ReconfigureStream { + basin, + stream, + storage_class, + retention_policy, + retention_age_secs, + timestamping_mode, + timestamping_uncapped, + selected, + editing_age, + age_input, + } => { + // If editing age, handle number input + if *editing_age { + match key.code { + KeyCode::Esc | KeyCode::Enter => { + if let Ok(secs) = age_input.parse::() { + *retention_age_secs = secs; + } + *editing_age = false; + } + KeyCode::Backspace => { + age_input.pop(); + } + KeyCode::Char(c) if c.is_ascii_digit() => { + age_input.push(c); + } + _ => {} + } + return; + } + + // Stream has 5 rows: storage, retention_type, retention_age, ts_mode, ts_uncapped + const STREAM_MAX_ROW: usize = 4; + + match key.code { + KeyCode::Esc => { + self.input_mode = InputMode::Normal; + } + KeyCode::Up | KeyCode::Char('k') => { + if *selected > 0 { + *selected -= 1; + } + } + KeyCode::Down | KeyCode::Char('j') => { + if *selected < STREAM_MAX_ROW { + *selected += 1; + } + } + KeyCode::Char(' ') | KeyCode::Enter => { + match *selected { + 0 => { + *storage_class = match storage_class { + None => Some(StorageClass::Express), + Some(StorageClass::Express) => Some(StorageClass::Standard), + Some(StorageClass::Standard) => None, + }; + } + 1 => { + *retention_policy = match retention_policy { + RetentionPolicyOption::Infinite => RetentionPolicyOption::Age, + RetentionPolicyOption::Age => RetentionPolicyOption::Infinite, + }; + } + 2 => { + if *retention_policy == RetentionPolicyOption::Age { + *editing_age = true; + *age_input = retention_age_secs.to_string(); + } + } + 3 => { + *timestamping_mode = match timestamping_mode { + None => Some(TimestampingMode::ClientPrefer), + Some(TimestampingMode::ClientPrefer) => Some(TimestampingMode::ClientRequire), + Some(TimestampingMode::ClientRequire) => Some(TimestampingMode::Arrival), + Some(TimestampingMode::Arrival) => None, + }; + } + 4 => *timestamping_uncapped = Some(!timestamping_uncapped.unwrap_or(false)), + _ => {} + } + } + KeyCode::Char('s') => { + let b = basin.clone(); + let s = stream.clone(); + let config = StreamReconfigureConfig { + storage_class: storage_class.clone(), + retention_policy: *retention_policy, + retention_age_secs: *retention_age_secs, + timestamping_mode: timestamping_mode.clone(), + timestamping_uncapped: *timestamping_uncapped, + }; + self.reconfigure_stream(b, s, config, tx.clone()); + } + _ => {} + } + } + + InputMode::CustomRead { + basin, + stream, + start_from, + seq_num_value, + timestamp_value, + ago_value, + ago_unit, + tail_offset_value, + count_limit, + byte_limit, + until_timestamp, + selected, + editing, + } => { + // If editing a value, handle text input + if *editing { + match key.code { + KeyCode::Esc => { + *editing = false; + } + KeyCode::Enter => { + *editing = false; + } + KeyCode::Backspace => { + match *selected { + 1 => { seq_num_value.pop(); } + 2 => { timestamp_value.pop(); } + 3 => { ago_value.pop(); } + 5 => { tail_offset_value.pop(); } + 6 => { count_limit.pop(); } + 7 => { byte_limit.pop(); } + 8 => { until_timestamp.pop(); } + _ => {} + } + } + KeyCode::Char(c) if c.is_ascii_digit() => { + match *selected { + 1 => seq_num_value.push(c), + 2 => timestamp_value.push(c), + 3 => ago_value.push(c), + 5 => tail_offset_value.push(c), + 6 => count_limit.push(c), + 7 => byte_limit.push(c), + 8 => until_timestamp.push(c), + _ => {} + } + } + _ => {} + } + return; + } + + // Navigation and selection + // Rows: + // 0: start_from selector + // 1: seq_num (if SeqNum) + // 2: timestamp (if Timestamp) + // 3: ago value (if Ago) + // 4: ago unit (if Ago) + // 5: tail_offset (if TailOffset) + // 6: count_limit + // 7: byte_limit + // 8: until_timestamp + // 9: [Start Reading] button + const MAX_ROW: usize = 9; + + match key.code { + KeyCode::Esc => { + self.input_mode = InputMode::Normal; + } + KeyCode::Up | KeyCode::Char('k') => { + if *selected > 0 { + *selected -= 1; + } + } + KeyCode::Down | KeyCode::Char('j') => { + if *selected < MAX_ROW { + *selected += 1; + } + } + KeyCode::Char(' ') | KeyCode::Enter => { + match *selected { + 0 => { + // Cycle start_from + *start_from = match start_from { + ReadStartFrom::Tail => ReadStartFrom::SeqNum, + ReadStartFrom::SeqNum => ReadStartFrom::Timestamp, + ReadStartFrom::Timestamp => ReadStartFrom::Ago, + ReadStartFrom::Ago => ReadStartFrom::TailOffset, + ReadStartFrom::TailOffset => ReadStartFrom::Tail, + }; + } + 1 if *start_from == ReadStartFrom::SeqNum => { + *editing = true; + } + 2 if *start_from == ReadStartFrom::Timestamp => { + *editing = true; + } + 3 if *start_from == ReadStartFrom::Ago => { + *editing = true; + } + 4 if *start_from == ReadStartFrom::Ago => { + *ago_unit = ago_unit.next(); + } + 5 if *start_from == ReadStartFrom::TailOffset => { + *editing = true; + } + 6 => *editing = true, // count_limit + 7 => *editing = true, // byte_limit + 8 => *editing = true, // until_timestamp + 9 => { + // Start reading - clone all values first + let b = basin.clone(); + let s = stream.clone(); + let sf = *start_from; + let snv = seq_num_value.clone(); + let tsv = timestamp_value.clone(); + let agv = ago_value.clone(); + let agu = *ago_unit; + let tov = tail_offset_value.clone(); + let cl = count_limit.clone(); + let bl = byte_limit.clone(); + let ut = until_timestamp.clone(); + self.input_mode = InputMode::Normal; + self.start_custom_read(b, s, sf, snv, tsv, agv, agu, tov, cl, bl, ut, tx.clone()); + } + _ => {} + } + } + _ => {} + } + } } } @@ -596,6 +1195,26 @@ impl App { }; } } + KeyCode::Char('e') => { + if let Some(basin) = filtered.get(state.selected) { + let basin_name = basin.name.clone(); + self.input_mode = InputMode::ReconfigureBasin { + basin: basin_name.clone(), + create_stream_on_append: None, + create_stream_on_read: None, + storage_class: None, + retention_policy: RetentionPolicyOption::Infinite, + retention_age_secs: 604800, // 1 week default + timestamping_mode: None, + timestamping_uncapped: None, + selected: 0, + editing_age: false, + age_input: String::new(), + }; + // Load current config + self.load_basin_config(basin_name, tx); + } + } KeyCode::Esc => { if !state.filter.is_empty() { state.filter.clear(); @@ -718,6 +1337,26 @@ impl App { }; } } + KeyCode::Char('e') => { + if let Some(stream) = filtered.get(state.selected) { + let basin_name = state.basin_name.clone(); + let stream_name = stream.name.clone(); + self.input_mode = InputMode::ReconfigureStream { + basin: basin_name.clone(), + stream: stream_name.clone(), + storage_class: None, + retention_policy: RetentionPolicyOption::Infinite, + retention_age_secs: 604800, + timestamping_mode: None, + timestamping_uncapped: None, + selected: 0, + editing_age: false, + age_input: String::new(), + }; + // Load current config + self.load_stream_config_for_reconfig(basin_name, stream_name, tx); + } + } _ => {} } } @@ -735,6 +1374,8 @@ impl App { streams: Vec::new(), selected: 0, loading: true, + filter: String::new(), + filter_active: false, }); self.load_streams(basin_name, tx); } @@ -745,7 +1386,7 @@ impl App { } KeyCode::Down | KeyCode::Char('j') => { if state.selected_action < 1 { - // 2 actions: read, tail + // 2 actions: tail, custom read state.selected_action += 1; } } @@ -753,26 +1394,39 @@ impl App { let basin_name = state.basin_name.clone(); let stream_name = state.stream_name.clone(); match state.selected_action { - 0 => { - // Read from start - self.start_read(basin_name, stream_name, false, tx); - } - 1 => { - // Tail - self.start_read(basin_name, stream_name, true, tx); - } + 0 => self.start_tail(basin_name, stream_name, tx), // Tail + 1 => self.open_custom_read_dialog(basin_name, stream_name), // Custom read _ => {} } } + KeyCode::Char('t') => { + // Simple tail - s2 read with no flags (live follow from current position) + let basin_name = state.basin_name.clone(); + let stream_name = state.stream_name.clone(); + self.start_tail(basin_name, stream_name, tx); + } KeyCode::Char('r') => { + // Custom read - open configuration dialog let basin_name = state.basin_name.clone(); let stream_name = state.stream_name.clone(); - self.start_read(basin_name, stream_name, false, tx); + self.open_custom_read_dialog(basin_name, stream_name); } - KeyCode::Char('t') => { + KeyCode::Char('e') => { let basin_name = state.basin_name.clone(); let stream_name = state.stream_name.clone(); - self.start_read(basin_name, stream_name, true, tx); + self.input_mode = InputMode::ReconfigureStream { + basin: basin_name.clone(), + stream: stream_name.clone(), + storage_class: None, + retention_policy: RetentionPolicyOption::Infinite, + retention_age_secs: 604800, + timestamping_mode: None, + timestamping_uncapped: None, + selected: 0, + editing_age: false, + age_input: String::new(), + }; + self.load_stream_config_for_reconfig(basin_name, stream_name, tx); } _ => {} } @@ -1089,18 +1743,115 @@ impl App { }); } - fn start_read( + /// Simple tail - like `s2 read` with no flags (live follow from current position) + fn start_tail( + &mut self, + basin_name: BasinName, + stream_name: StreamName, + tx: mpsc::UnboundedSender, + ) { + self.screen = Screen::ReadView(ReadViewState { + basin_name: basin_name.clone(), + stream_name: stream_name.clone(), + records: VecDeque::new(), + is_tailing: true, + scroll_offset: 0, + paused: false, + loading: true, + }); + + let s2 = self.s2.clone(); + let uri = S2BasinAndStreamUri { + basin: basin_name, + stream: stream_name, + }; + + tokio::spawn(async move { + // Simple tail: no flags = TailOffset(0) = start at current tail, wait for new records + let args = ReadArgs { + uri, + seq_num: None, + timestamp: None, + ago: None, + tail_offset: None, // Defaults to TailOffset(0) in ops::read + count: None, + bytes: None, + clamp: true, + until: None, + format: RecordFormat::default(), + output: RecordsOut::Stdout, + }; + + match ops::read(&s2, &args).await { + Ok(mut batch_stream) => { + use futures::StreamExt; + while let Some(batch_result) = batch_stream.next().await { + match batch_result { + Ok(batch) => { + for record in batch.records { + if tx.send(Event::RecordReceived(Ok(record))).is_err() { + return; + } + } + } + Err(e) => { + let _ = tx.send(Event::RecordReceived(Err(crate::error::CliError::op( + crate::error::OpKind::Read, + e, + )))); + return; + } + } + } + let _ = tx.send(Event::ReadEnded); + } + Err(e) => { + let _ = tx.send(Event::Error(e)); + } + } + }); + } + + /// Open custom read configuration dialog + fn open_custom_read_dialog(&mut self, basin: BasinName, stream: StreamName) { + self.input_mode = InputMode::CustomRead { + basin, + stream, + start_from: ReadStartFrom::SeqNum, // Default to reading from beginning + seq_num_value: "0".to_string(), + timestamp_value: String::new(), + ago_value: "5".to_string(), + ago_unit: AgoUnit::Minutes, + tail_offset_value: "10".to_string(), + count_limit: String::new(), + byte_limit: String::new(), + until_timestamp: String::new(), + selected: 0, + editing: false, + }; + } + + /// Start reading with custom configuration + fn start_custom_read( &mut self, basin_name: BasinName, stream_name: StreamName, - is_tailing: bool, + start_from: ReadStartFrom, + seq_num_value: String, + timestamp_value: String, + ago_value: String, + ago_unit: AgoUnit, + tail_offset_value: String, + count_limit: String, + byte_limit: String, + until_timestamp: String, tx: mpsc::UnboundedSender, ) { self.screen = Screen::ReadView(ReadViewState { basin_name: basin_name.clone(), stream_name: stream_name.clone(), records: VecDeque::new(), - is_tailing, + is_tailing: true, scroll_offset: 0, paused: false, loading: true, @@ -1113,26 +1864,72 @@ impl App { }; tokio::spawn(async move { - let args = TailArgs { + // Parse values + let seq_num = if start_from == ReadStartFrom::SeqNum { + seq_num_value.parse().ok() + } else { + None + }; + + let timestamp = if start_from == ReadStartFrom::Timestamp { + timestamp_value.parse().ok() + } else { + None + }; + + let ago = if start_from == ReadStartFrom::Ago { + ago_value.parse::().ok().map(|v| { + let secs = ago_unit.to_seconds(v); + humantime::Duration::from(std::time::Duration::from_secs(secs)) + }) + } else { + None + }; + + let tail_offset = if start_from == ReadStartFrom::TailOffset { + tail_offset_value.parse().ok() + } else if start_from == ReadStartFrom::Tail { + Some(0) // Tail means TailOffset(0) + } else { + None + }; + + let count = count_limit.parse().ok().filter(|&v| v > 0); + let bytes = byte_limit.parse().ok().filter(|&v| v > 0); + let until = until_timestamp.parse().ok().filter(|&v| v > 0); + + let args = ReadArgs { uri, - lines: if is_tailing { 10 } else { 100 }, - follow: is_tailing, - format: RecordFormat::Text, + seq_num, + timestamp, + ago, + tail_offset, + count, + bytes, + clamp: true, + until, + format: RecordFormat::default(), output: RecordsOut::Stdout, }; - match ops::tail(&s2, &args).await { - Ok(mut stream) => { - while let Some(result) = stream.next().await { - match result { - Ok(record) => { - if tx.send(Event::RecordReceived(Ok(record))).is_err() { - break; + match ops::read(&s2, &args).await { + Ok(mut batch_stream) => { + use futures::StreamExt; + while let Some(batch_result) = batch_stream.next().await { + match batch_result { + Ok(batch) => { + for record in batch.records { + if tx.send(Event::RecordReceived(Ok(record))).is_err() { + return; + } } } Err(e) => { - let _ = tx.send(Event::RecordReceived(Err(e))); - break; + let _ = tx.send(Event::RecordReceived(Err(crate::error::CliError::op( + crate::error::OpKind::Read, + e, + )))); + return; } } } @@ -1144,4 +1941,208 @@ impl App { } }); } + + fn load_basin_config(&self, basin: BasinName, tx: mpsc::UnboundedSender) { + let s2 = self.s2.clone(); + tokio::spawn(async move { + match ops::get_basin_config(&s2, &basin).await { + Ok(config) => { + // Extract default stream config info + let (storage_class, retention_age_secs, timestamping_mode, timestamping_uncapped) = + if let Some(default_config) = &config.default_stream_config { + let sc = default_config.storage_class.map(StorageClass::from); + let age = match default_config.retention_policy { + Some(s2_sdk::types::RetentionPolicy::Age(secs)) => Some(secs), + _ => None, + }; + let ts_mode = default_config.timestamping.as_ref() + .and_then(|t| t.mode.map(TimestampingMode::from)); + let ts_uncapped = default_config.timestamping.as_ref() + .map(|t| t.uncapped) + .unwrap_or(false); + (sc, age, ts_mode, ts_uncapped) + } else { + (None, None, None, false) + }; + + let info = BasinConfigInfo { + create_stream_on_append: config.create_stream_on_append, + create_stream_on_read: config.create_stream_on_read, + storage_class, + retention_age_secs, + timestamping_mode, + timestamping_uncapped, + }; + let _ = tx.send(Event::BasinConfigLoaded(Ok(info))); + } + Err(e) => { + let _ = tx.send(Event::BasinConfigLoaded(Err(e))); + } + } + }); + } + + fn load_stream_config_for_reconfig( + &self, + basin: BasinName, + stream: StreamName, + tx: mpsc::UnboundedSender, + ) { + let s2 = self.s2.clone(); + let uri = S2BasinAndStreamUri { basin, stream }; + tokio::spawn(async move { + match ops::get_stream_config(&s2, uri).await { + Ok(config) => { + let storage_class = config.storage_class.map(StorageClass::from); + let retention_age_secs = match config.retention_policy { + Some(s2_sdk::types::RetentionPolicy::Age(secs)) => Some(secs), + _ => None, + }; + let timestamping_mode = config.timestamping.as_ref() + .and_then(|t| t.mode.map(TimestampingMode::from)); + let timestamping_uncapped = config.timestamping.as_ref() + .map(|t| t.uncapped) + .unwrap_or(false); + + let info = StreamConfigInfo { + storage_class, + retention_age_secs, + timestamping_mode, + timestamping_uncapped, + }; + let _ = tx.send(Event::StreamConfigForReconfigLoaded(Ok(info))); + } + Err(e) => { + let _ = tx.send(Event::StreamConfigForReconfigLoaded(Err(e))); + } + } + }); + } + + fn reconfigure_basin( + &mut self, + basin: BasinName, + config: BasinReconfigureConfig, + tx: mpsc::UnboundedSender, + ) { + let s2 = self.s2.clone(); + let tx_refresh = tx.clone(); + tokio::spawn(async move { + // Build the default stream config + let retention_policy = match config.retention_policy { + RetentionPolicyOption::Infinite => Some(crate::types::RetentionPolicy::Infinite), + RetentionPolicyOption::Age => Some(crate::types::RetentionPolicy::Age(Duration::from_secs(config.retention_age_secs))), + }; + + let timestamping = if config.timestamping_mode.is_some() || config.timestamping_uncapped.is_some() { + Some(crate::types::TimestampingConfig { + timestamping_mode: config.timestamping_mode, + timestamping_uncapped: config.timestamping_uncapped, + }) + } else { + None + }; + + let default_stream_config = StreamConfig { + storage_class: config.storage_class, + retention_policy, + timestamping, + delete_on_empty: None, + }; + + let args = ReconfigureBasinArgs { + basin: S2BasinUri(basin), + create_stream_on_append: config.create_stream_on_append, + create_stream_on_read: config.create_stream_on_read, + default_stream_config, + }; + match ops::reconfigure_basin(&s2, args).await { + Ok(_) => { + let _ = tx.send(Event::BasinReconfigured(Ok(()))); + // Trigger refresh + let args = ListBasinsArgs { + prefix: None, + start_after: None, + limit: Some(100), + no_auto_paginate: false, + }; + if let Ok(stream) = ops::list_basins(&s2, args).await { + let basins: Vec<_> = stream + .take(100) + .filter_map(|r| async { r.ok() }) + .collect() + .await; + let _ = tx_refresh.send(Event::BasinsLoaded(Ok(basins))); + } + } + Err(e) => { + let _ = tx.send(Event::BasinReconfigured(Err(e))); + } + } + }); + } + + fn reconfigure_stream( + &mut self, + basin: BasinName, + stream: StreamName, + config: StreamReconfigureConfig, + tx: mpsc::UnboundedSender, + ) { + let s2 = self.s2.clone(); + let basin_clone = basin.clone(); + let tx_refresh = tx.clone(); + tokio::spawn(async move { + let retention_policy = match config.retention_policy { + RetentionPolicyOption::Infinite => Some(crate::types::RetentionPolicy::Infinite), + RetentionPolicyOption::Age => Some(crate::types::RetentionPolicy::Age(Duration::from_secs(config.retention_age_secs))), + }; + + let timestamping = if config.timestamping_mode.is_some() || config.timestamping_uncapped.is_some() { + Some(crate::types::TimestampingConfig { + timestamping_mode: config.timestamping_mode, + timestamping_uncapped: config.timestamping_uncapped, + }) + } else { + None + }; + + let args = ReconfigureStreamArgs { + uri: S2BasinAndStreamUri { basin, stream }, + config: StreamConfig { + storage_class: config.storage_class, + retention_policy, + timestamping, + delete_on_empty: None, + }, + }; + match ops::reconfigure_stream(&s2, args).await { + Ok(_) => { + let _ = tx.send(Event::StreamReconfigured(Ok(()))); + // Trigger refresh + let args = ListStreamsArgs { + uri: S2BasinAndMaybeStreamUri { + basin: basin_clone, + stream: None, + }, + prefix: None, + start_after: None, + limit: Some(100), + no_auto_paginate: false, + }; + if let Ok(stream) = ops::list_streams(&s2, args).await { + let streams: Vec<_> = stream + .take(100) + .filter_map(|r| async { r.ok() }) + .collect() + .await; + let _ = tx_refresh.send(Event::StreamsLoaded(Ok(streams))); + } + } + Err(e) => { + let _ = tx.send(Event::StreamReconfigured(Err(e))); + } + } + }); + } } diff --git a/src/tui/event.rs b/src/tui/event.rs index 30bb272..2d4647e 100644 --- a/src/tui/event.rs +++ b/src/tui/event.rs @@ -1,7 +1,28 @@ use s2_sdk::types::{BasinInfo, SequencedRecord, StreamInfo, StreamPosition}; use crate::error::CliError; -use crate::types::StreamConfig; +use crate::types::{StorageClass, StreamConfig, TimestampingMode}; + +/// Basin config info for reconfiguration +#[derive(Debug, Clone)] +pub struct BasinConfigInfo { + pub create_stream_on_append: bool, + pub create_stream_on_read: bool, + // Default stream config + pub storage_class: Option, + pub retention_age_secs: Option, // None = infinite + pub timestamping_mode: Option, + pub timestamping_uncapped: bool, +} + +/// Stream config info for reconfiguration +#[derive(Debug, Clone)] +pub struct StreamConfigInfo { + pub storage_class: Option, + pub retention_age_secs: Option, // None = infinite + pub timestamping_mode: Option, + pub timestamping_uncapped: bool, +} /// Events that can occur in the TUI #[derive(Debug)] @@ -36,6 +57,18 @@ pub enum Event { /// Stream deleted successfully StreamDeleted(Result), + /// Basin config loaded for reconfiguration + BasinConfigLoaded(Result), + + /// Stream config loaded for reconfiguration + StreamConfigForReconfigLoaded(Result), + + /// Basin reconfigured successfully + BasinReconfigured(Result<(), CliError>), + + /// Stream reconfigured successfully + StreamReconfigured(Result<(), CliError>), + /// An error occurred in a background task Error(CliError), } diff --git a/src/tui/ui.rs b/src/tui/ui.rs index 88c08cd..f143f50 100644 --- a/src/tui/ui.rs +++ b/src/tui/ui.rs @@ -6,7 +6,9 @@ use ratatui::{ widgets::{Block, Borders, Clear, Padding, Paragraph, Wrap}, }; -use super::app::{App, BasinsState, InputMode, MessageLevel, ReadViewState, Screen, StreamDetailState, StreamsState}; +use crate::types::{StorageClass, TimestampingMode}; + +use super::app::{App, AgoUnit, BasinsState, InputMode, MessageLevel, ReadStartFrom, ReadViewState, RetentionPolicyOption, Screen, StreamDetailState, StreamsState}; // S2 Console dark theme const GREEN: Color = Color::Rgb(34, 197, 94); // Active green @@ -84,20 +86,18 @@ fn draw_basins(f: &mut Frame, area: Rect, state: &BasinsState) { let search_text = if state.filter_active { Line::from(vec![ - Span::styled(" 🔍 ", Style::default().fg(TEXT_MUTED)), + Span::styled(" [/] ", Style::default().fg(GREEN)), Span::styled(&state.filter, Style::default().fg(TEXT_PRIMARY)), Span::styled("_", Style::default().fg(GREEN)), ]) } else if state.filter.is_empty() { Line::from(vec![ - Span::styled(" 🔍 Filter by prefix... ", Style::default().fg(TEXT_MUTED)), - Span::styled("(press /)", Style::default().fg(BORDER)), + Span::styled(" [/] Filter by prefix...", Style::default().fg(TEXT_MUTED)), ]) } else { Line::from(vec![ - Span::styled(" 🔍 ", Style::default().fg(TEXT_MUTED)), + Span::styled(" [/] ", Style::default().fg(TEXT_MUTED)), Span::styled(&state.filter, Style::default().fg(TEXT_PRIMARY)), - Span::styled(" (esc to clear)", Style::default().fg(BORDER)), ]) }; @@ -258,9 +258,10 @@ fn draw_streams(f: &mut Frame, area: Rect, state: &StreamsState) { } }; + let basin_name_str = state.basin_name.to_string(); let title = Line::from(vec![ Span::styled(" ← ", Style::default().fg(TEXT_MUTED)), - Span::styled(&state.basin_name.to_string(), Style::default().fg(GREEN).bold()), + Span::styled(&basin_name_str, Style::default().fg(GREEN).bold()), Span::styled(count_text, Style::default().fg(TEXT_MUTED)), ]); f.render_widget(Paragraph::new(title), chunks[0]); @@ -273,20 +274,18 @@ fn draw_streams(f: &mut Frame, area: Rect, state: &StreamsState) { let search_text = if state.filter_active { Line::from(vec![ - Span::styled(" 🔍 ", Style::default().fg(TEXT_MUTED)), + Span::styled(" [/] ", Style::default().fg(GREEN)), Span::styled(&state.filter, Style::default().fg(TEXT_PRIMARY)), Span::styled("_", Style::default().fg(GREEN)), ]) } else if state.filter.is_empty() { Line::from(vec![ - Span::styled(" 🔍 Filter by prefix... ", Style::default().fg(TEXT_MUTED)), - Span::styled("(press /)", Style::default().fg(BORDER)), + Span::styled(" [/] Filter by prefix...", Style::default().fg(TEXT_MUTED)), ]) } else { Line::from(vec![ - Span::styled(" 🔍 ", Style::default().fg(TEXT_MUTED)), + Span::styled(" [/] ", Style::default().fg(TEXT_MUTED)), Span::styled(&state.filter, Style::default().fg(TEXT_PRIMARY)), - Span::styled(" (esc to clear)", Style::default().fg(BORDER)), ]) }; @@ -376,17 +375,8 @@ fn draw_streams(f: &mut Frame, area: Rect, state: &StreamsState) { name }; - // Created timestamp - let created = stream.created_at - .map(|ts| { - // Convert milliseconds timestamp to readable format - let secs = ts / 1000; - let datetime = chrono::DateTime::from_timestamp(secs as i64, 0) - .map(|dt| dt.format("%Y-%m-%d %H:%M:%S").to_string()) - .unwrap_or_else(|| format!("{}", ts)); - datetime - }) - .unwrap_or_else(|| "—".to_string()); + // Created timestamp - S2DateTime implements Display + let created = stream.created_at.to_string(); // Render name let name_style = if is_selected { @@ -500,8 +490,8 @@ fn draw_stream_detail(f: &mut Frame, area: Rect, state: &StreamDetailState) { .padding(Padding::new(2, 2, 1, 1)); let actions = vec![ - ("r", "Read from beginning", "Fetch historical records from seq 0"), - ("t", "Tail stream", "Follow new records in real-time"), + ("t", "Tail stream", "Live follow from current position"), + ("r", "Custom read", "Configure start position and limits"), ]; let mut action_lines = vec![Line::from("")]; @@ -621,14 +611,14 @@ fn draw_read_view(f: &mut Frame, area: Rect, state: &ReadViewState) { fn draw_status_bar(f: &mut Frame, area: Rect, app: &App) { let hints = match &app.screen { - Screen::Basins(_) => "/ search j/k navigate enter select c create d delete r refresh ? help q quit", - Screen::Streams(_) => "/ search j/k navigate enter select c create d delete r refresh esc back", - Screen::StreamDetail(_) => "j/k navigate enter select r read t tail esc back", + Screen::Basins(_) => "/ filter | jk nav | ret open | c new | e cfg | d del | r ref | ? | q", + Screen::Streams(_) => "/ filter | jk nav | ret open | c new | e cfg | d del | r ref | esc", + Screen::StreamDetail(_) => "jk nav | ret run | t tail | r custom | e cfg | esc", Screen::ReadView(s) => { if s.is_tailing { - "space pause j/k scroll esc back" + "space pause | jk scroll | esc" } else { - "j/k scroll g/G top/bottom esc back" + "jk scroll | gG top/bot | esc" } } }; @@ -673,6 +663,10 @@ fn draw_help_overlay(f: &mut Frame, screen: &Screen) { Span::styled(" g/G ", Style::default().fg(GREEN).bold()), Span::styled("Top / Bottom", Style::default().fg(TEXT_SECONDARY)), ]), + Line::from(vec![ + Span::styled(" / ", Style::default().fg(GREEN).bold()), + Span::styled("Filter", Style::default().fg(TEXT_SECONDARY)), + ]), Line::from(vec![ Span::styled("enter ", Style::default().fg(GREEN).bold()), Span::styled("Select basin", Style::default().fg(TEXT_SECONDARY)), @@ -681,6 +675,10 @@ fn draw_help_overlay(f: &mut Frame, screen: &Screen) { Span::styled(" c ", Style::default().fg(GREEN).bold()), Span::styled("Create basin", Style::default().fg(TEXT_SECONDARY)), ]), + Line::from(vec![ + Span::styled(" e ", Style::default().fg(GREEN).bold()), + Span::styled("Reconfigure basin", Style::default().fg(TEXT_SECONDARY)), + ]), Line::from(vec![ Span::styled(" d ", Style::default().fg(GREEN).bold()), Span::styled("Delete basin", Style::default().fg(TEXT_SECONDARY)), @@ -701,6 +699,10 @@ fn draw_help_overlay(f: &mut Frame, screen: &Screen) { Span::styled(" j/k ", Style::default().fg(GREEN).bold()), Span::styled("Navigate", Style::default().fg(TEXT_SECONDARY)), ]), + Line::from(vec![ + Span::styled(" / ", Style::default().fg(GREEN).bold()), + Span::styled("Filter", Style::default().fg(TEXT_SECONDARY)), + ]), Line::from(vec![ Span::styled("enter ", Style::default().fg(GREEN).bold()), Span::styled("Select stream", Style::default().fg(TEXT_SECONDARY)), @@ -709,6 +711,10 @@ fn draw_help_overlay(f: &mut Frame, screen: &Screen) { Span::styled(" c ", Style::default().fg(GREEN).bold()), Span::styled("Create stream", Style::default().fg(TEXT_SECONDARY)), ]), + Line::from(vec![ + Span::styled(" e ", Style::default().fg(GREEN).bold()), + Span::styled("Reconfigure stream", Style::default().fg(TEXT_SECONDARY)), + ]), Line::from(vec![ Span::styled(" d ", Style::default().fg(GREEN).bold()), Span::styled("Delete stream", Style::default().fg(TEXT_SECONDARY)), @@ -733,13 +739,17 @@ fn draw_help_overlay(f: &mut Frame, screen: &Screen) { Span::styled("enter ", Style::default().fg(GREEN).bold()), Span::styled("Execute action", Style::default().fg(TEXT_SECONDARY)), ]), + Line::from(vec![ + Span::styled(" t ", Style::default().fg(GREEN).bold()), + Span::styled("Tail (live follow)", Style::default().fg(TEXT_SECONDARY)), + ]), Line::from(vec![ Span::styled(" r ", Style::default().fg(GREEN).bold()), - Span::styled("Read from start", Style::default().fg(TEXT_SECONDARY)), + Span::styled("Custom read", Style::default().fg(TEXT_SECONDARY)), ]), Line::from(vec![ - Span::styled(" t ", Style::default().fg(GREEN).bold()), - Span::styled("Tail (live follow)", Style::default().fg(TEXT_SECONDARY)), + Span::styled(" e ", Style::default().fg(GREEN).bold()), + Span::styled("Reconfigure stream", Style::default().fg(TEXT_SECONDARY)), ]), Line::from(vec![ Span::styled(" esc ", Style::default().fg(GREEN).bold()), @@ -881,9 +891,320 @@ fn draw_input_dialog(f: &mut Frame, mode: &InputMode) { ], "y confirm n/esc cancel", ), + + InputMode::ReconfigureBasin { + basin, + create_stream_on_append, + create_stream_on_read, + storage_class, + retention_policy, + retention_age_secs, + timestamping_mode, + timestamping_uncapped, + selected, + editing_age, + age_input, + } => { + let checkbox = |checked: bool| if checked { "[x]" } else { "[ ]" }; + let sel = |idx: usize, s: &usize| if idx == *s { ">" } else { " " }; + let sc_str = match storage_class { + None => "default", + Some(StorageClass::Express) => "express", + Some(StorageClass::Standard) => "standard", + }; + let ts_str = match timestamping_mode { + None => "default", + Some(TimestampingMode::ClientPrefer) => "client-prefer", + Some(TimestampingMode::ClientRequire) => "client-require", + Some(TimestampingMode::Arrival) => "arrival", + }; + + let mut lines = vec![ + Line::from(vec![ + Span::styled(basin.to_string(), Style::default().fg(GREEN).bold()), + ]), + Line::from(""), + Line::from(Span::styled("-- Create Streams Automatically --", Style::default().fg(TEXT_MUTED))), + Line::from(vec![ + Span::styled(sel(0, selected), Style::default().fg(GREEN)), + Span::styled(format!(" {} on append", checkbox(create_stream_on_append.unwrap_or(false))), Style::default().fg(TEXT_SECONDARY)), + ]), + Line::from(vec![ + Span::styled(sel(1, selected), Style::default().fg(GREEN)), + Span::styled(format!(" {} on read", checkbox(create_stream_on_read.unwrap_or(false))), Style::default().fg(TEXT_SECONDARY)), + ]), + Line::from(""), + Line::from(Span::styled("-- Default Stream Config --", Style::default().fg(TEXT_MUTED))), + Line::from(vec![ + Span::styled(sel(2, selected), Style::default().fg(GREEN)), + Span::styled(format!(" Storage class: < {} >", sc_str), Style::default().fg(TEXT_SECONDARY)), + ]), + Line::from(vec![ + Span::styled(sel(3, selected), Style::default().fg(GREEN)), + Span::styled(format!(" Retention: < {} >", if *retention_policy == RetentionPolicyOption::Infinite { "infinite" } else { "age-based" }), Style::default().fg(TEXT_SECONDARY)), + ]), + ]; + + if *retention_policy == RetentionPolicyOption::Age { + let age_display = if *editing_age { + format!("{}_ secs", age_input) + } else { + format!("{} secs", retention_age_secs) + }; + lines.push(Line::from(vec![ + Span::styled(sel(4, selected), Style::default().fg(GREEN)), + Span::styled(format!(" Age: {}", age_display), Style::default().fg(if *editing_age { GREEN } else { TEXT_SECONDARY })), + ])); + } else { + lines.push(Line::from(vec![ + Span::styled(" Age: (n/a)", Style::default().fg(BORDER)), + ])); + } + + lines.extend(vec![ + Line::from(vec![ + Span::styled(sel(5, selected), Style::default().fg(GREEN)), + Span::styled(format!(" Timestamping: < {} >", ts_str), Style::default().fg(TEXT_SECONDARY)), + ]), + Line::from(vec![ + Span::styled(sel(6, selected), Style::default().fg(GREEN)), + Span::styled(format!(" {} Allow ts > arrival", checkbox(timestamping_uncapped.unwrap_or(false))), Style::default().fg(TEXT_SECONDARY)), + ]), + ]); + + ( + " Reconfigure Basin ", + lines, + "jk nav | space/enter toggle | s save | esc cancel", + ) + } + + InputMode::ReconfigureStream { + basin, + stream, + storage_class, + retention_policy, + retention_age_secs, + timestamping_mode, + timestamping_uncapped, + selected, + editing_age, + age_input, + } => { + let checkbox = |checked: bool| if checked { "[x]" } else { "[ ]" }; + let sel = |idx: usize, s: &usize| if idx == *s { ">" } else { " " }; + let sc_str = match storage_class { + None => "default", + Some(StorageClass::Express) => "express", + Some(StorageClass::Standard) => "standard", + }; + let ts_str = match timestamping_mode { + None => "default", + Some(TimestampingMode::ClientPrefer) => "client-prefer", + Some(TimestampingMode::ClientRequire) => "client-require", + Some(TimestampingMode::Arrival) => "arrival", + }; + + let mut lines = vec![ + Line::from(vec![ + Span::styled(format!("{}/{}", basin, stream), Style::default().fg(GREEN).bold()), + ]), + Line::from(""), + Line::from(vec![ + Span::styled(sel(0, selected), Style::default().fg(GREEN)), + Span::styled(format!(" Storage class: < {} >", sc_str), Style::default().fg(TEXT_SECONDARY)), + ]), + Line::from(vec![ + Span::styled(sel(1, selected), Style::default().fg(GREEN)), + Span::styled(format!(" Retention: < {} >", if *retention_policy == RetentionPolicyOption::Infinite { "infinite" } else { "age-based" }), Style::default().fg(TEXT_SECONDARY)), + ]), + ]; + + if *retention_policy == RetentionPolicyOption::Age { + let age_display = if *editing_age { + format!("{}_ secs", age_input) + } else { + format!("{} secs", retention_age_secs) + }; + lines.push(Line::from(vec![ + Span::styled(sel(2, selected), Style::default().fg(GREEN)), + Span::styled(format!(" Age: {}", age_display), Style::default().fg(if *editing_age { GREEN } else { TEXT_SECONDARY })), + ])); + } else { + lines.push(Line::from(vec![ + Span::styled(" Age: (n/a)", Style::default().fg(BORDER)), + ])); + } + + lines.extend(vec![ + Line::from(vec![ + Span::styled(sel(3, selected), Style::default().fg(GREEN)), + Span::styled(format!(" Timestamping: < {} >", ts_str), Style::default().fg(TEXT_SECONDARY)), + ]), + Line::from(vec![ + Span::styled(sel(4, selected), Style::default().fg(GREEN)), + Span::styled(format!(" {} Allow ts > arrival", checkbox(timestamping_uncapped.unwrap_or(false))), Style::default().fg(TEXT_SECONDARY)), + ]), + ]); + + ( + " Reconfigure Stream ", + lines, + "jk nav | space/enter toggle | s save | esc cancel", + ) + } + + InputMode::CustomRead { + basin, + stream, + start_from, + seq_num_value, + timestamp_value, + ago_value, + ago_unit, + tail_offset_value, + count_limit, + byte_limit, + until_timestamp, + selected, + editing, + } => { + let sel = |idx: usize, s: &usize| if idx == *s { ">" } else { " " }; + let editable = |idx: usize, s: &usize, e: &bool| { + if idx == *s && *e { GREEN } else if idx == *s { TEXT_PRIMARY } else { TEXT_SECONDARY } + }; + + let start_str = match start_from { + ReadStartFrom::Tail => "tail (live follow)", + ReadStartFrom::SeqNum => "sequence number", + ReadStartFrom::Timestamp => "timestamp (ms)", + ReadStartFrom::Ago => "time ago", + ReadStartFrom::TailOffset => "tail offset", + }; + + let mut lines = vec![ + Line::from(vec![ + Span::styled(format!("{}/{}", basin, stream), Style::default().fg(GREEN).bold()), + ]), + Line::from(""), + Line::from(Span::styled("-- Start Position --", Style::default().fg(TEXT_MUTED))), + Line::from(vec![ + Span::styled(sel(0, selected), Style::default().fg(GREEN)), + Span::styled(format!(" Start from: < {} >", start_str), Style::default().fg(if *selected == 0 { TEXT_PRIMARY } else { TEXT_SECONDARY })), + ]), + ]; + + // Show relevant input based on start_from + match start_from { + ReadStartFrom::Tail => { + lines.push(Line::from(Span::styled(" (follows new records only)", Style::default().fg(TEXT_MUTED)))); + } + ReadStartFrom::SeqNum => { + let display = if *editing && *selected == 1 { + format!("{}|", seq_num_value) + } else { + if seq_num_value.is_empty() { "0".to_string() } else { seq_num_value.clone() } + }; + lines.push(Line::from(vec![ + Span::styled(sel(1, selected), Style::default().fg(GREEN)), + Span::styled(format!(" Seq num: {}", display), Style::default().fg(editable(1, selected, editing))), + ])); + } + ReadStartFrom::Timestamp => { + let display = if *editing && *selected == 2 { + format!("{}|", timestamp_value) + } else { + if timestamp_value.is_empty() { "(enter ms)".to_string() } else { format!("{} ms", timestamp_value) } + }; + lines.push(Line::from(vec![ + Span::styled(sel(2, selected), Style::default().fg(GREEN)), + Span::styled(format!(" Timestamp: {}", display), Style::default().fg(editable(2, selected, editing))), + ])); + } + ReadStartFrom::Ago => { + let display = if *editing && *selected == 3 { + format!("{}|", ago_value) + } else { + if ago_value.is_empty() { "5".to_string() } else { ago_value.clone() } + }; + lines.push(Line::from(vec![ + Span::styled(sel(3, selected), Style::default().fg(GREEN)), + Span::styled(format!(" Value: {}", display), Style::default().fg(editable(3, selected, editing))), + ])); + lines.push(Line::from(vec![ + Span::styled(sel(4, selected), Style::default().fg(GREEN)), + Span::styled(format!(" Unit: < {} >", match ago_unit { + AgoUnit::Seconds => "seconds", + AgoUnit::Minutes => "minutes", + AgoUnit::Hours => "hours", + AgoUnit::Days => "days", + }), Style::default().fg(if *selected == 4 { TEXT_PRIMARY } else { TEXT_SECONDARY })), + ])); + } + ReadStartFrom::TailOffset => { + let display = if *editing && *selected == 5 { + format!("{}|", tail_offset_value) + } else { + if tail_offset_value.is_empty() { "10".to_string() } else { format!("{} records back", tail_offset_value) } + }; + lines.push(Line::from(vec![ + Span::styled(sel(5, selected), Style::default().fg(GREEN)), + Span::styled(format!(" Offset: {}", display), Style::default().fg(editable(5, selected, editing))), + ])); + } + } + + lines.push(Line::from("")); + lines.push(Line::from(Span::styled("-- Limits (optional) --", Style::default().fg(TEXT_MUTED)))); + + // Count limit + let count_display = if *editing && *selected == 6 { + format!("{}|", count_limit) + } else { + if count_limit.is_empty() { "unlimited".to_string() } else { format!("{} records", count_limit) } + }; + lines.push(Line::from(vec![ + Span::styled(sel(6, selected), Style::default().fg(GREEN)), + Span::styled(format!(" Max records: {}", count_display), Style::default().fg(editable(6, selected, editing))), + ])); + + // Byte limit + let byte_display = if *editing && *selected == 7 { + format!("{}|", byte_limit) + } else { + if byte_limit.is_empty() { "unlimited".to_string() } else { format!("{} bytes", byte_limit) } + }; + lines.push(Line::from(vec![ + Span::styled(sel(7, selected), Style::default().fg(GREEN)), + Span::styled(format!(" Max bytes: {}", byte_display), Style::default().fg(editable(7, selected, editing))), + ])); + + // Until timestamp + let until_display = if *editing && *selected == 8 { + format!("{}|", until_timestamp) + } else { + if until_timestamp.is_empty() { "none".to_string() } else { format!("{} ms", until_timestamp) } + }; + lines.push(Line::from(vec![ + Span::styled(sel(8, selected), Style::default().fg(GREEN)), + Span::styled(format!(" Until timestamp: {}", until_display), Style::default().fg(editable(8, selected, editing))), + ])); + + lines.push(Line::from("")); + lines.push(Line::from(vec![ + Span::styled(sel(9, selected), Style::default().fg(GREEN)), + Span::styled(" [ Start Reading ]", Style::default().fg(if *selected == 9 { GREEN } else { TEXT_SECONDARY }).bold()), + ])); + + ( + " Custom Read ", + lines, + "jk nav | space/enter edit | esc cancel", + ) + } }; - let area = centered_rect(50, 40, f.area()); + let area = centered_rect(60, 60, f.area()); let block = Block::default() .title(Line::from(Span::styled(title, Style::default().fg(TEXT_PRIMARY).bold()))) From 9e66bd747a331333741e7995f10db29d7d8f45de Mon Sep 17 00:00:00 2001 From: Mehul Arora Date: Thu, 22 Jan 2026 22:42:56 -0500 Subject: [PATCH 03/31] . --- src/tui/app.rs | 170 ++++++++++++------- src/tui/ui.rs | 439 +++++++++++++++++++++++++++---------------------- 2 files changed, 350 insertions(+), 259 deletions(-) diff --git a/src/tui/app.rs b/src/tui/app.rs index d521692..49d5948 100644 --- a/src/tui/app.rs +++ b/src/tui/app.rs @@ -145,8 +145,10 @@ pub enum InputMode { // Limits count_limit: String, byte_limit: String, - // Options until_timestamp: String, + // Options + clamp: bool, + format: ReadFormat, // UI state selected: usize, editing: bool, @@ -197,15 +199,6 @@ pub enum AgoUnit { } impl AgoUnit { - pub fn as_str(&self) -> &'static str { - match self { - AgoUnit::Seconds => "s", - AgoUnit::Minutes => "m", - AgoUnit::Hours => "h", - AgoUnit::Days => "d", - } - } - pub fn to_seconds(&self, value: u64) -> u64 { match self { AgoUnit::Seconds => value, @@ -231,6 +224,33 @@ impl Default for AgoUnit { } } +/// Output format for read operation +#[derive(Debug, Clone, Copy, PartialEq, Default)] +pub enum ReadFormat { + #[default] + Text, + Json, + JsonBase64, +} + +impl ReadFormat { + pub fn next(&self) -> Self { + match self { + ReadFormat::Text => ReadFormat::Json, + ReadFormat::Json => ReadFormat::JsonBase64, + ReadFormat::JsonBase64 => ReadFormat::Text, + } + } + + pub fn as_str(&self) -> &'static str { + match self { + ReadFormat::Text => "text", + ReadFormat::Json => "json", + ReadFormat::JsonBase64 => "json-base64", + } + } +} + /// Config for basin reconfiguration #[derive(Debug, Clone)] @@ -984,39 +1004,42 @@ impl App { count_limit, byte_limit, until_timestamp, + clamp, + format, selected, editing, } => { // If editing a value, handle text input if *editing { match key.code { - KeyCode::Esc => { + KeyCode::Esc | KeyCode::Enter => { *editing = false; } - KeyCode::Enter => { - *editing = false; + KeyCode::Tab if *selected == 2 => { + // Cycle time unit while editing ago value + *ago_unit = ago_unit.next(); } KeyCode::Backspace => { match *selected { - 1 => { seq_num_value.pop(); } - 2 => { timestamp_value.pop(); } - 3 => { ago_value.pop(); } - 5 => { tail_offset_value.pop(); } - 6 => { count_limit.pop(); } - 7 => { byte_limit.pop(); } - 8 => { until_timestamp.pop(); } + 0 => { seq_num_value.pop(); } + 1 => { timestamp_value.pop(); } + 2 => { ago_value.pop(); } + 3 => { tail_offset_value.pop(); } + 4 => { count_limit.pop(); } + 5 => { byte_limit.pop(); } + 6 => { until_timestamp.pop(); } _ => {} } } KeyCode::Char(c) if c.is_ascii_digit() => { match *selected { - 1 => seq_num_value.push(c), - 2 => timestamp_value.push(c), - 3 => ago_value.push(c), - 5 => tail_offset_value.push(c), - 6 => count_limit.push(c), - 7 => byte_limit.push(c), - 8 => until_timestamp.push(c), + 0 => seq_num_value.push(c), + 1 => timestamp_value.push(c), + 2 => ago_value.push(c), + 3 => tail_offset_value.push(c), + 4 => count_limit.push(c), + 5 => byte_limit.push(c), + 6 => until_timestamp.push(c), _ => {} } } @@ -1025,18 +1048,17 @@ impl App { return; } - // Navigation and selection - // Rows: - // 0: start_from selector - // 1: seq_num (if SeqNum) - // 2: timestamp (if Timestamp) - // 3: ago value (if Ago) - // 4: ago unit (if Ago) - // 5: tail_offset (if TailOffset) - // 6: count_limit - // 7: byte_limit - // 8: until_timestamp - // 9: [Start Reading] button + // Navigation layout: + // 0: Sequence number (radio + input) + // 1: Timestamp (radio + input) + // 2: Time ago (radio + input, tab=unit) + // 3: Tail offset (radio + input) + // 4: Max records + // 5: Max bytes + // 6: Until timestamp + // 7: Clamp (checkbox) + // 8: Format (selector) + // 9: Start button const MAX_ROW: usize = 9; match key.code { @@ -1053,36 +1075,46 @@ impl App { *selected += 1; } } - KeyCode::Char(' ') | KeyCode::Enter => { + KeyCode::Tab if *selected == 2 => { + // Cycle time unit for ago + *ago_unit = ago_unit.next(); + } + KeyCode::Char(' ') => { + // Space = select/toggle + match *selected { + 0 => *start_from = ReadStartFrom::SeqNum, + 1 => *start_from = ReadStartFrom::Timestamp, + 2 => *start_from = ReadStartFrom::Ago, + 3 => *start_from = ReadStartFrom::TailOffset, + 7 => *clamp = !*clamp, + 8 => *format = format.next(), + _ => {} + } + } + KeyCode::Enter => { + // Enter = select + edit value, toggle, or run match *selected { 0 => { - // Cycle start_from - *start_from = match start_from { - ReadStartFrom::Tail => ReadStartFrom::SeqNum, - ReadStartFrom::SeqNum => ReadStartFrom::Timestamp, - ReadStartFrom::Timestamp => ReadStartFrom::Ago, - ReadStartFrom::Ago => ReadStartFrom::TailOffset, - ReadStartFrom::TailOffset => ReadStartFrom::Tail, - }; - } - 1 if *start_from == ReadStartFrom::SeqNum => { + *start_from = ReadStartFrom::SeqNum; *editing = true; } - 2 if *start_from == ReadStartFrom::Timestamp => { + 1 => { + *start_from = ReadStartFrom::Timestamp; *editing = true; } - 3 if *start_from == ReadStartFrom::Ago => { + 2 => { + *start_from = ReadStartFrom::Ago; *editing = true; } - 4 if *start_from == ReadStartFrom::Ago => { - *ago_unit = ago_unit.next(); - } - 5 if *start_from == ReadStartFrom::TailOffset => { + 3 => { + *start_from = ReadStartFrom::TailOffset; *editing = true; } - 6 => *editing = true, // count_limit - 7 => *editing = true, // byte_limit - 8 => *editing = true, // until_timestamp + 4 => *editing = true, // count_limit + 5 => *editing = true, // byte_limit + 6 => *editing = true, // until_timestamp + 7 => *clamp = !*clamp, + 8 => *format = format.next(), 9 => { // Start reading - clone all values first let b = basin.clone(); @@ -1096,8 +1128,10 @@ impl App { let cl = count_limit.clone(); let bl = byte_limit.clone(); let ut = until_timestamp.clone(); + let clp = *clamp; + let fmt = *format; self.input_mode = InputMode::Normal; - self.start_custom_read(b, s, sf, snv, tsv, agv, agu, tov, cl, bl, ut, tx.clone()); + self.start_custom_read(b, s, sf, snv, tsv, agv, agu, tov, cl, bl, ut, clp, fmt, tx.clone()); } _ => {} } @@ -1826,6 +1860,8 @@ impl App { count_limit: String::new(), byte_limit: String::new(), until_timestamp: String::new(), + clamp: true, + format: ReadFormat::Text, selected: 0, editing: false, }; @@ -1845,6 +1881,8 @@ impl App { count_limit: String, byte_limit: String, until_timestamp: String, + clamp: bool, + format: ReadFormat, tx: mpsc::UnboundedSender, ) { self.screen = Screen::ReadView(ReadViewState { @@ -1888,8 +1926,6 @@ impl App { let tail_offset = if start_from == ReadStartFrom::TailOffset { tail_offset_value.parse().ok() - } else if start_from == ReadStartFrom::Tail { - Some(0) // Tail means TailOffset(0) } else { None }; @@ -1898,6 +1934,12 @@ impl App { let bytes = byte_limit.parse().ok().filter(|&v| v > 0); let until = until_timestamp.parse().ok().filter(|&v| v > 0); + let record_format = match format { + ReadFormat::Text => RecordFormat::Text, + ReadFormat::Json => RecordFormat::Json, + ReadFormat::JsonBase64 => RecordFormat::JsonBase64, + }; + let args = ReadArgs { uri, seq_num, @@ -1906,9 +1948,9 @@ impl App { tail_offset, count, bytes, - clamp: true, + clamp, until, - format: RecordFormat::default(), + format: record_format, output: RecordsOut::Stdout, }; diff --git a/src/tui/ui.rs b/src/tui/ui.rs index f143f50..cf7f0f0 100644 --- a/src/tui/ui.rs +++ b/src/tui/ui.rs @@ -399,131 +399,167 @@ fn draw_streams(f: &mut Frame, area: Rect, state: &StreamsState) { } fn draw_stream_detail(f: &mut Frame, area: Rect, state: &StreamDetailState) { + // Vertical layout: Header, Stats row, Actions let chunks = Layout::default() - .direction(Direction::Horizontal) + .direction(Direction::Vertical) .constraints([ - Constraint::Percentage(50), // Info - Constraint::Percentage(50), // Actions + Constraint::Length(3), // Header with URI + Constraint::Length(7), // Stats cards + Constraint::Min(8), // Actions ]) .split(area); - // Left: Info panel + // === Header === let uri = format!("s2://{}/{}", state.basin_name, state.stream_name); - let info_block = Block::default() - .title(Line::from(vec![ - Span::styled(" ", Style::default()), - Span::styled(&uri, Style::default().fg(GREEN).bold()), - Span::styled(" ", Style::default()), - ])) - .borders(Borders::ALL) - .border_style(Style::default().fg(BORDER)) - .style(Style::default().bg(BG_PANEL)) - .padding(Padding::new(2, 2, 1, 1)); - - let mut info_lines = vec![ - Line::from(""), - Line::from(vec![ - Span::styled("Stream", Style::default().fg(TEXT_MUTED)), - ]), - Line::from(vec![ - Span::styled(state.stream_name.to_string(), Style::default().fg(TEXT_PRIMARY).bold()), - ]), - Line::from(""), - Line::from(vec![ - Span::styled("Basin", Style::default().fg(TEXT_MUTED)), - ]), - Line::from(vec![ - Span::styled(state.basin_name.to_string(), Style::default().fg(TEXT_SECONDARY)), - ]), - Line::from(""), - ]; - - // Tail position - if let Some(pos) = &state.tail_position { - info_lines.push(Line::from(vec![ - Span::styled("Tail Position", Style::default().fg(TEXT_MUTED)), - ])); - info_lines.push(Line::from(vec![ - Span::styled(format!("{}", pos.seq_num), Style::default().fg(TEXT_PRIMARY).bold()), - Span::styled(" seq", Style::default().fg(TEXT_MUTED)), - ])); - info_lines.push(Line::from("")); - info_lines.push(Line::from(vec![ - Span::styled("Last Timestamp", Style::default().fg(TEXT_MUTED)), - ])); - info_lines.push(Line::from(vec![ - Span::styled(format!("{}", pos.timestamp), Style::default().fg(TEXT_SECONDARY)), - Span::styled(" ms", Style::default().fg(TEXT_MUTED)), - ])); - } else if state.loading { - info_lines.push(Line::from(vec![ - Span::styled("Loading...", Style::default().fg(TEXT_MUTED)), - ])); + let header = Paragraph::new(Line::from(vec![ + Span::styled(" ", Style::default()), + Span::styled(&uri, Style::default().fg(GREEN).bold()), + ])) + .block(Block::default() + .borders(Borders::BOTTOM) + .border_style(Style::default().fg(BORDER))); + f.render_widget(header, chunks[0]); + + // === Stats Row === + let stats_chunks = Layout::default() + .direction(Direction::Horizontal) + .constraints([ + Constraint::Ratio(1, 4), + Constraint::Ratio(1, 4), + Constraint::Ratio(1, 4), + Constraint::Ratio(1, 4), + ]) + .split(chunks[1]); + + // Stat card helper function + fn render_stat_card(f: &mut Frame, area: Rect, label: &str, value: &str, sub: Option<&str>) { + let mut lines = vec![ + Line::from(Span::styled(label, Style::default().fg(TEXT_MUTED))), + Line::from(Span::styled(value, Style::default().fg(TEXT_PRIMARY).bold())), + ]; + if let Some(s) = sub { + lines.push(Line::from(Span::styled(s, Style::default().fg(TEXT_MUTED)))); + } + let widget = Paragraph::new(lines) + .block(Block::default() + .borders(Borders::ALL) + .border_style(Style::default().fg(BORDER)) + .padding(Padding::horizontal(1))) + .alignment(Alignment::Center); + f.render_widget(widget, area); } - info_lines.push(Line::from("")); + // Tail Position + let (tail_val, tail_sub): (String, Option<&str>) = if let Some(pos) = &state.tail_position { + (format!("{}", pos.seq_num), Some("records")) + } else if state.loading { + ("...".to_string(), None) + } else { + ("--".to_string(), None) + }; + render_stat_card(f, stats_chunks[0], "Tail Position", &tail_val, tail_sub); + + // Last Timestamp + let ts_val = if let Some(pos) = &state.tail_position { + if pos.timestamp > 0 { + // Format as relative time if recent + let now_ms = std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .map(|d| d.as_millis() as u64) + .unwrap_or(0); + let age_secs = now_ms.saturating_sub(pos.timestamp) / 1000; + if age_secs < 60 { + format!("{}s ago", age_secs) + } else if age_secs < 3600 { + format!("{}m ago", age_secs / 60) + } else if age_secs < 86400 { + format!("{}h ago", age_secs / 3600) + } else { + format!("{}d ago", age_secs / 86400) + } + } else { + "never".to_string() + } + } else { + "--".to_string() + }; + render_stat_card(f, stats_chunks[1], "Last Write", &ts_val, None); - // Config - if let Some(config) = &state.config { - let storage = config - .storage_class + // Storage Class + let storage_val = if let Some(config) = &state.config { + config.storage_class .as_ref() .map(|s| format!("{:?}", s).to_lowercase()) - .unwrap_or_else(|| "default".to_string()); - - info_lines.push(Line::from(vec![ - Span::styled("Storage Class", Style::default().fg(TEXT_MUTED)), - ])); - info_lines.push(Line::from(vec![ - Span::styled(storage, Style::default().fg(TEXT_SECONDARY)), - ])); - } + .unwrap_or_else(|| "default".to_string()) + } else { + "--".to_string() + }; + render_stat_card(f, stats_chunks[2], "Storage", &storage_val, None); - let info = Paragraph::new(info_lines).block(info_block); - f.render_widget(info, chunks[0]); + // Retention + let retention_val = if let Some(config) = &state.config { + config.retention_policy + .as_ref() + .map(|p| match p { + crate::types::RetentionPolicy::Age(dur) => { + let secs = dur.as_secs(); + if secs >= 86400 { + format!("{}d", secs / 86400) + } else if secs >= 3600 { + format!("{}h", secs / 3600) + } else { + format!("{}s", secs) + } + } + crate::types::RetentionPolicy::Infinite => "infinite".to_string(), + }) + .unwrap_or_else(|| "infinite".to_string()) + } else { + "--".to_string() + }; + render_stat_card(f, stats_chunks[3], "Retention", &retention_val, None); - // Right: Actions panel + // === Actions === let actions_block = Block::default() - .title(Line::from(Span::styled(" Actions ", Style::default().fg(TEXT_PRIMARY).bold()))) + .title(Line::from(vec![ + Span::styled(" Read Stream ", Style::default().fg(TEXT_PRIMARY).bold()), + ])) .borders(Borders::ALL) .border_style(Style::default().fg(BORDER)) .padding(Padding::new(2, 2, 1, 1)); let actions = vec![ - ("t", "Tail stream", "Live follow from current position"), - ("r", "Custom read", "Configure start position and limits"), + ("t", "Tail", "Live follow from current position - see new records as they arrive"), + ("r", "Custom Read", "Configure start position, limits, and time range"), ]; - let mut action_lines = vec![Line::from("")]; + let mut action_lines = vec![]; for (i, (key, title, desc)) in actions.iter().enumerate() { let is_selected = i == state.selected_action; - let indicator = if is_selected { ">" } else { " " }; - action_lines.push(Line::from(vec![ - Span::styled(indicator, Style::default().fg(GREEN).bold()), - Span::raw(" "), - Span::styled( - format!("[{}]", key), - Style::default().fg(if is_selected { GREEN } else { GREEN_DIM }).bold(), - ), - Span::raw(" "), - Span::styled( - *title, - Style::default().fg(if is_selected { TEXT_PRIMARY } else { TEXT_SECONDARY }), - ), - ])); - action_lines.push(Line::from(vec![ - Span::styled( - format!(" {}", desc), - Style::default().fg(TEXT_MUTED), - ), - ])); + if is_selected { + action_lines.push(Line::from(vec![ + Span::styled("> ", Style::default().fg(GREEN)), + Span::styled(format!("[{}] ", key), Style::default().fg(GREEN).bold()), + Span::styled(*title, Style::default().fg(TEXT_PRIMARY).bold()), + ])); + action_lines.push(Line::from(vec![ + Span::styled(" ", Style::default()), + Span::styled(*desc, Style::default().fg(TEXT_SECONDARY)), + ])); + } else { + action_lines.push(Line::from(vec![ + Span::styled(" ", Style::default()), + Span::styled(format!("[{}] ", key), Style::default().fg(GREEN_DIM)), + Span::styled(*title, Style::default().fg(TEXT_MUTED)), + ])); + } action_lines.push(Line::from("")); } let actions_paragraph = Paragraph::new(action_lines).block(actions_block); - f.render_widget(actions_paragraph, chunks[1]); + f.render_widget(actions_paragraph, chunks[2]); } fn draw_read_view(f: &mut Frame, area: Rect, state: &ReadViewState) { @@ -1066,145 +1102,158 @@ fn draw_input_dialog(f: &mut Frame, mode: &InputMode) { count_limit, byte_limit, until_timestamp, + clamp, + format, selected, editing, } => { + // Radio button display + let radio = |opt: ReadStartFrom, current: &ReadStartFrom| { + if opt == *current { "(o)" } else { "( )" } + }; + let checkbox = |checked: bool| if checked { "[x]" } else { "[ ]" }; + + // Row selection indicator let sel = |idx: usize, s: &usize| if idx == *s { ">" } else { " " }; - let editable = |idx: usize, s: &usize, e: &bool| { - if idx == *s && *e { GREEN } else if idx == *s { TEXT_PRIMARY } else { TEXT_SECONDARY } + + // Input field styling - shows cursor when editing + let input_field = |value: &str, is_editing: bool, placeholder: &str| -> String { + if is_editing { + format!("[{}|]", value) + } else if value.is_empty() { + format!("[{}]", placeholder) + } else { + format!("[{}]", value) + } }; - let start_str = match start_from { - ReadStartFrom::Tail => "tail (live follow)", - ReadStartFrom::SeqNum => "sequence number", - ReadStartFrom::Timestamp => "timestamp (ms)", - ReadStartFrom::Ago => "time ago", - ReadStartFrom::TailOffset => "tail offset", + let unit_str = match ago_unit { + AgoUnit::Seconds => "s", + AgoUnit::Minutes => "m", + AgoUnit::Hours => "h", + AgoUnit::Days => "d", }; let mut lines = vec![ Line::from(vec![ Span::styled(format!("{}/{}", basin, stream), Style::default().fg(GREEN).bold()), ]), - Line::from(""), - Line::from(Span::styled("-- Start Position --", Style::default().fg(TEXT_MUTED))), - Line::from(vec![ - Span::styled(sel(0, selected), Style::default().fg(GREEN)), - Span::styled(format!(" Start from: < {} >", start_str), Style::default().fg(if *selected == 0 { TEXT_PRIMARY } else { TEXT_SECONDARY })), - ]), + Line::from(Span::styled("Start from:", Style::default().fg(TEXT_MUTED))), ]; - // Show relevant input based on start_from - match start_from { - ReadStartFrom::Tail => { - lines.push(Line::from(Span::styled(" (follows new records only)", Style::default().fg(TEXT_MUTED)))); - } - ReadStartFrom::SeqNum => { - let display = if *editing && *selected == 1 { - format!("{}|", seq_num_value) - } else { - if seq_num_value.is_empty() { "0".to_string() } else { seq_num_value.clone() } - }; - lines.push(Line::from(vec![ - Span::styled(sel(1, selected), Style::default().fg(GREEN)), - Span::styled(format!(" Seq num: {}", display), Style::default().fg(editable(1, selected, editing))), - ])); - } - ReadStartFrom::Timestamp => { - let display = if *editing && *selected == 2 { - format!("{}|", timestamp_value) - } else { - if timestamp_value.is_empty() { "(enter ms)".to_string() } else { format!("{} ms", timestamp_value) } - }; - lines.push(Line::from(vec![ - Span::styled(sel(2, selected), Style::default().fg(GREEN)), - Span::styled(format!(" Timestamp: {}", display), Style::default().fg(editable(2, selected, editing))), - ])); - } - ReadStartFrom::Ago => { - let display = if *editing && *selected == 3 { - format!("{}|", ago_value) - } else { - if ago_value.is_empty() { "5".to_string() } else { ago_value.clone() } - }; - lines.push(Line::from(vec![ - Span::styled(sel(3, selected), Style::default().fg(GREEN)), - Span::styled(format!(" Value: {}", display), Style::default().fg(editable(3, selected, editing))), - ])); - lines.push(Line::from(vec![ - Span::styled(sel(4, selected), Style::default().fg(GREEN)), - Span::styled(format!(" Unit: < {} >", match ago_unit { - AgoUnit::Seconds => "seconds", - AgoUnit::Minutes => "minutes", - AgoUnit::Hours => "hours", - AgoUnit::Days => "days", - }), Style::default().fg(if *selected == 4 { TEXT_PRIMARY } else { TEXT_SECONDARY })), - ])); - } - ReadStartFrom::TailOffset => { - let display = if *editing && *selected == 5 { - format!("{}|", tail_offset_value) - } else { - if tail_offset_value.is_empty() { "10".to_string() } else { format!("{} records back", tail_offset_value) } - }; - lines.push(Line::from(vec![ - Span::styled(sel(5, selected), Style::default().fg(GREEN)), - Span::styled(format!(" Offset: {}", display), Style::default().fg(editable(5, selected, editing))), - ])); - } - } + // Row 0: From sequence number + let is_seq = *start_from == ReadStartFrom::SeqNum; + let seq_field = input_field(seq_num_value, *editing && *selected == 0, "0"); + lines.push(Line::from(vec![ + Span::styled(sel(0, selected), Style::default().fg(GREEN)), + Span::styled(format!("{} ", radio(ReadStartFrom::SeqNum, start_from)), + Style::default().fg(if is_seq { GREEN } else { TEXT_MUTED })), + Span::styled("seq ", Style::default().fg(if *selected == 0 { TEXT_PRIMARY } else { TEXT_SECONDARY })), + Span::styled(seq_field, Style::default().fg(if *selected == 0 && *editing { GREEN } else if is_seq { TEXT_PRIMARY } else { TEXT_MUTED })), + ])); - lines.push(Line::from("")); - lines.push(Line::from(Span::styled("-- Limits (optional) --", Style::default().fg(TEXT_MUTED)))); + // Row 1: From timestamp + let is_ts = *start_from == ReadStartFrom::Timestamp; + let ts_field = input_field(timestamp_value, *editing && *selected == 1, "0"); + lines.push(Line::from(vec![ + Span::styled(sel(1, selected), Style::default().fg(GREEN)), + Span::styled(format!("{} ", radio(ReadStartFrom::Timestamp, start_from)), + Style::default().fg(if is_ts { GREEN } else { TEXT_MUTED })), + Span::styled("ts ", Style::default().fg(if *selected == 1 { TEXT_PRIMARY } else { TEXT_SECONDARY })), + Span::styled(ts_field, Style::default().fg(if *selected == 1 && *editing { GREEN } else if is_ts { TEXT_PRIMARY } else { TEXT_MUTED })), + Span::styled(" ms", Style::default().fg(TEXT_MUTED)), + ])); - // Count limit - let count_display = if *editing && *selected == 6 { - format!("{}|", count_limit) - } else { - if count_limit.is_empty() { "unlimited".to_string() } else { format!("{} records", count_limit) } - }; + // Row 2: From time ago + let is_ago = *start_from == ReadStartFrom::Ago; + let ago_field = input_field(ago_value, *editing && *selected == 2, "5"); + lines.push(Line::from(vec![ + Span::styled(sel(2, selected), Style::default().fg(GREEN)), + Span::styled(format!("{} ", radio(ReadStartFrom::Ago, start_from)), + Style::default().fg(if is_ago { GREEN } else { TEXT_MUTED })), + Span::styled("ago ", Style::default().fg(if *selected == 2 { TEXT_PRIMARY } else { TEXT_SECONDARY })), + Span::styled(ago_field, Style::default().fg(if *selected == 2 && *editing { GREEN } else if is_ago { TEXT_PRIMARY } else { TEXT_MUTED })), + Span::styled(format!(" <{}> tab", unit_str), Style::default().fg(if is_ago { GREEN_DIM } else { BORDER })), + ])); + + // Row 3: From tail offset + let is_off = *start_from == ReadStartFrom::TailOffset; + let off_field = input_field(tail_offset_value, *editing && *selected == 3, "10"); + lines.push(Line::from(vec![ + Span::styled(sel(3, selected), Style::default().fg(GREEN)), + Span::styled(format!("{} ", radio(ReadStartFrom::TailOffset, start_from)), + Style::default().fg(if is_off { GREEN } else { TEXT_MUTED })), + Span::styled("off ", Style::default().fg(if *selected == 3 { TEXT_PRIMARY } else { TEXT_SECONDARY })), + Span::styled(off_field, Style::default().fg(if *selected == 3 && *editing { GREEN } else if is_off { TEXT_PRIMARY } else { TEXT_MUTED })), + Span::styled(" back", Style::default().fg(TEXT_MUTED)), + ])); + + lines.push(Line::from(Span::styled("Limits:", Style::default().fg(TEXT_MUTED)))); + + // Row 4: Max records + let cnt_field = input_field(count_limit, *editing && *selected == 4, "-"); + lines.push(Line::from(vec![ + Span::styled(sel(4, selected), Style::default().fg(GREEN)), + Span::styled(" count ", Style::default().fg(if *selected == 4 { TEXT_PRIMARY } else { TEXT_SECONDARY })), + Span::styled(cnt_field, Style::default().fg(if *selected == 4 && *editing { GREEN } else { TEXT_MUTED })), + ])); + + // Row 5: Max bytes + let byte_field = input_field(byte_limit, *editing && *selected == 5, "-"); + lines.push(Line::from(vec![ + Span::styled(sel(5, selected), Style::default().fg(GREEN)), + Span::styled(" bytes ", Style::default().fg(if *selected == 5 { TEXT_PRIMARY } else { TEXT_SECONDARY })), + Span::styled(byte_field, Style::default().fg(if *selected == 5 && *editing { GREEN } else { TEXT_MUTED })), + ])); + + // Row 6: Until timestamp + let until_field = input_field(until_timestamp, *editing && *selected == 6, "-"); lines.push(Line::from(vec![ Span::styled(sel(6, selected), Style::default().fg(GREEN)), - Span::styled(format!(" Max records: {}", count_display), Style::default().fg(editable(6, selected, editing))), + Span::styled(" until ", Style::default().fg(if *selected == 6 { TEXT_PRIMARY } else { TEXT_SECONDARY })), + Span::styled(until_field, Style::default().fg(if *selected == 6 && *editing { GREEN } else { TEXT_MUTED })), + Span::styled(" ms", Style::default().fg(TEXT_MUTED)), ])); - // Byte limit - let byte_display = if *editing && *selected == 7 { - format!("{}|", byte_limit) - } else { - if byte_limit.is_empty() { "unlimited".to_string() } else { format!("{} bytes", byte_limit) } - }; + lines.push(Line::from(Span::styled("Options:", Style::default().fg(TEXT_MUTED)))); + + // Row 7: Clamp lines.push(Line::from(vec![ Span::styled(sel(7, selected), Style::default().fg(GREEN)), - Span::styled(format!(" Max bytes: {}", byte_display), Style::default().fg(editable(7, selected, editing))), + Span::styled(format!(" {} clamp to tail", checkbox(*clamp)), + Style::default().fg(if *selected == 7 { TEXT_PRIMARY } else { TEXT_SECONDARY })), ])); - // Until timestamp - let until_display = if *editing && *selected == 8 { - format!("{}|", until_timestamp) - } else { - if until_timestamp.is_empty() { "none".to_string() } else { format!("{} ms", until_timestamp) } - }; + // Row 8: Format lines.push(Line::from(vec![ Span::styled(sel(8, selected), Style::default().fg(GREEN)), - Span::styled(format!(" Until timestamp: {}", until_display), Style::default().fg(editable(8, selected, editing))), + Span::styled(format!(" format <{}>", format.as_str()), + Style::default().fg(if *selected == 8 { TEXT_PRIMARY } else { TEXT_SECONDARY })), ])); lines.push(Line::from("")); + + // Row 9: Start button + let btn_style = if *selected == 9 { + Style::default().fg(BG_DARK).bg(GREEN).bold() + } else { + Style::default().fg(GREEN).bold() + }; lines.push(Line::from(vec![ Span::styled(sel(9, selected), Style::default().fg(GREEN)), - Span::styled(" [ Start Reading ]", Style::default().fg(if *selected == 9 { GREEN } else { TEXT_SECONDARY }).bold()), + Span::styled(" ", Style::default()), + Span::styled(" Start Reading ", btn_style), ])); ( " Custom Read ", lines, - "jk nav | space/enter edit | esc cancel", + "jk nav | spc/ret select | tab unit | esc", ) } }; - let area = centered_rect(60, 60, f.area()); + let area = centered_rect(50, 70, f.area()); let block = Block::default() .title(Line::from(Span::styled(title, Style::default().fg(TEXT_PRIMARY).bold()))) From 09c4ef49c0264cd9f18ea6d8315a37c6fd49b2ed Mon Sep 17 00:00:00 2001 From: Mehul Arora Date: Fri, 23 Jan 2026 00:21:15 -0500 Subject: [PATCH 04/31] .. --- src/tui/app.rs | 848 +++++++++++++++++++++++++++++++++++++++++++-- src/tui/event.rs | 9 + src/tui/ui.rs | 878 +++++++++++++++++++++++++++++++++++++++-------- 3 files changed, 1572 insertions(+), 163 deletions(-) diff --git a/src/tui/app.rs b/src/tui/app.rs index 49d5948..38c1941 100644 --- a/src/tui/app.rs +++ b/src/tui/app.rs @@ -1,6 +1,7 @@ use std::collections::VecDeque; use std::time::Duration; +use base64ct::Encoding; use crossterm::event::{self, Event as CrosstermEvent, KeyCode, KeyEvent, KeyModifiers}; use futures::StreamExt; use ratatui::{Terminal, prelude::Backend}; @@ -26,6 +27,7 @@ pub enum Screen { Streams(StreamsState), StreamDetail(StreamDetailState), ReadView(ReadViewState), + AppendView(AppendViewState), } /// State for the basins list screen @@ -67,9 +69,41 @@ pub struct ReadViewState { pub stream_name: StreamName, pub records: VecDeque, pub is_tailing: bool, - pub scroll_offset: usize, + pub selected: usize, pub paused: bool, pub loading: bool, + pub show_detail: bool, + pub hide_list: bool, + pub output_file: Option, +} + +/// State for the append view +#[derive(Debug, Clone)] +pub struct AppendViewState { + pub basin_name: BasinName, + pub stream_name: StreamName, + // Record fields + pub body: String, + pub headers: Vec<(String, String)>, // List of (key, value) pairs + pub match_seq_num: String, // Empty = none + pub fencing_token: String, // Empty = none + // UI state + pub selected: usize, // 0=body, 1=headers, 2=match_seq, 3=fencing, 4=send + pub editing: bool, + pub header_key_input: String, // For adding new header + pub header_value_input: String, + pub editing_header_key: bool, // true = editing key, false = editing value + // Results + pub history: Vec, + pub appending: bool, +} + +/// Result of an append operation +#[derive(Debug, Clone)] +pub struct AppendResult { + pub seq_num: u64, + pub body_preview: String, + pub header_count: usize, } /// Status message level @@ -149,10 +183,29 @@ pub enum InputMode { // Options clamp: bool, format: ReadFormat, + output_file: String, // Empty = display only, path = write to file // UI state selected: usize, editing: bool, }, + /// Fence a stream (set new fencing token) + Fence { + basin: BasinName, + stream: StreamName, + new_token: String, + current_token: String, // Empty = no current token + selected: usize, // 0=new_token, 1=current_token, 2=submit + editing: bool, + }, + /// Trim a stream (delete records before seq num) + Trim { + basin: BasinName, + stream: StreamName, + trim_point: String, + fencing_token: String, // Empty = no fencing token + selected: usize, // 0=trim_point, 1=fencing_token, 2=submit + editing: bool, + }, } /// Retention policy option for UI @@ -433,6 +486,14 @@ impl App { // Keep buffer bounded while state.records.len() > MAX_RECORDS_BUFFER { state.records.pop_front(); + // Adjust selected if we removed records from front + if state.selected > 0 { + state.selected = state.selected.saturating_sub(1); + } + } + // Auto-follow: keep selected at latest when tailing + if state.is_tailing { + state.selected = state.records.len().saturating_sub(1); } } } @@ -654,6 +715,59 @@ impl App { } } + Event::StreamFenced(result) => { + self.input_mode = InputMode::Normal; + match result { + Ok(token) => { + self.message = Some(StatusMessage { + text: format!("Stream fenced with token: {}", token), + level: MessageLevel::Success, + }); + } + Err(e) => { + self.message = Some(StatusMessage { + text: format!("Failed to fence stream: {e}"), + level: MessageLevel::Error, + }); + } + } + } + + Event::StreamTrimmed(result) => { + self.input_mode = InputMode::Normal; + match result { + Ok((trim_point, new_tail)) => { + self.message = Some(StatusMessage { + text: format!("Trimmed to {} (tail: {})", trim_point, new_tail), + level: MessageLevel::Success, + }); + } + Err(e) => { + self.message = Some(StatusMessage { + text: format!("Failed to trim stream: {e}"), + level: MessageLevel::Error, + }); + } + } + } + + Event::RecordAppended(result) => { + if let Screen::AppendView(state) = &mut self.screen { + state.appending = false; + match result { + Ok((seq_num, body_preview, header_count)) => { + state.history.push(AppendResult { seq_num, body_preview, header_count }); + } + Err(e) => { + self.message = Some(StatusMessage { + text: format!("Append failed: {e}"), + level: MessageLevel::Error, + }); + } + } + } + } + Event::Error(e) => { self.message = Some(StatusMessage { text: e.to_string(), @@ -706,7 +820,8 @@ impl App { Screen::Basins(_) => self.handle_basins_key(key, tx), Screen::Streams(_) => self.handle_streams_key(key, tx), Screen::StreamDetail(_) => self.handle_stream_detail_key(key, tx), - Screen::ReadView(_) => self.handle_read_view_key(key), + Screen::ReadView(_) => self.handle_read_view_key(key, tx), + Screen::AppendView(_) => self.handle_append_view_key(key, tx), } } @@ -1006,6 +1121,7 @@ impl App { until_timestamp, clamp, format, + output_file, selected, editing, } => { @@ -1028,6 +1144,7 @@ impl App { 4 => { count_limit.pop(); } 5 => { byte_limit.pop(); } 6 => { until_timestamp.pop(); } + 9 => { output_file.pop(); } _ => {} } } @@ -1043,6 +1160,10 @@ impl App { _ => {} } } + KeyCode::Char(c) if *selected == 9 => { + // Output file accepts any printable char + output_file.push(c); + } _ => {} } return; @@ -1058,8 +1179,9 @@ impl App { // 6: Until timestamp // 7: Clamp (checkbox) // 8: Format (selector) - // 9: Start button - const MAX_ROW: usize = 9; + // 9: Output file + // 10: Start button + const MAX_ROW: usize = 10; match key.code { KeyCode::Esc => { @@ -1115,7 +1237,8 @@ impl App { 6 => *editing = true, // until_timestamp 7 => *clamp = !*clamp, 8 => *format = format.next(), - 9 => { + 9 => *editing = true, // output_file + 10 => { // Start reading - clone all values first let b = basin.clone(); let s = stream.clone(); @@ -1130,8 +1253,157 @@ impl App { let ut = until_timestamp.clone(); let clp = *clamp; let fmt = *format; + let of = output_file.clone(); self.input_mode = InputMode::Normal; - self.start_custom_read(b, s, sf, snv, tsv, agv, agu, tov, cl, bl, ut, clp, fmt, tx.clone()); + // Show message if writing to file + if !of.is_empty() { + self.message = Some(StatusMessage { + text: format!("Writing to {}", of), + level: MessageLevel::Info, + }); + } + self.start_custom_read(b, s, sf, snv, tsv, agv, agu, tov, cl, bl, ut, clp, fmt, of, tx.clone()); + } + _ => {} + } + } + _ => {} + } + } + + InputMode::Fence { + basin, + stream, + new_token, + current_token, + selected, + editing, + } => { + if *editing { + match key.code { + KeyCode::Esc | KeyCode::Enter => { + *editing = false; + } + KeyCode::Backspace => { + match *selected { + 0 => { new_token.pop(); } + 1 => { current_token.pop(); } + _ => {} + } + } + KeyCode::Char(c) => { + match *selected { + 0 => new_token.push(c), + 1 => current_token.push(c), + _ => {} + } + } + _ => {} + } + return; + } + + // Navigation: 0=new_token, 1=current_token, 2=submit + match key.code { + KeyCode::Esc => { + self.input_mode = InputMode::Normal; + } + KeyCode::Up | KeyCode::Char('k') => { + if *selected > 0 { + *selected -= 1; + } + } + KeyCode::Down | KeyCode::Char('j') => { + if *selected < 2 { + *selected += 1; + } + } + KeyCode::Enter => { + match *selected { + 0 | 1 => *editing = true, + 2 => { + // Submit fence + if !new_token.is_empty() { + let b = basin.clone(); + let s = stream.clone(); + let nt = new_token.clone(); + let ct = if current_token.is_empty() { + None + } else { + Some(current_token.clone()) + }; + self.fence_stream(b, s, nt, ct, tx.clone()); + } + } + _ => {} + } + } + _ => {} + } + } + + InputMode::Trim { + basin, + stream, + trim_point, + fencing_token, + selected, + editing, + } => { + if *editing { + match key.code { + KeyCode::Esc | KeyCode::Enter => { + *editing = false; + } + KeyCode::Backspace => { + match *selected { + 0 => { trim_point.pop(); } + 1 => { fencing_token.pop(); } + _ => {} + } + } + KeyCode::Char(c) => { + match *selected { + 0 if c.is_ascii_digit() => trim_point.push(c), + 1 => fencing_token.push(c), + _ => {} + } + } + _ => {} + } + return; + } + + // Navigation: 0=trim_point, 1=fencing_token, 2=submit + match key.code { + KeyCode::Esc => { + self.input_mode = InputMode::Normal; + } + KeyCode::Up | KeyCode::Char('k') => { + if *selected > 0 { + *selected -= 1; + } + } + KeyCode::Down | KeyCode::Char('j') => { + if *selected < 2 { + *selected += 1; + } + } + KeyCode::Enter => { + match *selected { + 0 | 1 => *editing = true, + 2 => { + // Submit trim + if let Ok(tp) = trim_point.parse::() { + let b = basin.clone(); + let s = stream.clone(); + let ft = if fencing_token.is_empty() { + None + } else { + Some(fencing_token.clone()) + }; + self.trim_stream(b, s, tp, ft, tx.clone()); + } } _ => {} } @@ -1419,8 +1691,8 @@ impl App { } } KeyCode::Down | KeyCode::Char('j') => { - if state.selected_action < 1 { - // 2 actions: tail, custom read + if state.selected_action < 4 { + // 5 actions: tail, custom read, append, fence, trim state.selected_action += 1; } } @@ -1430,6 +1702,9 @@ impl App { match state.selected_action { 0 => self.start_tail(basin_name, stream_name, tx), // Tail 1 => self.open_custom_read_dialog(basin_name, stream_name), // Custom read + 2 => self.open_append_view(basin_name, stream_name), // Append + 3 => self.open_fence_dialog(basin_name, stream_name), // Fence + 4 => self.open_trim_dialog(basin_name, stream_name), // Trim _ => {} } } @@ -1445,6 +1720,12 @@ impl App { let stream_name = state.stream_name.clone(); self.open_custom_read_dialog(basin_name, stream_name); } + KeyCode::Char('a') => { + // Append records + let basin_name = state.basin_name.clone(); + let stream_name = state.stream_name.clone(); + self.open_append_view(basin_name, stream_name); + } KeyCode::Char('e') => { let basin_name = state.basin_name.clone(); let stream_name = state.stream_name.clone(); @@ -1462,26 +1743,52 @@ impl App { }; self.load_stream_config_for_reconfig(basin_name, stream_name, tx); } + KeyCode::Char('f') => { + // Fence stream + let basin_name = state.basin_name.clone(); + let stream_name = state.stream_name.clone(); + self.open_fence_dialog(basin_name, stream_name); + } + KeyCode::Char('m') => { + // Trim stream + let basin_name = state.basin_name.clone(); + let stream_name = state.stream_name.clone(); + self.open_trim_dialog(basin_name, stream_name); + } _ => {} } } - fn handle_read_view_key(&mut self, key: KeyEvent) { + fn handle_read_view_key(&mut self, key: KeyEvent, tx: mpsc::UnboundedSender) { let Screen::ReadView(state) = &mut self.screen else { return; }; + // If showing detail panel, handle differently + if state.show_detail { + match key.code { + KeyCode::Esc | KeyCode::Enter | KeyCode::Char('q') => { + state.show_detail = false; + } + _ => {} + } + return; + } + match key.code { KeyCode::Esc | KeyCode::Char('q') => { - // Go back to stream detail + // Go back to stream detail and reload data + let basin_name = state.basin_name.clone(); + let stream_name = state.stream_name.clone(); self.screen = Screen::StreamDetail(StreamDetailState { - basin_name: state.basin_name.clone(), - stream_name: state.stream_name.clone(), + basin_name: basin_name.clone(), + stream_name: stream_name.clone(), config: None, tail_position: None, selected_action: 0, - loading: false, + loading: true, }); + self.load_stream_detail(basin_name, stream_name, tx); } KeyCode::Char(' ') => { state.paused = !state.paused; @@ -1495,21 +1802,31 @@ impl App { }); } KeyCode::Up | KeyCode::Char('k') => { - if state.scroll_offset > 0 { - state.scroll_offset -= 1; + if state.selected > 0 { + state.selected -= 1; } } KeyCode::Down | KeyCode::Char('j') => { - let max_offset = state.records.len().saturating_sub(1); - if state.scroll_offset < max_offset { - state.scroll_offset += 1; + let max_idx = state.records.len().saturating_sub(1); + if state.selected < max_idx { + state.selected += 1; } } KeyCode::Char('g') => { - state.scroll_offset = 0; + state.selected = 0; } KeyCode::Char('G') => { - state.scroll_offset = state.records.len().saturating_sub(1); + state.selected = state.records.len().saturating_sub(1); + } + KeyCode::Tab | KeyCode::Char('l') => { + // Toggle list pane visibility + state.hide_list = !state.hide_list; + } + KeyCode::Enter | KeyCode::Char('h') => { + // Show headers popup + if !state.records.is_empty() { + state.show_detail = true; + } } _ => {} } @@ -1789,9 +2106,12 @@ impl App { stream_name: stream_name.clone(), records: VecDeque::new(), is_tailing: true, - scroll_offset: 0, + selected: 0, paused: false, loading: true, + show_detail: false, + hide_list: false, + output_file: None, }); let s2 = self.s2.clone(); @@ -1862,6 +2182,7 @@ impl App { until_timestamp: String::new(), clamp: true, format: ReadFormat::Text, + output_file: String::new(), selected: 0, editing: false, }; @@ -1883,16 +2204,21 @@ impl App { until_timestamp: String, clamp: bool, format: ReadFormat, + output_file: String, tx: mpsc::UnboundedSender, ) { + let has_output = !output_file.is_empty(); self.screen = Screen::ReadView(ReadViewState { basin_name: basin_name.clone(), stream_name: stream_name.clone(), records: VecDeque::new(), is_tailing: true, - scroll_offset: 0, + selected: 0, paused: false, loading: true, + show_detail: false, + hide_list: false, + output_file: if has_output { Some(output_file.clone()) } else { None }, }); let s2 = self.s2.clone(); @@ -1940,6 +2266,13 @@ impl App { ReadFormat::JsonBase64 => RecordFormat::JsonBase64, }; + // Set up output file if specified + let output = if output_file.is_empty() { + RecordsOut::Stdout + } else { + RecordsOut::File(std::path::PathBuf::from(&output_file)) + }; + let args = ReadArgs { uri, seq_num, @@ -1951,16 +2284,66 @@ impl App { clamp, until, format: record_format, - output: RecordsOut::Stdout, + output: output.clone(), + }; + + // Open file writer if output file is specified + let mut file_writer: Option = if !output_file.is_empty() { + match tokio::fs::File::create(&output_file).await { + Ok(f) => Some(f), + Err(e) => { + let _ = tx.send(Event::Error(crate::error::CliError::RecordWrite(e.to_string()))); + return; + } + } + } else { + None }; match ops::read(&s2, &args).await { Ok(mut batch_stream) => { use futures::StreamExt; + use tokio::io::AsyncWriteExt; while let Some(batch_result) = batch_stream.next().await { match batch_result { Ok(batch) => { for record in batch.records { + // Write to file if specified + if let Some(ref mut writer) = file_writer { + let line = match record_format { + RecordFormat::Text => { + format!("{}\n", String::from_utf8_lossy(&record.body)) + } + RecordFormat::Json => { + format!("{}\n", serde_json::json!({ + "seq_num": record.seq_num, + "timestamp": record.timestamp, + "headers": record.headers.iter().map(|h| { + serde_json::json!({ + "name": String::from_utf8_lossy(&h.name), + "value": String::from_utf8_lossy(&h.value) + }) + }).collect::>(), + "body": String::from_utf8_lossy(&record.body).to_string() + })) + } + RecordFormat::JsonBase64 => { + format!("{}\n", serde_json::json!({ + "seq_num": record.seq_num, + "timestamp": record.timestamp, + "headers": record.headers.iter().map(|h| { + serde_json::json!({ + "name": String::from_utf8_lossy(&h.name), + "value": String::from_utf8_lossy(&h.value) + }) + }).collect::>(), + "body": base64ct::Base64::encode_string(&record.body) + })) + } + }; + let _ = writer.write_all(line.as_bytes()).await; + } + if tx.send(Event::RecordReceived(Ok(record))).is_err() { return; } @@ -2187,4 +2570,423 @@ impl App { } }); } + + /// Open the append view + fn open_append_view(&mut self, basin_name: BasinName, stream_name: StreamName) { + self.screen = Screen::AppendView(AppendViewState { + basin_name, + stream_name, + body: String::new(), + headers: Vec::new(), + match_seq_num: String::new(), + fencing_token: String::new(), + selected: 0, + editing: false, + header_key_input: String::new(), + header_value_input: String::new(), + editing_header_key: true, + history: Vec::new(), + appending: false, + }); + } + + /// Handle keys in append view + /// Layout: 0=body, 1=headers, 2=match_seq, 3=fencing, 4=send + fn handle_append_view_key(&mut self, key: KeyEvent, tx: mpsc::UnboundedSender) { + let Screen::AppendView(state) = &mut self.screen else { + return; + }; + + // Don't handle keys while appending + if state.appending { + return; + } + + // If editing a field, handle text input + if state.editing { + match key.code { + KeyCode::Esc => { + state.editing = false; + } + KeyCode::Enter => { + if state.selected == 1 { + // Headers: if editing key, move to value; if editing value, add header + if state.editing_header_key { + if !state.header_key_input.is_empty() { + state.editing_header_key = false; + } + } else { + // Add the header if key is not empty + if !state.header_key_input.is_empty() { + state.headers.push(( + state.header_key_input.clone(), + state.header_value_input.clone(), + )); + state.header_key_input.clear(); + state.header_value_input.clear(); + state.editing_header_key = true; + } + state.editing = false; + } + } else { + state.editing = false; + } + } + KeyCode::Tab if state.selected == 1 => { + // Toggle between key and value in headers + state.editing_header_key = !state.editing_header_key; + } + KeyCode::Backspace => { + match state.selected { + 0 => { state.body.pop(); } + 1 => { + if state.editing_header_key { + state.header_key_input.pop(); + } else { + state.header_value_input.pop(); + } + } + 2 => { state.match_seq_num.pop(); } + 3 => { state.fencing_token.pop(); } + _ => {} + } + } + KeyCode::Char(c) => { + match state.selected { + 0 => { state.body.push(c); } + 1 => { + if state.editing_header_key { + state.header_key_input.push(c); + } else { + state.header_value_input.push(c); + } + } + 2 => { + // Only allow digits for match_seq_num + if c.is_ascii_digit() { + state.match_seq_num.push(c); + } + } + 3 => { state.fencing_token.push(c); } + _ => {} + } + } + _ => {} + } + return; + } + + // Not editing - handle navigation + match key.code { + KeyCode::Esc => { + // Go back to stream detail + let basin_name = state.basin_name.clone(); + let stream_name = state.stream_name.clone(); + self.screen = Screen::StreamDetail(StreamDetailState { + basin_name: basin_name.clone(), + stream_name: stream_name.clone(), + config: None, + tail_position: None, + selected_action: 2, // Append action + loading: true, + }); + self.load_stream_detail(basin_name, stream_name, tx); + } + KeyCode::Char('j') | KeyCode::Down => { + state.selected = (state.selected + 1).min(4); + } + KeyCode::Char('k') | KeyCode::Up => { + state.selected = state.selected.saturating_sub(1); + } + KeyCode::Char('d') if state.selected == 1 => { + // Delete last header + state.headers.pop(); + } + KeyCode::Enter => { + if state.selected == 4 { + // Send button - append the record + if !state.body.is_empty() { + let basin_name = state.basin_name.clone(); + let stream_name = state.stream_name.clone(); + let body = state.body.clone(); + let headers = state.headers.clone(); + let match_seq_num = state.match_seq_num.parse::().ok(); + let fencing_token = if state.fencing_token.is_empty() { + None + } else { + Some(state.fencing_token.clone()) + }; + state.body.clear(); + // Keep headers for convenience (user might want to send similar records) + state.appending = true; + self.append_record(basin_name, stream_name, body, headers, match_seq_num, fencing_token, tx); + } + } else { + // Start editing the selected field + state.editing = true; + if state.selected == 1 { + state.editing_header_key = true; + } + } + } + _ => {} + } + } + + /// Append a single record to the stream + fn append_record( + &self, + basin_name: BasinName, + stream_name: StreamName, + body: String, + headers: Vec<(String, String)>, + match_seq_num: Option, + fencing_token: Option, + tx: mpsc::UnboundedSender, + ) { + let s2 = self.s2.clone(); + let body_preview = if body.len() > 50 { + format!("{}...", &body[..50]) + } else { + body.clone() + }; + let header_count = headers.len(); + + tokio::spawn(async move { + use s2_sdk::types::{AppendInput, AppendRecord, AppendRecordBatch, FencingToken, Header}; + + let stream = s2.basin(basin_name).stream(stream_name); + + let mut record = match AppendRecord::new(body.into_bytes()) { + Ok(r) => r, + Err(e) => { + let _ = tx.send(Event::RecordAppended(Err(crate::error::CliError::RecordWrite( + e.to_string(), + )))); + return; + } + }; + + // Add headers if any + if !headers.is_empty() { + let parsed_headers: Vec
= headers + .into_iter() + .map(|(k, v)| Header::new(k.into_bytes(), v.into_bytes())) + .collect(); + record = match record.with_headers(parsed_headers) { + Ok(r) => r, + Err(e) => { + let _ = tx.send(Event::RecordAppended(Err(crate::error::CliError::RecordWrite( + e.to_string(), + )))); + return; + } + }; + } + + let records = match AppendRecordBatch::try_from_iter([record]) { + Ok(batch) => batch, + Err(e) => { + let _ = tx.send(Event::RecordAppended(Err(crate::error::CliError::RecordWrite( + e.to_string(), + )))); + return; + } + }; + + let mut input = AppendInput::new(records); + + // Add match_seq_num if specified + if let Some(seq) = match_seq_num { + input = input.with_match_seq_num(seq); + } + + // Add fencing token if specified + if let Some(token_str) = fencing_token { + match token_str.parse::() { + Ok(token) => { + input = input.with_fencing_token(token); + } + Err(e) => { + let _ = tx.send(Event::RecordAppended(Err(crate::error::CliError::RecordWrite( + format!("Invalid fencing token: {}", e), + )))); + return; + } + } + } + + match stream.append(input).await { + Ok(output) => { + let _ = tx.send(Event::RecordAppended(Ok((output.start.seq_num, body_preview, header_count)))); + } + Err(e) => { + let _ = tx.send(Event::RecordAppended(Err(crate::error::CliError::op( + crate::error::OpKind::Append, + e, + )))); + } + } + }); + } + + /// Open fence dialog + fn open_fence_dialog(&mut self, basin: BasinName, stream: StreamName) { + self.input_mode = InputMode::Fence { + basin, + stream, + new_token: String::new(), + current_token: String::new(), + selected: 0, + editing: false, + }; + } + + /// Open trim dialog + fn open_trim_dialog(&mut self, basin: BasinName, stream: StreamName) { + self.input_mode = InputMode::Trim { + basin, + stream, + trim_point: String::new(), + fencing_token: String::new(), + selected: 0, + editing: false, + }; + } + + /// Fence a stream + fn fence_stream( + &self, + basin: BasinName, + stream: StreamName, + new_token: String, + current_token: Option, + tx: mpsc::UnboundedSender, + ) { + let s2 = self.s2.clone(); + let new_token_clone = new_token.clone(); + + tokio::spawn(async move { + use s2_sdk::types::{AppendInput, AppendRecordBatch, CommandRecord, FencingToken}; + + let stream_client = s2.basin(basin).stream(stream); + + // Parse the new fencing token + let new_fencing_token = match new_token.parse::() { + Ok(token) => token, + Err(e) => { + let _ = tx.send(Event::StreamFenced(Err(crate::error::CliError::RecordWrite( + format!("Invalid new fencing token: {}", e), + )))); + return; + } + }; + + // Create fence command record + let command = CommandRecord::fence(new_fencing_token); + let record: s2_sdk::types::AppendRecord = command.into(); + let records = match AppendRecordBatch::try_from_iter([record]) { + Ok(batch) => batch, + Err(e) => { + let _ = tx.send(Event::StreamFenced(Err(crate::error::CliError::RecordWrite( + e.to_string(), + )))); + return; + } + }; + + let mut input = AppendInput::new(records); + + // Add current fencing token if specified + if let Some(token_str) = current_token { + if !token_str.is_empty() { + match token_str.parse::() { + Ok(token) => { + input = input.with_fencing_token(token); + } + Err(e) => { + let _ = tx.send(Event::StreamFenced(Err(crate::error::CliError::RecordWrite( + format!("Invalid current fencing token: {}", e), + )))); + return; + } + } + } + } + + match stream_client.append(input).await { + Ok(_) => { + let _ = tx.send(Event::StreamFenced(Ok(new_token_clone))); + } + Err(e) => { + let _ = tx.send(Event::StreamFenced(Err(crate::error::CliError::op( + crate::error::OpKind::Fence, + e, + )))); + } + } + }); + } + + /// Trim a stream + fn trim_stream( + &self, + basin: BasinName, + stream: StreamName, + trim_point: u64, + fencing_token: Option, + tx: mpsc::UnboundedSender, + ) { + let s2 = self.s2.clone(); + + tokio::spawn(async move { + use s2_sdk::types::{AppendInput, AppendRecordBatch, CommandRecord, FencingToken}; + + let stream_client = s2.basin(basin).stream(stream); + + // Create trim command record + let command = CommandRecord::trim(trim_point); + let record: s2_sdk::types::AppendRecord = command.into(); + let records = match AppendRecordBatch::try_from_iter([record]) { + Ok(batch) => batch, + Err(e) => { + let _ = tx.send(Event::StreamTrimmed(Err(crate::error::CliError::RecordWrite( + e.to_string(), + )))); + return; + } + }; + + let mut input = AppendInput::new(records); + + // Add fencing token if specified + if let Some(token_str) = fencing_token { + if !token_str.is_empty() { + match token_str.parse::() { + Ok(token) => { + input = input.with_fencing_token(token); + } + Err(e) => { + let _ = tx.send(Event::StreamTrimmed(Err(crate::error::CliError::RecordWrite( + format!("Invalid fencing token: {}", e), + )))); + return; + } + } + } + } + + match stream_client.append(input).await { + Ok(output) => { + let _ = tx.send(Event::StreamTrimmed(Ok((trim_point, output.tail.seq_num)))); + } + Err(e) => { + let _ = tx.send(Event::StreamTrimmed(Err(crate::error::CliError::op( + crate::error::OpKind::Trim, + e, + )))); + } + } + }); + } } diff --git a/src/tui/event.rs b/src/tui/event.rs index 2d4647e..e4fc00a 100644 --- a/src/tui/event.rs +++ b/src/tui/event.rs @@ -69,6 +69,15 @@ pub enum Event { /// Stream reconfigured successfully StreamReconfigured(Result<(), CliError>), + /// Record appended successfully (seq_num, body_preview, header_count) + RecordAppended(Result<(u64, String, usize), CliError>), + + /// Stream fenced successfully (new token) + StreamFenced(Result), + + /// Stream trimmed successfully (trim_point, new_tail_seq_num) + StreamTrimmed(Result<(u64, u64), CliError>), + /// An error occurred in a background task Error(CliError), } diff --git a/src/tui/ui.rs b/src/tui/ui.rs index cf7f0f0..d4ef469 100644 --- a/src/tui/ui.rs +++ b/src/tui/ui.rs @@ -3,12 +3,12 @@ use ratatui::{ layout::{Alignment, Constraint, Direction, Layout, Rect}, style::{Color, Style, Stylize}, text::{Line, Span}, - widgets::{Block, Borders, Clear, Padding, Paragraph, Wrap}, + widgets::{Block, Borders, Clear, Padding, Paragraph}, }; use crate::types::{StorageClass, TimestampingMode}; -use super::app::{App, AgoUnit, BasinsState, InputMode, MessageLevel, ReadStartFrom, ReadViewState, RetentionPolicyOption, Screen, StreamDetailState, StreamsState}; +use super::app::{App, AgoUnit, AppendViewState, BasinsState, InputMode, MessageLevel, ReadStartFrom, ReadViewState, RetentionPolicyOption, Screen, StreamDetailState, StreamsState}; // S2 Console dark theme const GREEN: Color = Color::Rgb(34, 197, 94); // Active green @@ -51,6 +51,7 @@ pub fn draw(f: &mut Frame, app: &App) { Screen::Streams(state) => draw_streams(f, chunks[0], state), Screen::StreamDetail(state) => draw_stream_detail(f, chunks[0], state), Screen::ReadView(state) => draw_read_view(f, chunks[0], state), + Screen::AppendView(state) => draw_append_view(f, chunks[0], state), } // Draw status bar @@ -521,9 +522,6 @@ fn draw_stream_detail(f: &mut Frame, area: Rect, state: &StreamDetailState) { // === Actions === let actions_block = Block::default() - .title(Line::from(vec![ - Span::styled(" Read Stream ", Style::default().fg(TEXT_PRIMARY).bold()), - ])) .borders(Borders::ALL) .border_style(Style::default().fg(BORDER)) .padding(Padding::new(2, 2, 1, 1)); @@ -531,6 +529,9 @@ fn draw_stream_detail(f: &mut Frame, area: Rect, state: &StreamDetailState) { let actions = vec![ ("t", "Tail", "Live follow from current position - see new records as they arrive"), ("r", "Custom Read", "Configure start position, limits, and time range"), + ("a", "Append", "Write records to this stream"), + ("f", "Fence", "Set a fencing token to block other writers"), + ("m", "Trim", "Delete records before a sequence number"), ]; let mut action_lines = vec![]; @@ -575,86 +576,478 @@ fn draw_read_view(f: &mut Frame, area: Rect, state: &ReadViewState) { let uri = format!("s2://{}/{}", state.basin_name, state.stream_name); - let block = Block::default() - .title(Line::from(vec![ - Span::styled(" ", Style::default()), - Span::styled(mode_text, Style::default().fg(mode_color).bold()), - Span::styled(" ", Style::default()), - Span::styled(&uri, Style::default().fg(TEXT_SECONDARY)), - Span::styled( - format!(" {} records ", state.records.len()), - Style::default().fg(TEXT_MUTED), - ), - ])) + // Build title spans + let mut title_spans = vec![ + Span::styled(" ", Style::default()), + Span::styled(mode_text, Style::default().fg(mode_color).bold()), + Span::styled(" ", Style::default()), + Span::styled(&uri, Style::default().fg(TEXT_SECONDARY)), + Span::styled( + format!(" {} records ", state.records.len()), + Style::default().fg(TEXT_MUTED), + ), + ]; + + // Add output file indicator if writing to file + if let Some(ref output) = state.output_file { + title_spans.push(Span::styled(" → ", Style::default().fg(TEXT_MUTED))); + title_spans.push(Span::styled(output, Style::default().fg(YELLOW))); + } + + // Main container with title + let outer_block = Block::default() + .title(Line::from(title_spans)) .borders(Borders::ALL) - .border_style(Style::default().fg(if state.is_tailing && !state.paused { GREEN } else { BORDER })) - .padding(Padding::horizontal(1)); + .border_style(Style::default().fg(if state.is_tailing && !state.paused { GREEN } else { BORDER })); + + let inner_area = outer_block.inner(area); + f.render_widget(outer_block, area); if state.records.is_empty() { let text = if state.loading { - Line::from(Span::styled("Waiting for records...", Style::default().fg(TEXT_MUTED))) + "Waiting for records..." } else { - Line::from(Span::styled("No records", Style::default().fg(TEXT_MUTED))) + "No records" }; - let para = Paragraph::new(text).block(block); - f.render_widget(para, area); + let para = Paragraph::new(Span::styled(text, Style::default().fg(TEXT_MUTED))) + .alignment(Alignment::Center); + f.render_widget(para, Rect::new(inner_area.x, inner_area.y + 2, inner_area.width, 1)); return; } - // Calculate visible records - let inner_height = area.height.saturating_sub(2) as usize; let total_records = state.records.len(); - let records_per_view = inner_height / 3; + let selected = state.selected.min(total_records.saturating_sub(1)); - // Auto-scroll to bottom when tailing - let scroll_offset = if state.is_tailing && !state.paused { - total_records.saturating_sub(records_per_view) + // Layout depends on whether list is hidden + let body_area = if state.hide_list { + // Full width for body when list hidden + inner_area } else { - state.scroll_offset.min(total_records.saturating_sub(1)) + // Split into left (record list) and right (body preview) panes + let panes = Layout::default() + .direction(Direction::Horizontal) + .constraints([ + Constraint::Length(28), // Record list - compact + Constraint::Min(20), // Body preview - takes remaining space + ]) + .split(inner_area); + + let list_area = panes[0]; + let visible_height = list_area.height as usize; + + // Keep selected record in view + let scroll_offset = if state.is_tailing && !state.paused { + // Auto-scroll to show latest + total_records.saturating_sub(visible_height) + } else if selected >= visible_height { + selected - visible_height + 1 + } else { + 0 + }; + + // === Left pane: Record list === + for (view_idx, record) in state.records.iter().enumerate().skip(scroll_offset).take(visible_height) { + let y = list_area.y + (view_idx - scroll_offset) as u16; + if y >= list_area.y + list_area.height { + break; + } + + let is_selected = view_idx == selected; + let has_headers = !record.headers.is_empty(); + let row_area = Rect::new(list_area.x, y, list_area.width, 1); + + // Selection highlight + if is_selected { + f.render_widget( + Block::default().style(Style::default().bg(Color::Rgb(39, 39, 42))), + row_area, + ); + } + + let sel_indicator = if is_selected { "▸" } else { " " }; + let header_indicator = if has_headers { "⌘" } else { " " }; + + let line = Line::from(vec![ + Span::styled(sel_indicator, Style::default().fg(GREEN)), + Span::styled( + format!("#{:<8}", record.seq_num), + Style::default().fg(if is_selected { GREEN } else { TEXT_SECONDARY }).bold(), + ), + Span::styled( + format!("{:>13}", record.timestamp), + Style::default().fg(TEXT_MUTED), + ), + Span::styled( + format!(" {}", header_indicator), + Style::default().fg(if has_headers { YELLOW } else { BORDER }), + ), + ]); + f.render_widget(Paragraph::new(line), row_area); + } + + // Vertical separator + let sep_x = panes[1].x.saturating_sub(1); + for y in 0..inner_area.height { + f.render_widget( + Paragraph::new(Span::styled("│", Style::default().fg(BORDER))), + Rect::new(sep_x, inner_area.y + y, 1, 1), + ); + } + + panes[1] }; - let lines: Vec = state - .records - .iter() - .skip(scroll_offset) - .take(records_per_view + 1) - .flat_map(|record| { - let body = String::from_utf8_lossy(&record.body); - let body_preview: String = body.chars().take(200).collect(); + // === Body preview of selected record === + if let Some(record) = state.records.get(selected) { + let body = String::from_utf8_lossy(&record.body); + let body_width = body_area.width.saturating_sub(2) as usize; + let body_height = body_area.height as usize; - vec![ - Line::from(vec![ - Span::styled( - format!("#{}", record.seq_num), - Style::default().fg(GREEN).bold(), - ), - Span::styled( - format!(" ts={}", record.timestamp), - Style::default().fg(TEXT_MUTED), - ), - ]), - Line::from(Span::styled(body_preview, Style::default().fg(TEXT_SECONDARY))), - Line::from(""), - ] - }) - .collect(); + // Cinema mode: when list is hidden and tailing, show raw body without chrome + let cinema_mode = state.hide_list && state.is_tailing && !state.paused; + + let (content_start_y, content_height) = if cinema_mode { + // Full height for body in cinema mode + (body_area.y, body_height) + } else { + // Header line with metadata + let header_line = Line::from(vec![ + Span::styled(format!(" #{}", record.seq_num), Style::default().fg(GREEN).bold()), + Span::styled(format!(" {}ms", record.timestamp), Style::default().fg(TEXT_MUTED)), + Span::styled(format!(" {} bytes", record.body.len()), Style::default().fg(TEXT_MUTED)), + if !record.headers.is_empty() { + Span::styled(format!(" ⌘{}", record.headers.len()), Style::default().fg(YELLOW)) + } else { + Span::styled("", Style::default()) + }, + ]); + f.render_widget(Paragraph::new(header_line), Rect::new(body_area.x, body_area.y, body_area.width, 1)); + + // Separator + let sep = "─".repeat(body_width); + f.render_widget( + Paragraph::new(Span::styled(format!(" {}", sep), Style::default().fg(BORDER))), + Rect::new(body_area.x, body_area.y + 1, body_area.width, 1), + ); + + (body_area.y + 2, body_height.saturating_sub(2)) + }; + + if body.is_empty() { + f.render_widget( + Paragraph::new(Span::styled(" (empty body)", Style::default().fg(TEXT_MUTED).italic())), + Rect::new(body_area.x, content_start_y, body_area.width, 1), + ); + } else { + // Display body text line by line (no wrapping for ASCII art) + let mut display_lines: Vec = Vec::new(); + + for line in body.lines().take(content_height) { + // For cinema mode, preserve spacing for ASCII art; otherwise wrap + if cinema_mode { + display_lines.push(Line::from(Span::styled(line.to_string(), Style::default().fg(TEXT_PRIMARY)))); + } else { + let chars: Vec = line.chars().collect(); + if chars.is_empty() { + display_lines.push(Line::from("")); + } else { + for chunk in chars.chunks(body_width.max(1)) { + let text: String = chunk.iter().collect(); + display_lines.push(Line::from(Span::styled(text, Style::default().fg(TEXT_PRIMARY)))); + if display_lines.len() >= content_height { + break; + } + } + } + } + + if display_lines.len() >= content_height { + break; + } + } + + let body_para = Paragraph::new(display_lines) + .block(Block::default().padding(Padding::horizontal(if cinema_mode { 0 } else { 1 }))); + f.render_widget(body_para, Rect::new(body_area.x, content_start_y, body_area.width, content_height as u16)); + } + } - let para = Paragraph::new(lines) - .block(block) - .wrap(Wrap { trim: true }); + // Draw headers popup if showing + if state.show_detail { + if let Some(record) = state.records.get(selected) { + draw_headers_popup(f, record); + } + } +} + +fn draw_headers_popup(f: &mut Frame, record: &s2_sdk::types::SequencedRecord) { + // Size popup based on number of headers (min height for "no headers" message) + let content_lines = if record.headers.is_empty() { 1 } else { record.headers.len() }; + let height = (content_lines + 5).min(20) as u16; + let area = centered_rect(50, height * 100 / f.area().height.max(1), f.area()); + + let mut lines = vec![ + Line::from(""), + Line::from(vec![ + Span::styled(format!(" Record #{}", record.seq_num), Style::default().fg(GREEN).bold()), + ]), + Line::from(""), + ]; + + if record.headers.is_empty() { + lines.push(Line::from(vec![ + Span::styled(" No headers", Style::default().fg(TEXT_MUTED).italic()), + ])); + } else { + for header in &record.headers { + let name = String::from_utf8_lossy(&header.name); + let value = String::from_utf8_lossy(&header.value); + lines.push(Line::from(vec![ + Span::styled(" ", Style::default()), + Span::styled(format!("{}", name), Style::default().fg(YELLOW)), + Span::styled(" = ", Style::default().fg(BORDER)), + Span::styled(format!("{}", value), Style::default().fg(TEXT_PRIMARY)), + ])); + } + } + + let (title, border_color) = if record.headers.is_empty() { + (" Headers ", BORDER) + } else { + (" Headers ", YELLOW) + }; + + let block = Block::default() + .title(Line::from(Span::styled(title, Style::default().fg(border_color).bold()))) + .borders(Borders::ALL) + .border_style(Style::default().fg(border_color)) + .style(Style::default().bg(BG_DARK)); + + f.render_widget(Clear, area); + let para = Paragraph::new(lines).block(block); f.render_widget(para, area); } +fn draw_append_view(f: &mut Frame, area: Rect, state: &AppendViewState) { + let uri = format!("s2://{}/{}", state.basin_name, state.stream_name); + + // Split into form (left) and history (right) + let main_chunks = Layout::default() + .direction(Direction::Horizontal) + .constraints([ + Constraint::Percentage(50), // Form + Constraint::Percentage(50), // History + ]) + .split(area); + + // === Form pane === + let form_block = Block::default() + .title(Line::from(vec![ + Span::styled(" APPEND ", Style::default().fg(GREEN).bold()), + Span::styled(" ", Style::default()), + Span::styled(&uri, Style::default().fg(TEXT_SECONDARY)), + ])) + .borders(Borders::ALL) + .border_style(Style::default().fg(GREEN)) + .padding(Padding::new(2, 2, 1, 1)); + + let form_inner = form_block.inner(main_chunks[0]); + f.render_widget(form_block, main_chunks[0]); + + // Helper functions + let cursor = |editing: bool| if editing { "▎" } else { "" }; + let selected_marker = |sel: bool| if sel { "▸ " } else { " " }; + + let mut lines: Vec = Vec::new(); + + // Row 0: Body + let body_selected = state.selected == 0; + let body_editing = body_selected && state.editing; + lines.push(Line::from(vec![ + Span::styled(selected_marker(body_selected), Style::default().fg(GREEN)), + Span::styled("Body", Style::default().fg(if body_selected { TEXT_PRIMARY } else { TEXT_MUTED })), + ])); + lines.push(Line::from(vec![ + Span::styled(" ", Style::default()), + Span::styled( + if state.body.is_empty() && !body_editing { + "(empty)".to_string() + } else { + format!("{}{}", &state.body, cursor(body_editing)) + }, + Style::default().fg(if body_editing { GREEN } else if state.body.is_empty() { TEXT_MUTED } else { TEXT_SECONDARY }) + ), + ])); + lines.push(Line::from("")); + + // Row 1: Headers + let headers_selected = state.selected == 1; + let headers_editing = headers_selected && state.editing; + lines.push(Line::from(vec![ + Span::styled(selected_marker(headers_selected), Style::default().fg(GREEN)), + Span::styled("Headers", Style::default().fg(if headers_selected { TEXT_PRIMARY } else { TEXT_MUTED })), + Span::styled(format!(" ({} added)", state.headers.len()), Style::default().fg(TEXT_MUTED)), + if headers_selected && !headers_editing { + Span::styled(" d=del", Style::default().fg(BORDER)) + } else { + Span::raw("") + }, + ])); + + // Show existing headers + for (key, value) in &state.headers { + lines.push(Line::from(vec![ + Span::styled(" ", Style::default()), + Span::styled(key, Style::default().fg(YELLOW)), + Span::styled(": ", Style::default().fg(TEXT_MUTED)), + Span::styled(value, Style::default().fg(TEXT_SECONDARY)), + ])); + } + + // Show header input if editing + if headers_editing { + lines.push(Line::from(vec![ + Span::styled(" + ", Style::default().fg(GREEN)), + Span::styled( + format!("{}{}", &state.header_key_input, if state.editing_header_key { "▎" } else { "" }), + Style::default().fg(if state.editing_header_key { GREEN } else { YELLOW }) + ), + Span::styled(": ", Style::default().fg(TEXT_MUTED)), + Span::styled( + format!("{}{}", &state.header_value_input, if !state.editing_header_key { "▎" } else { "" }), + Style::default().fg(if !state.editing_header_key { GREEN } else { TEXT_SECONDARY }) + ), + Span::styled(" ⇥=switch", Style::default().fg(BORDER)), + ])); + } else if headers_selected { + lines.push(Line::from(vec![ + Span::styled(" ", Style::default()), + Span::styled("Press Enter to add header", Style::default().fg(TEXT_MUTED).italic()), + ])); + } + lines.push(Line::from("")); + + // Row 2: Match seq num + let match_selected = state.selected == 2; + let match_editing = match_selected && state.editing; + lines.push(Line::from(vec![ + Span::styled(selected_marker(match_selected), Style::default().fg(GREEN)), + Span::styled("Match Seq#", Style::default().fg(if match_selected { TEXT_PRIMARY } else { TEXT_MUTED })), + Span::styled(" ", Style::default()), + Span::styled( + if state.match_seq_num.is_empty() && !match_editing { + "(none)".to_string() + } else { + format!("{}{}", &state.match_seq_num, cursor(match_editing)) + }, + Style::default().fg(if match_editing { GREEN } else if state.match_seq_num.is_empty() { TEXT_MUTED } else { TEXT_SECONDARY }) + ), + ])); + lines.push(Line::from("")); + + // Row 3: Fencing token + let fence_selected = state.selected == 3; + let fence_editing = fence_selected && state.editing; + lines.push(Line::from(vec![ + Span::styled(selected_marker(fence_selected), Style::default().fg(GREEN)), + Span::styled("Fencing Token", Style::default().fg(if fence_selected { TEXT_PRIMARY } else { TEXT_MUTED })), + Span::styled(" ", Style::default()), + Span::styled( + if state.fencing_token.is_empty() && !fence_editing { + "(none)".to_string() + } else { + format!("{}{}", &state.fencing_token, cursor(fence_editing)) + }, + Style::default().fg(if fence_editing { GREEN } else if state.fencing_token.is_empty() { TEXT_MUTED } else { TEXT_SECONDARY }) + ), + ])); + lines.push(Line::from("")); + + // Row 4: Send button + let send_selected = state.selected == 4; + let can_send = !state.body.is_empty() && !state.appending; + let (btn_fg, btn_bg) = if state.appending { + (BG_DARK, YELLOW) + } else if send_selected && can_send { + (BG_DARK, GREEN) + } else { + (if can_send { GREEN } else { TEXT_MUTED }, BG_PANEL) + }; + lines.push(Line::from(vec![ + Span::styled(selected_marker(send_selected), Style::default().fg(GREEN)), + Span::styled( + if state.appending { " ◌ SENDING... " } else { " ▶ SEND " }, + Style::default().fg(btn_fg).bg(btn_bg).bold() + ), + ])); + + let form_para = Paragraph::new(lines); + f.render_widget(form_para, form_inner); + + // === History pane === + let history_block = Block::default() + .title(Line::from(vec![ + Span::styled(" History ", Style::default().fg(TEXT_PRIMARY)), + Span::styled(format!(" {} appended", state.history.len()), Style::default().fg(TEXT_MUTED)), + ])) + .borders(Borders::ALL) + .border_style(Style::default().fg(BORDER)); + + if state.history.is_empty() { + let text = Paragraph::new(Span::styled( + "No records appended yet", + Style::default().fg(TEXT_MUTED).italic(), + )) + .alignment(Alignment::Center) + .block(history_block); + f.render_widget(text, main_chunks[1]); + } else { + let history_inner = history_block.inner(main_chunks[1]); + f.render_widget(history_block, main_chunks[1]); + + let visible_height = history_inner.height as usize; + let start = state.history.len().saturating_sub(visible_height); + + let mut history_lines: Vec = Vec::new(); + for result in state.history.iter().skip(start) { + let mut spans = vec![ + Span::styled(format!("#{:<8}", result.seq_num), Style::default().fg(GREEN)), + ]; + if result.header_count > 0 { + spans.push(Span::styled(format!(" ⌘{}", result.header_count), Style::default().fg(YELLOW))); + } + spans.push(Span::styled(format!(" {}", &result.body_preview), Style::default().fg(TEXT_SECONDARY))); + history_lines.push(Line::from(spans)); + } + + let history_para = Paragraph::new(history_lines); + f.render_widget(history_para, history_inner); + } +} + fn draw_status_bar(f: &mut Frame, area: Rect, app: &App) { let hints = match &app.screen { Screen::Basins(_) => "/ filter | jk nav | ret open | c new | e cfg | d del | r ref | ? | q", Screen::Streams(_) => "/ filter | jk nav | ret open | c new | e cfg | d del | r ref | esc", - Screen::StreamDetail(_) => "jk nav | ret run | t tail | r custom | e cfg | esc", + Screen::StreamDetail(_) => "jk | ret | t tail | r read | a append | f fence | m trim | e cfg | esc", Screen::ReadView(s) => { - if s.is_tailing { - "space pause | jk scroll | esc" + if s.show_detail { + "esc/⏎ close" + } else if s.is_tailing { + "jk nav | h headers | ⇥ list | space pause | gG top/bot | esc" } else { - "jk scroll | gG top/bot | esc" + "jk nav | h headers | ⇥ list | gG top/bot | esc" + } + } + Screen::AppendView(s) => { + if s.editing { + if s.selected == 1 { + "type | ⇥ key/val | ⏎ add | esc done" + } else { + "type | ⏎ done | esc cancel" + } + } else { + "jk nav | ⏎ edit/send | d del header | esc back" } } }; @@ -783,6 +1176,18 @@ fn draw_help_overlay(f: &mut Frame, screen: &Screen) { Span::styled(" r ", Style::default().fg(GREEN).bold()), Span::styled("Custom read", Style::default().fg(TEXT_SECONDARY)), ]), + Line::from(vec![ + Span::styled(" a ", Style::default().fg(GREEN).bold()), + Span::styled("Append records", Style::default().fg(TEXT_SECONDARY)), + ]), + Line::from(vec![ + Span::styled(" f ", Style::default().fg(GREEN).bold()), + Span::styled("Fence stream", Style::default().fg(TEXT_SECONDARY)), + ]), + Line::from(vec![ + Span::styled(" m ", Style::default().fg(GREEN).bold()), + Span::styled("Trim stream", Style::default().fg(TEXT_SECONDARY)), + ]), Line::from(vec![ Span::styled(" e ", Style::default().fg(GREEN).bold()), Span::styled("Reconfigure stream", Style::default().fg(TEXT_SECONDARY)), @@ -813,6 +1218,30 @@ fn draw_help_overlay(f: &mut Frame, screen: &Screen) { ]), Line::from(""), ], + Screen::AppendView(_) => vec![ + Line::from(""), + Line::from(vec![ + Span::styled(" j/k ", Style::default().fg(GREEN).bold()), + Span::styled("Navigate fields", Style::default().fg(TEXT_SECONDARY)), + ]), + Line::from(vec![ + Span::styled("enter ", Style::default().fg(GREEN).bold()), + Span::styled("Edit field / Send record", Style::default().fg(TEXT_SECONDARY)), + ]), + Line::from(vec![ + Span::styled(" d ", Style::default().fg(GREEN).bold()), + Span::styled("Delete last header", Style::default().fg(TEXT_SECONDARY)), + ]), + Line::from(vec![ + Span::styled(" tab ", Style::default().fg(GREEN).bold()), + Span::styled("Switch header key/value", Style::default().fg(TEXT_SECONDARY)), + ]), + Line::from(vec![ + Span::styled(" esc ", Style::default().fg(GREEN).bold()), + Span::styled("Stop editing / Back", Style::default().fg(TEXT_SECONDARY)), + ]), + Line::from(""), + ], }; let block = Block::default() @@ -1104,156 +1533,325 @@ fn draw_input_dialog(f: &mut Frame, mode: &InputMode) { until_timestamp, clamp, format, + output_file, selected, editing, } => { - // Radio button display - let radio = |opt: ReadStartFrom, current: &ReadStartFrom| { - if opt == *current { "(o)" } else { "( )" } - }; - let checkbox = |checked: bool| if checked { "[x]" } else { "[ ]" }; + // Stylish indicators + let radio = |active: bool| if active { "●" } else { "○" }; + let check = |on: bool| if on { "✓" } else { " " }; - // Row selection indicator - let sel = |idx: usize, s: &usize| if idx == *s { ">" } else { " " }; - - // Input field styling - shows cursor when editing - let input_field = |value: &str, is_editing: bool, placeholder: &str| -> String { + // Value display - clean with ∞ for unlimited + let show_val = |value: &str, is_editing: bool, placeholder: &str| -> String { if is_editing { - format!("[{}|]", value) + if value.is_empty() { + "▎".to_string() + } else { + format!("{}▎", value) + } } else if value.is_empty() { - format!("[{}]", placeholder) + placeholder.to_string() } else { - format!("[{}]", value) + value.to_string() } }; let unit_str = match ago_unit { - AgoUnit::Seconds => "s", - AgoUnit::Minutes => "m", - AgoUnit::Hours => "h", - AgoUnit::Days => "d", + AgoUnit::Seconds => "sec", + AgoUnit::Minutes => "min", + AgoUnit::Hours => "hr", + AgoUnit::Days => "day", }; let mut lines = vec![ Line::from(vec![ - Span::styled(format!("{}/{}", basin, stream), Style::default().fg(GREEN).bold()), + Span::styled(" ", Style::default()), + Span::styled(format!("s2://{}/{}", basin, stream), Style::default().fg(GREEN)), ]), - Line::from(Span::styled("Start from:", Style::default().fg(TEXT_MUTED))), + Line::from(""), + Line::from(Span::styled(" START POSITION", Style::default().fg(TEXT_MUTED))), ]; - // Row 0: From sequence number + // Row 0: Sequence number let is_seq = *start_from == ReadStartFrom::SeqNum; - let seq_field = input_field(seq_num_value, *editing && *selected == 0, "0"); lines.push(Line::from(vec![ - Span::styled(sel(0, selected), Style::default().fg(GREEN)), - Span::styled(format!("{} ", radio(ReadStartFrom::SeqNum, start_from)), - Style::default().fg(if is_seq { GREEN } else { TEXT_MUTED })), - Span::styled("seq ", Style::default().fg(if *selected == 0 { TEXT_PRIMARY } else { TEXT_SECONDARY })), - Span::styled(seq_field, Style::default().fg(if *selected == 0 && *editing { GREEN } else if is_seq { TEXT_PRIMARY } else { TEXT_MUTED })), + Span::styled(if *selected == 0 { " ▸ " } else { " " }, Style::default().fg(GREEN)), + Span::styled(format!("{} ", radio(is_seq)), Style::default().fg(if is_seq { GREEN } else { BORDER })), + Span::styled("Sequence # ", Style::default().fg(if *selected == 0 { TEXT_PRIMARY } else { TEXT_MUTED })), + Span::styled( + show_val(seq_num_value, *editing && *selected == 0, "0"), + Style::default().fg(if *editing && *selected == 0 { GREEN } else if is_seq { TEXT_PRIMARY } else { TEXT_MUTED }) + ), ])); - // Row 1: From timestamp + // Row 1: Timestamp let is_ts = *start_from == ReadStartFrom::Timestamp; - let ts_field = input_field(timestamp_value, *editing && *selected == 1, "0"); lines.push(Line::from(vec![ - Span::styled(sel(1, selected), Style::default().fg(GREEN)), - Span::styled(format!("{} ", radio(ReadStartFrom::Timestamp, start_from)), - Style::default().fg(if is_ts { GREEN } else { TEXT_MUTED })), - Span::styled("ts ", Style::default().fg(if *selected == 1 { TEXT_PRIMARY } else { TEXT_SECONDARY })), - Span::styled(ts_field, Style::default().fg(if *selected == 1 && *editing { GREEN } else if is_ts { TEXT_PRIMARY } else { TEXT_MUTED })), + Span::styled(if *selected == 1 { " ▸ " } else { " " }, Style::default().fg(GREEN)), + Span::styled(format!("{} ", radio(is_ts)), Style::default().fg(if is_ts { GREEN } else { BORDER })), + Span::styled("Timestamp ", Style::default().fg(if *selected == 1 { TEXT_PRIMARY } else { TEXT_MUTED })), + Span::styled( + show_val(timestamp_value, *editing && *selected == 1, "0"), + Style::default().fg(if *editing && *selected == 1 { GREEN } else if is_ts { TEXT_PRIMARY } else { TEXT_MUTED }) + ), Span::styled(" ms", Style::default().fg(TEXT_MUTED)), ])); - // Row 2: From time ago + // Row 2: Time ago let is_ago = *start_from == ReadStartFrom::Ago; - let ago_field = input_field(ago_value, *editing && *selected == 2, "5"); lines.push(Line::from(vec![ - Span::styled(sel(2, selected), Style::default().fg(GREEN)), - Span::styled(format!("{} ", radio(ReadStartFrom::Ago, start_from)), - Style::default().fg(if is_ago { GREEN } else { TEXT_MUTED })), - Span::styled("ago ", Style::default().fg(if *selected == 2 { TEXT_PRIMARY } else { TEXT_SECONDARY })), - Span::styled(ago_field, Style::default().fg(if *selected == 2 && *editing { GREEN } else if is_ago { TEXT_PRIMARY } else { TEXT_MUTED })), - Span::styled(format!(" <{}> tab", unit_str), Style::default().fg(if is_ago { GREEN_DIM } else { BORDER })), + Span::styled(if *selected == 2 { " ▸ " } else { " " }, Style::default().fg(GREEN)), + Span::styled(format!("{} ", radio(is_ago)), Style::default().fg(if is_ago { GREEN } else { BORDER })), + Span::styled("Time ago ", Style::default().fg(if *selected == 2 { TEXT_PRIMARY } else { TEXT_MUTED })), + Span::styled( + show_val(ago_value, *editing && *selected == 2, "5"), + Style::default().fg(if *editing && *selected == 2 { GREEN } else if is_ago { TEXT_PRIMARY } else { TEXT_MUTED }) + ), + Span::styled(format!(" {} ", unit_str), Style::default().fg(if is_ago { TEXT_SECONDARY } else { TEXT_MUTED })), + Span::styled("‹tab›", Style::default().fg(BORDER)), ])); - // Row 3: From tail offset + // Row 3: Tail offset let is_off = *start_from == ReadStartFrom::TailOffset; - let off_field = input_field(tail_offset_value, *editing && *selected == 3, "10"); lines.push(Line::from(vec![ - Span::styled(sel(3, selected), Style::default().fg(GREEN)), - Span::styled(format!("{} ", radio(ReadStartFrom::TailOffset, start_from)), - Style::default().fg(if is_off { GREEN } else { TEXT_MUTED })), - Span::styled("off ", Style::default().fg(if *selected == 3 { TEXT_PRIMARY } else { TEXT_SECONDARY })), - Span::styled(off_field, Style::default().fg(if *selected == 3 && *editing { GREEN } else if is_off { TEXT_PRIMARY } else { TEXT_MUTED })), + Span::styled(if *selected == 3 { " ▸ " } else { " " }, Style::default().fg(GREEN)), + Span::styled(format!("{} ", radio(is_off)), Style::default().fg(if is_off { GREEN } else { BORDER })), + Span::styled("Tail offset ", Style::default().fg(if *selected == 3 { TEXT_PRIMARY } else { TEXT_MUTED })), + Span::styled( + show_val(tail_offset_value, *editing && *selected == 3, "10"), + Style::default().fg(if *editing && *selected == 3 { GREEN } else if is_off { TEXT_PRIMARY } else { TEXT_MUTED }) + ), Span::styled(" back", Style::default().fg(TEXT_MUTED)), ])); - lines.push(Line::from(Span::styled("Limits:", Style::default().fg(TEXT_MUTED)))); + lines.push(Line::from("")); + lines.push(Line::from(Span::styled(" LIMITS", Style::default().fg(TEXT_MUTED)))); - // Row 4: Max records - let cnt_field = input_field(count_limit, *editing && *selected == 4, "-"); + // Row 4: Count lines.push(Line::from(vec![ - Span::styled(sel(4, selected), Style::default().fg(GREEN)), - Span::styled(" count ", Style::default().fg(if *selected == 4 { TEXT_PRIMARY } else { TEXT_SECONDARY })), - Span::styled(cnt_field, Style::default().fg(if *selected == 4 && *editing { GREEN } else { TEXT_MUTED })), + Span::styled(if *selected == 4 { " ▸ " } else { " " }, Style::default().fg(GREEN)), + Span::styled("Max records ", Style::default().fg(if *selected == 4 { TEXT_PRIMARY } else { TEXT_MUTED })), + Span::styled( + show_val(count_limit, *editing && *selected == 4, "∞"), + Style::default().fg(if *editing && *selected == 4 { GREEN } else { TEXT_SECONDARY }) + ), ])); - // Row 5: Max bytes - let byte_field = input_field(byte_limit, *editing && *selected == 5, "-"); + // Row 5: Bytes lines.push(Line::from(vec![ - Span::styled(sel(5, selected), Style::default().fg(GREEN)), - Span::styled(" bytes ", Style::default().fg(if *selected == 5 { TEXT_PRIMARY } else { TEXT_SECONDARY })), - Span::styled(byte_field, Style::default().fg(if *selected == 5 && *editing { GREEN } else { TEXT_MUTED })), + Span::styled(if *selected == 5 { " ▸ " } else { " " }, Style::default().fg(GREEN)), + Span::styled("Max bytes ", Style::default().fg(if *selected == 5 { TEXT_PRIMARY } else { TEXT_MUTED })), + Span::styled( + show_val(byte_limit, *editing && *selected == 5, "∞"), + Style::default().fg(if *editing && *selected == 5 { GREEN } else { TEXT_SECONDARY }) + ), ])); - // Row 6: Until timestamp - let until_field = input_field(until_timestamp, *editing && *selected == 6, "-"); + // Row 6: Until lines.push(Line::from(vec![ - Span::styled(sel(6, selected), Style::default().fg(GREEN)), - Span::styled(" until ", Style::default().fg(if *selected == 6 { TEXT_PRIMARY } else { TEXT_SECONDARY })), - Span::styled(until_field, Style::default().fg(if *selected == 6 && *editing { GREEN } else { TEXT_MUTED })), + Span::styled(if *selected == 6 { " ▸ " } else { " " }, Style::default().fg(GREEN)), + Span::styled("Until ", Style::default().fg(if *selected == 6 { TEXT_PRIMARY } else { TEXT_MUTED })), + Span::styled( + show_val(until_timestamp, *editing && *selected == 6, "∞"), + Style::default().fg(if *editing && *selected == 6 { GREEN } else { TEXT_SECONDARY }) + ), Span::styled(" ms", Style::default().fg(TEXT_MUTED)), ])); - lines.push(Line::from(Span::styled("Options:", Style::default().fg(TEXT_MUTED)))); + lines.push(Line::from("")); + lines.push(Line::from(Span::styled(" OPTIONS", Style::default().fg(TEXT_MUTED)))); // Row 7: Clamp lines.push(Line::from(vec![ - Span::styled(sel(7, selected), Style::default().fg(GREEN)), - Span::styled(format!(" {} clamp to tail", checkbox(*clamp)), - Style::default().fg(if *selected == 7 { TEXT_PRIMARY } else { TEXT_SECONDARY })), + Span::styled(if *selected == 7 { " ▸ " } else { " " }, Style::default().fg(GREEN)), + Span::styled(format!("[{}] ", check(*clamp)), Style::default().fg(if *clamp { GREEN } else { BORDER })), + Span::styled("Clamp to tail", Style::default().fg(if *selected == 7 { TEXT_PRIMARY } else { TEXT_MUTED })), ])); // Row 8: Format lines.push(Line::from(vec![ - Span::styled(sel(8, selected), Style::default().fg(GREEN)), - Span::styled(format!(" format <{}>", format.as_str()), - Style::default().fg(if *selected == 8 { TEXT_PRIMARY } else { TEXT_SECONDARY })), + Span::styled(if *selected == 8 { " ▸ " } else { " " }, Style::default().fg(GREEN)), + Span::styled("Format ", Style::default().fg(if *selected == 8 { TEXT_PRIMARY } else { TEXT_MUTED })), + Span::styled(format!("‹ {} ›", format.as_str()), Style::default().fg(if *selected == 8 { GREEN } else { TEXT_SECONDARY })), + ])); + + // Row 9: Output file + lines.push(Line::from(vec![ + Span::styled(if *selected == 9 { " ▸ " } else { " " }, Style::default().fg(GREEN)), + Span::styled("Output ", Style::default().fg(if *selected == 9 { TEXT_PRIMARY } else { TEXT_MUTED })), + Span::styled( + show_val(output_file, *editing && *selected == 9, "(display only)"), + Style::default().fg(if *editing && *selected == 9 { GREEN } else if output_file.is_empty() { TEXT_MUTED } else { TEXT_SECONDARY }) + ), ])); lines.push(Line::from("")); - // Row 9: Start button - let btn_style = if *selected == 9 { - Style::default().fg(BG_DARK).bg(GREEN).bold() + // Row 10: Start button + let (btn_fg, btn_bg) = if *selected == 10 { + (BG_DARK, GREEN) } else { - Style::default().fg(GREEN).bold() + (GREEN, BG_PANEL) }; lines.push(Line::from(vec![ - Span::styled(sel(9, selected), Style::default().fg(GREEN)), - Span::styled(" ", Style::default()), - Span::styled(" Start Reading ", btn_style), + Span::styled(if *selected == 10 { " ▸ " } else { " " }, Style::default().fg(GREEN)), + Span::styled(" ▶ START ", Style::default().fg(btn_fg).bg(btn_bg).bold()), ])); ( " Custom Read ", lines, - "jk nav | spc/ret select | tab unit | esc", + "↑↓ nav ⏎ edit ␣ toggle ⇥ unit esc", + ) + } + + InputMode::Fence { + basin, + stream, + new_token, + current_token, + selected, + editing, + } => { + let cursor = |is_editing: bool| if is_editing { "▎" } else { "" }; + let marker = |sel: bool| if sel { "▸ " } else { " " }; + + let mut lines = vec![ + Line::from(vec![ + Span::styled(" ", Style::default()), + Span::styled(format!("s2://{}/{}", basin, stream), Style::default().fg(GREEN)), + ]), + Line::from(""), + Line::from(Span::styled(" Set a new fencing token to block other writers.", Style::default().fg(TEXT_MUTED))), + Line::from(""), + ]; + + // Row 0: New token + let new_editing = *editing && *selected == 0; + lines.push(Line::from(vec![ + Span::styled(marker(*selected == 0), Style::default().fg(GREEN)), + Span::styled("New Token ", Style::default().fg(if *selected == 0 { TEXT_PRIMARY } else { TEXT_MUTED })), + Span::styled( + if new_token.is_empty() && !new_editing { + "(required)".to_string() + } else { + format!("{}{}", new_token, cursor(new_editing)) + }, + Style::default().fg(if new_editing { GREEN } else if new_token.is_empty() { WARNING } else { TEXT_SECONDARY }) + ), + ])); + + // Row 1: Current token + let cur_editing = *editing && *selected == 1; + lines.push(Line::from(vec![ + Span::styled(marker(*selected == 1), Style::default().fg(GREEN)), + Span::styled("Current Token ", Style::default().fg(if *selected == 1 { TEXT_PRIMARY } else { TEXT_MUTED })), + Span::styled( + if current_token.is_empty() && !cur_editing { + "(none)".to_string() + } else { + format!("{}{}", current_token, cursor(cur_editing)) + }, + Style::default().fg(if cur_editing { GREEN } else if current_token.is_empty() { TEXT_MUTED } else { TEXT_SECONDARY }) + ), + ])); + + lines.push(Line::from("")); + + // Row 2: Submit button + let can_submit = !new_token.is_empty(); + let (btn_fg, btn_bg) = if *selected == 2 && can_submit { + (BG_DARK, GREEN) + } else { + (if can_submit { GREEN } else { TEXT_MUTED }, BG_PANEL) + }; + lines.push(Line::from(vec![ + Span::styled(marker(*selected == 2), Style::default().fg(GREEN)), + Span::styled(" ▶ FENCE ", Style::default().fg(btn_fg).bg(btn_bg).bold()), + ])); + + ( + " Fence Stream ", + lines, + "↑↓ nav ⏎ edit/submit esc", + ) + } + + InputMode::Trim { + basin, + stream, + trim_point, + fencing_token, + selected, + editing, + } => { + let cursor = |is_editing: bool| if is_editing { "▎" } else { "" }; + let marker = |sel: bool| if sel { "▸ " } else { " " }; + + let mut lines = vec![ + Line::from(vec![ + Span::styled(" ", Style::default()), + Span::styled(format!("s2://{}/{}", basin, stream), Style::default().fg(GREEN)), + ]), + Line::from(""), + Line::from(Span::styled(" Delete all records before the trim point.", Style::default().fg(TEXT_MUTED))), + Line::from(Span::styled(" This is eventually consistent.", Style::default().fg(TEXT_MUTED))), + Line::from(""), + ]; + + // Row 0: Trim point + let trim_editing = *editing && *selected == 0; + lines.push(Line::from(vec![ + Span::styled(marker(*selected == 0), Style::default().fg(GREEN)), + Span::styled("Trim Point ", Style::default().fg(if *selected == 0 { TEXT_PRIMARY } else { TEXT_MUTED })), + Span::styled( + if trim_point.is_empty() && !trim_editing { + "(seq num)".to_string() + } else { + format!("{}{}", trim_point, cursor(trim_editing)) + }, + Style::default().fg(if trim_editing { GREEN } else if trim_point.is_empty() { WARNING } else { TEXT_SECONDARY }) + ), + ])); + + // Row 1: Fencing token + let fence_editing = *editing && *selected == 1; + lines.push(Line::from(vec![ + Span::styled(marker(*selected == 1), Style::default().fg(GREEN)), + Span::styled("Fencing Token ", Style::default().fg(if *selected == 1 { TEXT_PRIMARY } else { TEXT_MUTED })), + Span::styled( + if fencing_token.is_empty() && !fence_editing { + "(none)".to_string() + } else { + format!("{}{}", fencing_token, cursor(fence_editing)) + }, + Style::default().fg(if fence_editing { GREEN } else if fencing_token.is_empty() { TEXT_MUTED } else { TEXT_SECONDARY }) + ), + ])); + + lines.push(Line::from("")); + + // Row 2: Submit button + let can_submit = !trim_point.is_empty() && trim_point.parse::().is_ok(); + let (btn_fg, btn_bg) = if *selected == 2 && can_submit { + (BG_DARK, WARNING) + } else { + (if can_submit { WARNING } else { TEXT_MUTED }, BG_PANEL) + }; + lines.push(Line::from(vec![ + Span::styled(marker(*selected == 2), Style::default().fg(GREEN)), + Span::styled(" ▶ TRIM ", Style::default().fg(btn_fg).bg(btn_bg).bold()), + ])); + + ( + " Trim Stream ", + lines, + "↑↓ nav ⏎ edit/submit esc", ) } }; - let area = centered_rect(50, 70, f.area()); + let area = centered_rect(55, 85, f.area()); let block = Block::default() .title(Line::from(Span::styled(title, Style::default().fg(TEXT_PRIMARY).bold()))) From 20a8ddb8b05d2e2c86c42bc94bbe4ccce0b7033b Mon Sep 17 00:00:00 2001 From: Mehul Arora Date: Fri, 23 Jan 2026 13:14:23 -0500 Subject: [PATCH 05/31] . --- src/tui/app.rs | 903 ++++++++++++++++++++++++++++++++++++++++++++++- src/tui/event.rs | 11 +- src/tui/ui.rs | 580 +++++++++++++++++++++++++++++- src/types.rs | 2 +- 4 files changed, 1468 insertions(+), 28 deletions(-) diff --git a/src/tui/app.rs b/src/tui/app.rs index 38c1941..03e53c2 100644 --- a/src/tui/app.rs +++ b/src/tui/app.rs @@ -5,14 +5,14 @@ use base64ct::Encoding; use crossterm::event::{self, Event as CrosstermEvent, KeyCode, KeyEvent, KeyModifiers}; use futures::StreamExt; use ratatui::{Terminal, prelude::Backend}; -use s2_sdk::types::{BasinInfo, BasinName, StreamInfo, StreamName, StreamPosition}; +use s2_sdk::types::{AccessTokenId, AccessTokenInfo, BasinInfo, BasinName, StreamInfo, StreamName, StreamPosition}; use tokio::sync::mpsc; -use crate::cli::{CreateBasinArgs, CreateStreamArgs, ListBasinsArgs, ListStreamsArgs, ReadArgs, ReconfigureBasinArgs, ReconfigureStreamArgs}; +use crate::cli::{CreateBasinArgs, CreateStreamArgs, IssueAccessTokenArgs, ListAccessTokensArgs, ListBasinsArgs, ListStreamsArgs, ReadArgs, ReconfigureBasinArgs, ReconfigureStreamArgs}; use crate::error::CliError; use crate::ops; use crate::record_format::{RecordFormat, RecordsOut}; -use crate::types::{BasinConfig, S2BasinAndMaybeStreamUri, S2BasinAndStreamUri, S2BasinUri, StorageClass, StreamConfig, TimestampingMode}; +use crate::types::{BasinConfig, Operation, S2BasinAndMaybeStreamUri, S2BasinAndStreamUri, S2BasinUri, StorageClass, StreamConfig, TimestampingMode}; use super::event::{BasinConfigInfo, Event, StreamConfigInfo}; use super::ui; @@ -20,14 +20,24 @@ use super::ui; /// Maximum records to keep in read view buffer const MAX_RECORDS_BUFFER: usize = 1000; +/// Top-level navigation tabs +#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)] +pub enum Tab { + #[default] + Basins, + AccessTokens, +} + /// Current screen being displayed #[derive(Debug, Clone)] pub enum Screen { + Splash, Basins(BasinsState), Streams(StreamsState), StreamDetail(StreamDetailState), ReadView(ReadViewState), AppendView(AppendViewState), + AccessTokens(AccessTokensState), } /// State for the basins list screen @@ -106,6 +116,16 @@ pub struct AppendResult { pub header_count: usize, } +/// State for the access tokens list screen +#[derive(Debug, Clone, Default)] +pub struct AccessTokensState { + pub tokens: Vec, + pub selected: usize, + pub loading: bool, + pub filter: String, + pub filter_active: bool, +} + /// Status message level #[derive(Debug, Clone, Copy, PartialEq, Eq)] pub enum MessageLevel { @@ -206,6 +226,36 @@ pub enum InputMode { selected: usize, // 0=trim_point, 1=fencing_token, 2=submit editing: bool, }, + /// Issue a new access token + IssueAccessToken { + // Basic info + id: String, + expiry: ExpiryOption, + expiry_custom: String, // For custom duration input + // Resource scopes + basins_scope: ScopeOption, + basins_value: String, + streams_scope: ScopeOption, + streams_value: String, + tokens_scope: ScopeOption, + tokens_value: String, + // Operation permissions (Read/Write for each level) + account_read: bool, + account_write: bool, + basin_read: bool, + basin_write: bool, + stream_read: bool, + stream_write: bool, + // Options + auto_prefix_streams: bool, + // UI state + selected: usize, + editing: bool, + }, + /// Confirming access token revocation + ConfirmRevokeToken { token_id: String }, + /// Show issued token (one-time display) + ShowIssuedToken { token: String }, } /// Retention policy option for UI @@ -221,6 +271,108 @@ impl Default for RetentionPolicyOption { } } +/// Expiry options for access tokens +#[derive(Debug, Clone, Copy, PartialEq, Default)] +pub enum ExpiryOption { + #[default] + Never, + OneDay, + SevenDays, + ThirtyDays, + NinetyDays, + OneYear, + Custom, +} + +impl ExpiryOption { + pub fn next(&self) -> Self { + match self { + ExpiryOption::Never => ExpiryOption::OneDay, + ExpiryOption::OneDay => ExpiryOption::SevenDays, + ExpiryOption::SevenDays => ExpiryOption::ThirtyDays, + ExpiryOption::ThirtyDays => ExpiryOption::NinetyDays, + ExpiryOption::NinetyDays => ExpiryOption::OneYear, + ExpiryOption::OneYear => ExpiryOption::Custom, + ExpiryOption::Custom => ExpiryOption::Never, + } + } + + pub fn prev(&self) -> Self { + match self { + ExpiryOption::Never => ExpiryOption::Custom, + ExpiryOption::OneDay => ExpiryOption::Never, + ExpiryOption::SevenDays => ExpiryOption::OneDay, + ExpiryOption::ThirtyDays => ExpiryOption::SevenDays, + ExpiryOption::NinetyDays => ExpiryOption::ThirtyDays, + ExpiryOption::OneYear => ExpiryOption::NinetyDays, + ExpiryOption::Custom => ExpiryOption::OneYear, + } + } + + pub fn as_str(&self) -> &'static str { + match self { + ExpiryOption::Never => "Never (permanent)", + ExpiryOption::OneDay => "1 day", + ExpiryOption::SevenDays => "7 days", + ExpiryOption::ThirtyDays => "30 days", + ExpiryOption::NinetyDays => "90 days", + ExpiryOption::OneYear => "1 year", + ExpiryOption::Custom => "Custom", + } + } + + pub fn to_duration_str(&self) -> Option<&'static str> { + match self { + ExpiryOption::Never => None, + ExpiryOption::OneDay => Some("1d"), + ExpiryOption::SevenDays => Some("7d"), + ExpiryOption::ThirtyDays => Some("30d"), + ExpiryOption::NinetyDays => Some("90d"), + ExpiryOption::OneYear => Some("365d"), + ExpiryOption::Custom => None, // Use custom value + } + } +} + +/// Scope options for resource access +#[derive(Debug, Clone, Copy, PartialEq, Default)] +pub enum ScopeOption { + #[default] + All, + Prefix, + Exact, + None, +} + +impl ScopeOption { + pub fn next(&self) -> Self { + match self { + ScopeOption::All => ScopeOption::Prefix, + ScopeOption::Prefix => ScopeOption::Exact, + ScopeOption::Exact => ScopeOption::None, + ScopeOption::None => ScopeOption::All, + } + } + + pub fn prev(&self) -> Self { + match self { + ScopeOption::All => ScopeOption::None, + ScopeOption::Prefix => ScopeOption::All, + ScopeOption::Exact => ScopeOption::Prefix, + ScopeOption::None => ScopeOption::Exact, + } + } + + pub fn as_str(&self) -> &'static str { + match self { + ScopeOption::All => "All", + ScopeOption::Prefix => "Prefix", + ScopeOption::Exact => "Exact", + ScopeOption::None => "None", + } + } +} + /// Start position for read operation #[derive(Debug, Clone, Copy, PartialEq)] pub enum ReadStartFrom { @@ -336,6 +488,7 @@ impl Default for InputMode { /// Main application state pub struct App { pub screen: Screen, + pub tab: Tab, pub s2: s2_sdk::S2, pub message: Option, pub show_help: bool, @@ -346,10 +499,8 @@ pub struct App { impl App { pub fn new(s2: s2_sdk::S2) -> Self { Self { - screen: Screen::Basins(BasinsState { - loading: true, - ..Default::default() - }), + screen: Screen::Splash, + tab: Tab::Basins, s2, message: None, show_help: false, @@ -361,19 +512,58 @@ impl App { pub async fn run(mut self, terminal: &mut Terminal) -> Result<(), CliError> { let (tx, mut rx) = mpsc::unbounded_channel(); - // Initial data load + // Show splash screen briefly + let splash_start = std::time::Instant::now(); + let splash_duration = Duration::from_millis(1200); + + // Start loading basins in background self.load_basins(tx.clone()); + // Track loaded basins for transition from splash + let mut pending_basins: Option, CliError>> = None; + loop { // Render terminal .draw(|f| ui::draw(f, &self)) .map_err(|e| CliError::RecordWrite(format!("Failed to draw: {e}")))?; + // Check if splash screen should end + if matches!(self.screen, Screen::Splash) && splash_start.elapsed() >= splash_duration { + // Transition to basins + let mut basins_state = BasinsState { + loading: pending_basins.is_none(), + ..Default::default() + }; + if let Some(result) = pending_basins.take() { + match result { + Ok(basins) => { + basins_state.basins = basins; + basins_state.loading = false; + } + Err(e) => { + basins_state.loading = false; + self.message = Some(StatusMessage { + text: format!("Failed to load basins: {e}"), + level: MessageLevel::Error, + }); + } + } + } + self.screen = Screen::Basins(basins_state); + } + // Handle events tokio::select! { // Handle async events from background tasks Some(event) = rx.recv() => { + // If on splash screen, cache the basins result + if matches!(self.screen, Screen::Splash) { + if let Event::BasinsLoaded(result) = event { + pending_basins = Some(result); + continue; + } + } self.handle_event(event); } @@ -385,6 +575,30 @@ impl App { if let CrosstermEvent::Key(key) = event::read() .map_err(|e| CliError::RecordWrite(format!("Failed to read event: {e}")))? { + // Skip to basins on any key during splash + if matches!(self.screen, Screen::Splash) { + let mut basins_state = BasinsState { + loading: pending_basins.is_none(), + ..Default::default() + }; + if let Some(result) = pending_basins.take() { + match result { + Ok(basins) => { + basins_state.basins = basins; + basins_state.loading = false; + } + Err(e) => { + basins_state.loading = false; + self.message = Some(StatusMessage { + text: format!("Failed to load basins: {e}"), + level: MessageLevel::Error, + }); + } + } + } + self.screen = Screen::Basins(basins_state); + continue; + } self.handle_key(key, tx.clone()); } } @@ -768,6 +982,73 @@ impl App { } } + Event::AccessTokensLoaded(result) => { + if let Screen::AccessTokens(state) = &mut self.screen { + state.loading = false; + match result { + Ok(tokens) => { + state.tokens = tokens; + self.message = Some(StatusMessage { + text: format!("Loaded {} access tokens", state.tokens.len()), + level: MessageLevel::Success, + }); + } + Err(e) => { + self.message = Some(StatusMessage { + text: format!("Failed to load access tokens: {e}"), + level: MessageLevel::Error, + }); + } + } + } + } + + Event::AccessTokenIssued(result) => { + self.input_mode = InputMode::Normal; + match result { + Ok(token) => { + // Show the token in a special dialog (one-time display) + self.input_mode = InputMode::ShowIssuedToken { token: token.clone() }; + self.message = Some(StatusMessage { + text: "Access token issued - copy it now, it won't be shown again!".to_string(), + level: MessageLevel::Success, + }); + // Refresh tokens list + if let Screen::AccessTokens(state) = &mut self.screen { + state.loading = true; + } + } + Err(e) => { + self.message = Some(StatusMessage { + text: format!("Failed to issue access token: {e}"), + level: MessageLevel::Error, + }); + } + } + } + + Event::AccessTokenRevoked(result) => { + self.input_mode = InputMode::Normal; + match result { + Ok(id) => { + self.message = Some(StatusMessage { + text: format!("Revoked access token '{}'", id), + level: MessageLevel::Success, + }); + // Refresh tokens list + if let Screen::AccessTokens(state) = &mut self.screen { + state.loading = true; + } + } + Err(e) => { + self.message = Some(StatusMessage { + text: format!("Failed to revoke access token: {e}"), + level: MessageLevel::Error, + }); + } + } + } + Event::Error(e) => { self.message = Some(StatusMessage { text: e.to_string(), @@ -815,17 +1096,99 @@ impl App { return; } + // Tab key to switch between tabs (only on top-level screens) + if key.code == KeyCode::Tab { + match &self.screen { + Screen::Basins(_) | Screen::AccessTokens(_) => { + self.switch_tab(tx.clone()); + return; + } + _ => {} + } + } + // Screen-specific keys - handle in place to avoid borrow issues match &self.screen { + Screen::Splash => {} // Keys handled in run loop Screen::Basins(_) => self.handle_basins_key(key, tx), Screen::Streams(_) => self.handle_streams_key(key, tx), Screen::StreamDetail(_) => self.handle_stream_detail_key(key, tx), Screen::ReadView(_) => self.handle_read_view_key(key, tx), Screen::AppendView(_) => self.handle_append_view_key(key, tx), + Screen::AccessTokens(_) => self.handle_access_tokens_key(key, tx), } } fn handle_input_key(&mut self, key: KeyEvent, tx: mpsc::UnboundedSender) { + // Handle IssueAccessToken submit separately to avoid borrow issues. + // We need to extract values before calling the method since the match arm + // holds borrows that conflict with the method call. + if matches!(key.code, KeyCode::Char(' ') | KeyCode::Enter) { + if let InputMode::IssueAccessToken { + id, + expiry, + expiry_custom, + basins_scope, + basins_value, + streams_scope, + streams_value, + tokens_scope, + tokens_value, + account_read, + account_write, + basin_read, + basin_write, + stream_read, + stream_write, + auto_prefix_streams, + selected, + editing, + } = &self.input_mode + { + if *selected == 16 && !*editing && !id.is_empty() { + // Clone all values we need + let id = id.clone(); + let expiry = *expiry; + let expiry_custom = expiry_custom.clone(); + let basins_scope = *basins_scope; + let basins_value = basins_value.clone(); + let streams_scope = *streams_scope; + let streams_value = streams_value.clone(); + let tokens_scope = *tokens_scope; + let tokens_value = tokens_value.clone(); + let account_read = *account_read; + let account_write = *account_write; + let basin_read = *basin_read; + let basin_write = *basin_write; + let stream_read = *stream_read; + let stream_write = *stream_write; + let auto_prefix_streams = *auto_prefix_streams; + + // Now we can safely call the method + self.issue_access_token_v2( + id, + expiry, + expiry_custom, + basins_scope, + basins_value, + streams_scope, + streams_value, + tokens_scope, + tokens_value, + account_read, + account_write, + basin_read, + basin_write, + stream_read, + stream_write, + auto_prefix_streams, + tx, + ); + return; + } + } + } + match &mut self.input_mode { InputMode::Normal => {} @@ -1411,6 +1774,170 @@ impl App { _ => {} } } + + InputMode::IssueAccessToken { + id, + expiry, + expiry_custom, + basins_scope, + basins_value, + streams_scope, + streams_value, + tokens_scope, + tokens_value, + account_read, + account_write, + basin_read, + basin_write, + stream_read, + stream_write, + auto_prefix_streams, + selected, + editing, + } => { + // Fields: 0=id, 1=expiry, 2=expiry_custom, 3=basins_scope, 4=basins_value, + // 5=streams_scope, 6=streams_value, 7=tokens_scope, 8=tokens_value, + // 9=account_read, 10=account_write, 11=basin_read, 12=basin_write, + // 13=stream_read, 14=stream_write, 15=auto_prefix, 16=submit + const MAX_FIELD: usize = 16; + + if *editing { + match key.code { + KeyCode::Esc | KeyCode::Enter => { + *editing = false; + } + KeyCode::Backspace => { + match *selected { + 0 => { id.pop(); } + 2 => { expiry_custom.pop(); } + 4 => { basins_value.pop(); } + 6 => { streams_value.pop(); } + 8 => { tokens_value.pop(); } + _ => {} + } + } + KeyCode::Char(c) => { + match *selected { + 0 => { + // Token ID: letters, numbers, hyphens, underscores + if c.is_ascii_alphanumeric() || c == '-' || c == '_' { + id.push(c); + } + } + 2 => { + // Custom expiry: e.g., "30d", "1w", "24h" + if c.is_ascii_alphanumeric() { + expiry_custom.push(c); + } + } + 4 => basins_value.push(c), + 6 => streams_value.push(c), + 8 => tokens_value.push(c), + _ => {} + } + } + _ => {} + } + return; + } + + match key.code { + KeyCode::Esc => { + self.input_mode = InputMode::Normal; + } + KeyCode::Up | KeyCode::Char('k') => { + if *selected > 0 { + *selected -= 1; + // Skip value fields if scope doesn't need them + if *selected == 2 && *expiry != ExpiryOption::Custom { + *selected = 1; + } + if *selected == 4 && !matches!(basins_scope, ScopeOption::Prefix | ScopeOption::Exact) { + *selected = 3; + } + if *selected == 6 && !matches!(streams_scope, ScopeOption::Prefix | ScopeOption::Exact) { + *selected = 5; + } + if *selected == 8 && !matches!(tokens_scope, ScopeOption::Prefix | ScopeOption::Exact) { + *selected = 7; + } + } + } + KeyCode::Down | KeyCode::Char('j') => { + if *selected < MAX_FIELD { + *selected += 1; + // Skip value fields if scope doesn't need them + if *selected == 2 && *expiry != ExpiryOption::Custom { + *selected = 3; + } + if *selected == 4 && !matches!(basins_scope, ScopeOption::Prefix | ScopeOption::Exact) { + *selected = 5; + } + if *selected == 6 && !matches!(streams_scope, ScopeOption::Prefix | ScopeOption::Exact) { + *selected = 7; + } + if *selected == 8 && !matches!(tokens_scope, ScopeOption::Prefix | ScopeOption::Exact) { + *selected = 9; + } + } + } + KeyCode::Left | KeyCode::Right => { + let forward = key.code == KeyCode::Right; + match *selected { + 1 => *expiry = if forward { expiry.next() } else { expiry.prev() }, + 3 => *basins_scope = if forward { basins_scope.next() } else { basins_scope.prev() }, + 5 => *streams_scope = if forward { streams_scope.next() } else { streams_scope.prev() }, + 7 => *tokens_scope = if forward { tokens_scope.next() } else { tokens_scope.prev() }, + _ => {} + } + } + KeyCode::Char(' ') | KeyCode::Enter => { + match *selected { + // Text inputs + 0 | 2 | 4 | 6 | 8 => *editing = true, + // Cycle options + 1 => *expiry = expiry.next(), + 3 => *basins_scope = basins_scope.next(), + 5 => *streams_scope = streams_scope.next(), + 7 => *tokens_scope = tokens_scope.next(), + // Toggle checkboxes + 9 => *account_read = !*account_read, + 10 => *account_write = !*account_write, + 11 => *basin_read = !*basin_read, + 12 => *basin_write = !*basin_write, + 13 => *stream_read = !*stream_read, + 14 => *stream_write = !*stream_write, + 15 => *auto_prefix_streams = !*auto_prefix_streams, + // Submit case (16) is handled before the match to avoid borrow issues + _ => {} + } + } + _ => {} + } + } + + InputMode::ConfirmRevokeToken { token_id } => { + match key.code { + KeyCode::Esc | KeyCode::Char('n') | KeyCode::Char('N') => { + self.input_mode = InputMode::Normal; + } + KeyCode::Char('y') | KeyCode::Char('Y') | KeyCode::Enter => { + let id = token_id.clone(); + self.revoke_access_token(id, tx.clone()); + } + _ => {} + } + } + + InputMode::ShowIssuedToken { .. } => { + // Any key dismisses the token display + match key.code { + KeyCode::Esc | KeyCode::Enter | KeyCode::Char(_) => { + self.input_mode = InputMode::Normal; + } + _ => {} + } + } } } @@ -2989,4 +3516,364 @@ impl App { } }); } + + /// Switch between tabs + fn switch_tab(&mut self, tx: mpsc::UnboundedSender) { + match self.tab { + Tab::Basins => { + self.tab = Tab::AccessTokens; + self.screen = Screen::AccessTokens(AccessTokensState { + loading: true, + ..Default::default() + }); + self.load_access_tokens(tx); + } + Tab::AccessTokens => { + self.tab = Tab::Basins; + self.screen = Screen::Basins(BasinsState { + loading: true, + ..Default::default() + }); + self.load_basins(tx); + } + } + } + + /// Handle keys on access tokens screen + fn handle_access_tokens_key(&mut self, key: KeyEvent, tx: mpsc::UnboundedSender) { + let Screen::AccessTokens(state) = &mut self.screen else { + return; + }; + + // Handle filter mode + if state.filter_active { + match key.code { + KeyCode::Esc => { + state.filter_active = false; + state.filter.clear(); + state.selected = 0; + } + KeyCode::Enter => { + state.filter_active = false; + } + KeyCode::Backspace => { + state.filter.pop(); + state.selected = 0; + } + KeyCode::Char(c) => { + state.filter.push(c); + state.selected = 0; + } + _ => {} + } + return; + } + + // Get filtered tokens for navigation + let filtered_tokens: Vec<_> = state + .tokens + .iter() + .filter(|t| { + state.filter.is_empty() + || t.id.to_string().to_lowercase().contains(&state.filter.to_lowercase()) + }) + .collect(); + + match key.code { + KeyCode::Char('q') => { + self.should_quit = true; + } + KeyCode::Char('j') | KeyCode::Down => { + if !filtered_tokens.is_empty() && state.selected < filtered_tokens.len() - 1 { + state.selected += 1; + } + } + KeyCode::Char('k') | KeyCode::Up => { + if state.selected > 0 { + state.selected -= 1; + } + } + KeyCode::Char('g') | KeyCode::Home => { + state.selected = 0; + } + KeyCode::Char('G') | KeyCode::End => { + if !filtered_tokens.is_empty() { + state.selected = filtered_tokens.len() - 1; + } + } + KeyCode::Char('/') => { + state.filter_active = true; + } + KeyCode::Char('c') => { + // Create/Issue new token + self.input_mode = InputMode::IssueAccessToken { + id: String::new(), + expiry: ExpiryOption::ThirtyDays, + expiry_custom: String::new(), + basins_scope: ScopeOption::All, + basins_value: String::new(), + streams_scope: ScopeOption::All, + streams_value: String::new(), + tokens_scope: ScopeOption::All, + tokens_value: String::new(), + account_read: true, + account_write: false, + basin_read: true, + basin_write: false, + stream_read: true, + stream_write: false, + auto_prefix_streams: false, + selected: 0, + editing: false, + }; + } + KeyCode::Char('d') => { + // Delete/Revoke selected token + if let Some(token) = filtered_tokens.get(state.selected) { + self.input_mode = InputMode::ConfirmRevokeToken { + token_id: token.id.to_string(), + }; + } + } + KeyCode::Char('r') => { + // Refresh + state.loading = true; + self.load_access_tokens(tx); + } + _ => {} + } + } + + /// Load access tokens + fn load_access_tokens(&self, tx: mpsc::UnboundedSender) { + let s2 = self.s2.clone(); + tokio::spawn(async move { + let args = ListAccessTokensArgs { + prefix: None, + start_after: None, + limit: Some(100), + no_auto_paginate: false, + }; + match ops::list_access_tokens(&s2, args).await { + Ok(stream) => { + let tokens: Vec<_> = stream + .take(100) + .filter_map(|r| async { r.ok() }) + .collect() + .await; + let _ = tx.send(Event::AccessTokensLoaded(Ok(tokens))); + } + Err(e) => { + let _ = tx.send(Event::AccessTokensLoaded(Err(e))); + } + } + }); + } + + /// Issue a new access token (v2 with full options) + #[allow(clippy::too_many_arguments)] + fn issue_access_token_v2( + &self, + id: String, + expiry: ExpiryOption, + expiry_custom: String, + basins_scope: ScopeOption, + basins_value: String, + streams_scope: ScopeOption, + streams_value: String, + tokens_scope: ScopeOption, + tokens_value: String, + account_read: bool, + account_write: bool, + basin_read: bool, + basin_write: bool, + stream_read: bool, + stream_write: bool, + auto_prefix_streams: bool, + tx: mpsc::UnboundedSender, + ) { + let s2 = self.s2.clone(); + let tx_refresh = tx.clone(); + + tokio::spawn(async move { + // Parse token ID + let token_id: AccessTokenId = match id.parse() { + Ok(id) => id, + Err(e) => { + let _ = tx.send(Event::AccessTokenIssued(Err(CliError::InvalidArgs( + miette::miette!("Invalid token ID: {}", e), + )))); + return; + } + }; + + // Build operations list based on read/write checkboxes + let mut operations: Vec = Vec::new(); + + // Account level operations + if account_read { + operations.push(Operation::ListBasins); + operations.push(Operation::GetAccountMetrics); + } + // (No account-write ops at account level) + + // Basin level operations + if basin_read { + operations.push(Operation::GetBasinConfig); + operations.push(Operation::GetBasinMetrics); + operations.push(Operation::ListStreams); + } + if basin_write { + operations.push(Operation::CreateBasin); + operations.push(Operation::DeleteBasin); + operations.push(Operation::ReconfigureBasin); + } + + // Stream level operations + if stream_read { + operations.push(Operation::GetStreamConfig); + operations.push(Operation::GetStreamMetrics); + operations.push(Operation::Read); + operations.push(Operation::CheckTail); + } + if stream_write { + operations.push(Operation::CreateStream); + operations.push(Operation::DeleteStream); + operations.push(Operation::ReconfigureStream); + operations.push(Operation::Append); + operations.push(Operation::Fence); + operations.push(Operation::Trim); + } + + // Token operations (based on tokens scope) + if !matches!(tokens_scope, ScopeOption::None) { + if account_read { + operations.push(Operation::ListAccessTokens); + } + if account_write { + operations.push(Operation::IssueAccessToken); + operations.push(Operation::RevokeAccessToken); + } + } + + // Build expiration + let expires_in_str = match expiry { + ExpiryOption::Never => None, + ExpiryOption::Custom => { + if expiry_custom.is_empty() { None } else { Some(expiry_custom.clone()) } + } + _ => expiry.to_duration_str().map(|s| s.to_string()), + }; + + // Build scope matchers + let basins_matcher = match basins_scope { + ScopeOption::All => None, + ScopeOption::None => Some("".to_string()), // Empty string = no basins + ScopeOption::Prefix => Some(basins_value.clone()), + ScopeOption::Exact => Some(format!("={}", basins_value)), + }; + + let streams_matcher = match streams_scope { + ScopeOption::All => None, + ScopeOption::None => Some("".to_string()), + ScopeOption::Prefix => Some(streams_value.clone()), + ScopeOption::Exact => Some(format!("={}", streams_value)), + }; + + let tokens_matcher = match tokens_scope { + ScopeOption::All => None, + ScopeOption::None => Some("".to_string()), + ScopeOption::Prefix => Some(tokens_value.clone()), + ScopeOption::Exact => Some(format!("={}", tokens_value)), + }; + + // Build args + let args = IssueAccessTokenArgs { + id: token_id, + expires_in: expires_in_str.and_then(|s| s.parse().ok()), + expires_at: None, + auto_prefix_streams, + basins: basins_matcher.and_then(|s| if s.is_empty() && matches!(basins_scope, ScopeOption::None) { + // For "None" scope, we don't pass anything (API default is all) + // Actually, to restrict to none, we need special handling + None + } else if s.is_empty() { + None + } else { + s.parse().ok() + }), + streams: streams_matcher.and_then(|s| if s.is_empty() { None } else { s.parse().ok() }), + access_tokens: tokens_matcher.and_then(|s| if s.is_empty() { None } else { s.parse().ok() }), + op_group_perms: None, + ops: operations, + }; + + match ops::issue_access_token(&s2, args).await { + Ok(token) => { + let _ = tx.send(Event::AccessTokenIssued(Ok(token))); + // Trigger refresh + let list_args = ListAccessTokensArgs { + prefix: None, + start_after: None, + limit: Some(100), + no_auto_paginate: false, + }; + if let Ok(stream) = ops::list_access_tokens(&s2, list_args).await { + let tokens: Vec<_> = stream + .take(100) + .filter_map(|r| async { r.ok() }) + .collect() + .await; + let _ = tx_refresh.send(Event::AccessTokensLoaded(Ok(tokens))); + } + } + Err(e) => { + let _ = tx.send(Event::AccessTokenIssued(Err(e))); + } + } + }); + } + + /// Revoke an access token + fn revoke_access_token(&self, id: String, tx: mpsc::UnboundedSender) { + let s2 = self.s2.clone(); + let tx_refresh = tx.clone(); + + tokio::spawn(async move { + // Parse token ID + let token_id: AccessTokenId = match id.parse() { + Ok(id) => id, + Err(e) => { + let _ = tx.send(Event::AccessTokenRevoked(Err(CliError::InvalidArgs( + miette::miette!("Invalid token ID: {}", e), + )))); + return; + } + }; + + match ops::revoke_access_token(&s2, token_id.clone()).await { + Ok(()) => { + let _ = tx.send(Event::AccessTokenRevoked(Ok(id))); + // Trigger refresh + let list_args = ListAccessTokensArgs { + prefix: None, + start_after: None, + limit: Some(100), + no_auto_paginate: false, + }; + if let Ok(stream) = ops::list_access_tokens(&s2, list_args).await { + let tokens: Vec<_> = stream + .take(100) + .filter_map(|r| async { r.ok() }) + .collect() + .await; + let _ = tx_refresh.send(Event::AccessTokensLoaded(Ok(tokens))); + } + } + Err(e) => { + let _ = tx.send(Event::AccessTokenRevoked(Err(e))); + } + } + }); + } } diff --git a/src/tui/event.rs b/src/tui/event.rs index e4fc00a..ec0cf5f 100644 --- a/src/tui/event.rs +++ b/src/tui/event.rs @@ -1,4 +1,4 @@ -use s2_sdk::types::{BasinInfo, SequencedRecord, StreamInfo, StreamPosition}; +use s2_sdk::types::{AccessTokenInfo, BasinInfo, SequencedRecord, StreamInfo, StreamPosition}; use crate::error::CliError; use crate::types::{StorageClass, StreamConfig, TimestampingMode}; @@ -78,6 +78,15 @@ pub enum Event { /// Stream trimmed successfully (trim_point, new_tail_seq_num) StreamTrimmed(Result<(u64, u64), CliError>), + /// Access tokens have been loaded from the API + AccessTokensLoaded(Result, CliError>), + + /// Access token issued successfully (token string) + AccessTokenIssued(Result), + + /// Access token revoked successfully (token id) + AccessTokenRevoked(Result), + /// An error occurred in a background task Error(CliError), } diff --git a/src/tui/ui.rs b/src/tui/ui.rs index d4ef469..6bc60f0 100644 --- a/src/tui/ui.rs +++ b/src/tui/ui.rs @@ -8,7 +8,7 @@ use ratatui::{ use crate::types::{StorageClass, TimestampingMode}; -use super::app::{App, AgoUnit, AppendViewState, BasinsState, InputMode, MessageLevel, ReadStartFrom, ReadViewState, RetentionPolicyOption, Screen, StreamDetailState, StreamsState}; +use super::app::{AccessTokensState, App, AgoUnit, AppendViewState, BasinsState, ExpiryOption, InputMode, MessageLevel, ReadStartFrom, ReadViewState, RetentionPolicyOption, ScopeOption, Screen, StreamDetailState, StreamsState, Tab}; // S2 Console dark theme const GREEN: Color = Color::Rgb(34, 197, 94); // Active green @@ -36,26 +36,55 @@ pub fn draw(f: &mut Frame, app: &App) { let area = f.area(); f.render_widget(Block::default().style(Style::default().bg(BG_DARK)), area); - let chunks = Layout::default() - .direction(Direction::Vertical) - .margin(1) - .constraints([ - Constraint::Min(3), // Main content - Constraint::Length(1), // Status bar (slimmer) - ]) - .split(area); + // Splash screen uses full area + if matches!(app.screen, Screen::Splash) { + draw_splash(f, area); + return; + } + + // Check if we should show tabs (only on top-level screens) + let show_tabs = matches!(app.screen, Screen::Basins(_) | Screen::AccessTokens(_)); + + let chunks = if show_tabs { + Layout::default() + .direction(Direction::Vertical) + .margin(1) + .constraints([ + Constraint::Length(1), // Tab bar + Constraint::Min(3), // Main content + Constraint::Length(1), // Status bar + ]) + .split(area) + } else { + Layout::default() + .direction(Direction::Vertical) + .margin(1) + .constraints([ + Constraint::Length(0), // No tab bar + Constraint::Min(3), // Main content + Constraint::Length(1), // Status bar + ]) + .split(area) + }; + + // Draw tab bar if on top-level screen + if show_tabs { + draw_tab_bar(f, chunks[0], app.tab); + } // Draw main content based on screen match &app.screen { - Screen::Basins(state) => draw_basins(f, chunks[0], state), - Screen::Streams(state) => draw_streams(f, chunks[0], state), - Screen::StreamDetail(state) => draw_stream_detail(f, chunks[0], state), - Screen::ReadView(state) => draw_read_view(f, chunks[0], state), - Screen::AppendView(state) => draw_append_view(f, chunks[0], state), + Screen::Splash => unreachable!(), + Screen::Basins(state) => draw_basins(f, chunks[1], state), + Screen::Streams(state) => draw_streams(f, chunks[1], state), + Screen::StreamDetail(state) => draw_stream_detail(f, chunks[1], state), + Screen::ReadView(state) => draw_read_view(f, chunks[1], state), + Screen::AppendView(state) => draw_append_view(f, chunks[1], state), + Screen::AccessTokens(state) => draw_access_tokens(f, chunks[1], state), } // Draw status bar - draw_status_bar(f, chunks[1], app); + draw_status_bar(f, chunks[2], app); // Draw help overlay if visible if app.show_help { @@ -68,6 +97,239 @@ pub fn draw(f: &mut Frame, app: &App) { } } +fn draw_splash(f: &mut Frame, area: Rect) { + // S2 logo + let logo = vec![ + " █████████████████████████ ", + " ██████████████████████████████ ", + " ███████████████████████████████ ", + "█████████████████████████████████", + "█████████████████████████████████ ", + "███████████████ ", + "███████████████ ", + "██████████████ ████████████████", + "██████████████ ████████████████", + "██████████████ ████████████████", + "███████████████ ███████", + "██████████████████ █████", + "█████████████████████████ ████", + "█████████████████████████ █████", + "██████ ██████", + "█████ ████████", + " ███ ██████████████████████ ", + " ██ ██████████████████████ ", + " ████████████████████ ", + ]; + + // Create lines with logo (centered) + let mut lines: Vec = logo + .iter() + .map(|&line| Line::from(Span::styled(line, Style::default().fg(Color::White)))) + .collect(); + + // Add tagline below logo + lines.push(Line::from("")); + lines.push(Line::from(Span::styled( + "Streams as a cloud", + Style::default().fg(Color::White).bold(), + ))); + lines.push(Line::from(Span::styled( + "storage primitive", + Style::default().fg(Color::White).bold(), + ))); + lines.push(Line::from("")); + lines.push(Line::from(Span::styled( + "The serverless API for unlimited, durable, real-time streams.", + Style::default().fg(TEXT_MUTED), + ))); + + let content_height = lines.len() as u16; + + // Center vertically + let y = area.y + area.height.saturating_sub(content_height) / 2; + + let centered_area = Rect::new(area.x, y, area.width, content_height); + let logo_widget = Paragraph::new(lines).alignment(Alignment::Center); + f.render_widget(logo_widget, centered_area); +} + +fn draw_tab_bar(f: &mut Frame, area: Rect, current_tab: Tab) { + let basins_style = if current_tab == Tab::Basins { + Style::default().fg(GREEN).bold() + } else { + Style::default().fg(TEXT_MUTED) + }; + + let tokens_style = if current_tab == Tab::AccessTokens { + Style::default().fg(GREEN).bold() + } else { + Style::default().fg(TEXT_MUTED) + }; + + let line = Line::from(vec![ + Span::styled("Basins", basins_style), + Span::styled(" │ ", Style::default().fg(BORDER)), + Span::styled("Access Tokens", tokens_style), + Span::styled(" (Tab to switch)", Style::default().fg(TEXT_MUTED)), + ]); + + let paragraph = Paragraph::new(line); + f.render_widget(paragraph, area); +} + +fn draw_access_tokens(f: &mut Frame, area: Rect, state: &AccessTokensState) { + // Layout: Search bar, Header, Table rows + let chunks = Layout::default() + .direction(Direction::Vertical) + .constraints([ + Constraint::Length(3), // Search bar + Constraint::Length(2), // Header + Constraint::Min(1), // Table rows + ]) + .split(area); + + // === Search Bar === + let search_block = Block::default() + .borders(Borders::ALL) + .border_style(Style::default().fg(if state.filter_active { GREEN } else { BORDER })) + .style(Style::default().bg(BG_PANEL)); + + let search_text = if state.filter_active { + Line::from(vec![ + Span::styled("/ ", Style::default().fg(GREEN)), + Span::styled(&state.filter, Style::default().fg(TEXT_PRIMARY)), + Span::styled("█", Style::default().fg(GREEN)), // Cursor + ]) + } else if !state.filter.is_empty() { + Line::from(vec![ + Span::styled("Filter: ", Style::default().fg(TEXT_MUTED)), + Span::styled(&state.filter, Style::default().fg(TEXT_PRIMARY)), + ]) + } else { + Line::from(Span::styled( + "Press / to search access tokens...", + Style::default().fg(TEXT_MUTED), + )) + }; + + let search_para = Paragraph::new(search_text) + .block(search_block) + .style(Style::default().bg(BG_PANEL)); + f.render_widget(search_para, chunks[0]); + + // === Header === + // Column widths: prefix(2) + token_id(30) + expires_at(28) + scope(rest) + let header = Line::from(vec![ + Span::styled(" ", Style::default()), // Space for selection prefix + Span::styled( + format!("{:<30}", "TOKEN ID"), + Style::default().fg(TEXT_MUTED).bold(), + ), + Span::styled( + format!("{:<28}", "EXPIRES AT"), + Style::default().fg(TEXT_MUTED).bold(), + ), + Span::styled("SCOPE", Style::default().fg(TEXT_MUTED).bold()), + ]); + let header_para = Paragraph::new(header); + f.render_widget(header_para, chunks[1]); + + // === Token List === + // Filter tokens + let filtered_tokens: Vec<_> = state + .tokens + .iter() + .filter(|t| { + state.filter.is_empty() + || t.id.to_string().to_lowercase().contains(&state.filter.to_lowercase()) + }) + .collect(); + + if state.loading { + let loading = Paragraph::new(Line::from(Span::styled( + "Loading access tokens...", + Style::default().fg(TEXT_MUTED), + ))); + f.render_widget(loading, chunks[2]); + } else if filtered_tokens.is_empty() { + let empty_msg = if state.tokens.is_empty() { + "No access tokens. Press 'c' to issue a new token." + } else { + "No tokens match filter." + }; + let empty = Paragraph::new(Line::from(Span::styled( + empty_msg, + Style::default().fg(TEXT_MUTED), + ))); + f.render_widget(empty, chunks[2]); + } else { + let list_height = chunks[2].height as usize; + let start = state.selected.saturating_sub(list_height / 2); + let visible_tokens = filtered_tokens.iter().skip(start).take(list_height); + + let lines: Vec = visible_tokens + .enumerate() + .map(|(i, token)| { + let actual_index = start + i; + let is_selected = actual_index == state.selected; + + // Format scope summary + let scope_summary = format_scope_summary(token); + + let style = if is_selected { + Style::default().fg(GREEN).bold() + } else { + Style::default().fg(TEXT_PRIMARY) + }; + + let prefix = if is_selected { "▶ " } else { " " }; + + // Truncate token ID if too long (max 28 chars to leave room for padding) + let token_id_str = token.id.to_string(); + let token_id_display = if token_id_str.len() > 28 { + format!("{}…", &token_id_str[..27]) + } else { + token_id_str + }; + + // Format expires_at more compactly + let expires_str = token.expires_at.to_string(); + let expires_display = if expires_str.len() > 26 { + format!("{}…", &expires_str[..25]) + } else { + expires_str + }; + + Line::from(vec![ + Span::styled(prefix, style), + Span::styled(format!("{:<30}", token_id_display), style), + Span::styled(format!("{:<28}", expires_display), Style::default().fg(TEXT_MUTED)), + Span::styled(scope_summary, Style::default().fg(TEXT_MUTED)), + ]) + }) + .collect(); + + let list = Paragraph::new(lines); + f.render_widget(list, chunks[2]); + } +} + +/// Format a summary of the token scope +fn format_scope_summary(token: &s2_sdk::types::AccessTokenInfo) -> String { + let ops_count = token.scope.ops.len(); + let has_basins = token.scope.basins.is_some(); + let has_streams = token.scope.streams.is_some(); + + let mut parts = vec![format!("{} ops", ops_count)]; + if has_basins { + parts.push("basins".to_string()); + } + if has_streams { + parts.push("streams".to_string()); + } + parts.join(", ") +} + fn draw_basins(f: &mut Frame, area: Rect, state: &BasinsState) { // Layout: Search bar, Header, Table rows let chunks = Layout::default() @@ -528,7 +790,7 @@ fn draw_stream_detail(f: &mut Frame, area: Rect, state: &StreamDetailState) { let actions = vec![ ("t", "Tail", "Live follow from current position - see new records as they arrive"), - ("r", "Custom Read", "Configure start position, limits, and time range"), + ("r", "Read", "Configure start position, limits, and time range"), ("a", "Append", "Write records to this stream"), ("f", "Fence", "Set a fencing token to block other writers"), ("m", "Trim", "Delete records before a sequence number"), @@ -1027,6 +1289,7 @@ fn draw_append_view(f: &mut Frame, area: Rect, state: &AppendViewState) { fn draw_status_bar(f: &mut Frame, area: Rect, app: &App) { let hints = match &app.screen { + Screen::Splash => "", // Never shown Screen::Basins(_) => "/ filter | jk nav | ret open | c new | e cfg | d del | r ref | ? | q", Screen::Streams(_) => "/ filter | jk nav | ret open | c new | e cfg | d del | r ref | esc", Screen::StreamDetail(_) => "jk | ret | t tail | r read | a append | f fence | m trim | e cfg | esc", @@ -1050,6 +1313,7 @@ fn draw_status_bar(f: &mut Frame, area: Rect, app: &App) { "jk nav | ⏎ edit/send | d del header | esc back" } } + Screen::AccessTokens(_) => "/ filter | jk nav | c issue | d revoke | r ref | ⇥ switch | ? | q", }; let message_span = app @@ -1082,6 +1346,7 @@ fn draw_help_overlay(f: &mut Frame, screen: &Screen) { let area = centered_rect(50, 50, f.area()); let help_text = match screen { + Screen::Splash => vec![], // Never shown Screen::Basins(_) => vec![ Line::from(""), Line::from(vec![ @@ -1116,6 +1381,10 @@ fn draw_help_overlay(f: &mut Frame, screen: &Screen) { Span::styled(" r ", Style::default().fg(GREEN).bold()), Span::styled("Refresh", Style::default().fg(TEXT_SECONDARY)), ]), + Line::from(vec![ + Span::styled(" tab ", Style::default().fg(GREEN).bold()), + Span::styled("Switch to Access Tokens", Style::default().fg(TEXT_SECONDARY)), + ]), Line::from(vec![ Span::styled(" q ", Style::default().fg(GREEN).bold()), Span::styled("Quit", Style::default().fg(TEXT_SECONDARY)), @@ -1174,7 +1443,7 @@ fn draw_help_overlay(f: &mut Frame, screen: &Screen) { ]), Line::from(vec![ Span::styled(" r ", Style::default().fg(GREEN).bold()), - Span::styled("Custom read", Style::default().fg(TEXT_SECONDARY)), + Span::styled("Read records", Style::default().fg(TEXT_SECONDARY)), ]), Line::from(vec![ Span::styled(" a ", Style::default().fg(GREEN).bold()), @@ -1242,6 +1511,42 @@ fn draw_help_overlay(f: &mut Frame, screen: &Screen) { ]), Line::from(""), ], + Screen::AccessTokens(_) => vec![ + Line::from(""), + Line::from(vec![ + Span::styled(" j/k ", Style::default().fg(GREEN).bold()), + Span::styled("Navigate", Style::default().fg(TEXT_SECONDARY)), + ]), + Line::from(vec![ + Span::styled(" g/G ", Style::default().fg(GREEN).bold()), + Span::styled("Top / Bottom", Style::default().fg(TEXT_SECONDARY)), + ]), + Line::from(vec![ + Span::styled(" / ", Style::default().fg(GREEN).bold()), + Span::styled("Filter", Style::default().fg(TEXT_SECONDARY)), + ]), + Line::from(vec![ + Span::styled(" c ", Style::default().fg(GREEN).bold()), + Span::styled("Issue new token", Style::default().fg(TEXT_SECONDARY)), + ]), + Line::from(vec![ + Span::styled(" d ", Style::default().fg(GREEN).bold()), + Span::styled("Revoke token", Style::default().fg(TEXT_SECONDARY)), + ]), + Line::from(vec![ + Span::styled(" r ", Style::default().fg(GREEN).bold()), + Span::styled("Refresh", Style::default().fg(TEXT_SECONDARY)), + ]), + Line::from(vec![ + Span::styled(" tab ", Style::default().fg(GREEN).bold()), + Span::styled("Switch to Basins", Style::default().fg(TEXT_SECONDARY)), + ]), + Line::from(vec![ + Span::styled(" q ", Style::default().fg(GREEN).bold()), + Span::styled("Quit", Style::default().fg(TEXT_SECONDARY)), + ]), + Line::from(""), + ], }; let block = Block::default() @@ -1699,7 +2004,7 @@ fn draw_input_dialog(f: &mut Frame, mode: &InputMode) { ])); ( - " Custom Read ", + " Read ", lines, "↑↓ nav ⏎ edit ␣ toggle ⇥ unit esc", ) @@ -1849,6 +2154,245 @@ fn draw_input_dialog(f: &mut Frame, mode: &InputMode) { "↑↓ nav ⏎ edit/submit esc", ) } + + InputMode::IssueAccessToken { + id, + expiry, + expiry_custom, + basins_scope, + basins_value, + streams_scope, + streams_value, + tokens_scope, + tokens_value, + account_read, + account_write, + basin_read, + basin_write, + stream_read, + stream_write, + auto_prefix_streams, + selected, + editing, + } => { + let cursor = |is_editing: bool| if is_editing { "▎" } else { "" }; + let marker = |sel: bool| if sel { "▸ " } else { " " }; + let checkbox = |checked: bool| if checked { "[x]" } else { "[ ]" }; + + let mut lines = vec![]; + + // Row 0: Token ID + let id_editing = *editing && *selected == 0; + lines.push(Line::from(vec![ + Span::styled(marker(*selected == 0), Style::default().fg(GREEN)), + Span::styled("Token ID ", Style::default().fg(if *selected == 0 { TEXT_PRIMARY } else { TEXT_MUTED })), + Span::styled( + if id.is_empty() && !id_editing { + "(required)".to_string() + } else { + format!("{}{}", id, cursor(id_editing)) + }, + Style::default().fg(if id_editing { GREEN } else if id.is_empty() { WARNING } else { TEXT_SECONDARY }) + ), + ])); + + // Row 1: Expiration (cycle) + lines.push(Line::from(vec![ + Span::styled(marker(*selected == 1), Style::default().fg(GREEN)), + Span::styled("Expiration ", Style::default().fg(if *selected == 1 { TEXT_PRIMARY } else { TEXT_MUTED })), + Span::styled(format!("< {} >", expiry.as_str()), Style::default().fg(TEXT_SECONDARY)), + ])); + + // Row 2: Custom expiration (only if Custom selected) + if *expiry == ExpiryOption::Custom { + let custom_editing = *editing && *selected == 2; + lines.push(Line::from(vec![ + Span::styled(marker(*selected == 2), Style::default().fg(GREEN)), + Span::styled(" Custom ", Style::default().fg(if *selected == 2 { TEXT_PRIMARY } else { TEXT_MUTED })), + Span::styled( + if expiry_custom.is_empty() && !custom_editing { + "(e.g., 30d, 1w)".to_string() + } else { + format!("{}{}", expiry_custom, cursor(custom_editing)) + }, + Style::default().fg(if custom_editing { GREEN } else { TEXT_SECONDARY }) + ), + ])); + } + + // Resources section header + lines.push(Line::from("")); + lines.push(Line::from(Span::styled("── Resources ──", Style::default().fg(TEXT_MUTED)))); + + // Row 3: Basins scope + lines.push(Line::from(vec![ + Span::styled(marker(*selected == 3), Style::default().fg(GREEN)), + Span::styled("Basins ", Style::default().fg(if *selected == 3 { TEXT_PRIMARY } else { TEXT_MUTED })), + Span::styled(format!("< {} >", basins_scope.as_str()), Style::default().fg(TEXT_SECONDARY)), + ])); + + // Row 4: Basins value (only if Prefix/Exact) + if matches!(basins_scope, ScopeOption::Prefix | ScopeOption::Exact) { + let basins_editing = *editing && *selected == 4; + lines.push(Line::from(vec![ + Span::styled(marker(*selected == 4), Style::default().fg(GREEN)), + Span::styled(" Pattern ", Style::default().fg(if *selected == 4 { TEXT_PRIMARY } else { TEXT_MUTED })), + Span::styled( + if basins_value.is_empty() && !basins_editing { + "(enter pattern)".to_string() + } else { + format!("{}{}", basins_value, cursor(basins_editing)) + }, + Style::default().fg(if basins_editing { GREEN } else { TEXT_SECONDARY }) + ), + ])); + } + + // Row 5: Streams scope + lines.push(Line::from(vec![ + Span::styled(marker(*selected == 5), Style::default().fg(GREEN)), + Span::styled("Streams ", Style::default().fg(if *selected == 5 { TEXT_PRIMARY } else { TEXT_MUTED })), + Span::styled(format!("< {} >", streams_scope.as_str()), Style::default().fg(TEXT_SECONDARY)), + ])); + + // Row 6: Streams value (only if Prefix/Exact) + if matches!(streams_scope, ScopeOption::Prefix | ScopeOption::Exact) { + let streams_editing = *editing && *selected == 6; + lines.push(Line::from(vec![ + Span::styled(marker(*selected == 6), Style::default().fg(GREEN)), + Span::styled(" Pattern ", Style::default().fg(if *selected == 6 { TEXT_PRIMARY } else { TEXT_MUTED })), + Span::styled( + if streams_value.is_empty() && !streams_editing { + "(enter pattern)".to_string() + } else { + format!("{}{}", streams_value, cursor(streams_editing)) + }, + Style::default().fg(if streams_editing { GREEN } else { TEXT_SECONDARY }) + ), + ])); + } + + // Row 7: Access Tokens scope + lines.push(Line::from(vec![ + Span::styled(marker(*selected == 7), Style::default().fg(GREEN)), + Span::styled("Access Tokens ", Style::default().fg(if *selected == 7 { TEXT_PRIMARY } else { TEXT_MUTED })), + Span::styled(format!("< {} >", tokens_scope.as_str()), Style::default().fg(TEXT_SECONDARY)), + ])); + + // Row 8: Tokens value (only if Prefix/Exact) + if matches!(tokens_scope, ScopeOption::Prefix | ScopeOption::Exact) { + let tokens_editing = *editing && *selected == 8; + lines.push(Line::from(vec![ + Span::styled(marker(*selected == 8), Style::default().fg(GREEN)), + Span::styled(" Pattern ", Style::default().fg(if *selected == 8 { TEXT_PRIMARY } else { TEXT_MUTED })), + Span::styled( + if tokens_value.is_empty() && !tokens_editing { + "(enter pattern)".to_string() + } else { + format!("{}{}", tokens_value, cursor(tokens_editing)) + }, + Style::default().fg(if tokens_editing { GREEN } else { TEXT_SECONDARY }) + ), + ])); + } + + // Operations section header + lines.push(Line::from("")); + lines.push(Line::from(Span::styled("── Operations ──", Style::default().fg(TEXT_MUTED)))); + + // Row 9-10: Account operations + lines.push(Line::from(vec![ + Span::styled(marker(*selected == 9), Style::default().fg(GREEN)), + Span::styled(format!("{} ", checkbox(*account_read)), Style::default().fg(if *account_read { GREEN } else { TEXT_MUTED })), + Span::styled("Account Read ", Style::default().fg(if *selected == 9 { TEXT_PRIMARY } else { TEXT_MUTED })), + Span::styled(marker(*selected == 10), Style::default().fg(GREEN)), + Span::styled(format!("{} ", checkbox(*account_write)), Style::default().fg(if *account_write { GREEN } else { TEXT_MUTED })), + Span::styled("Write", Style::default().fg(if *selected == 10 { TEXT_PRIMARY } else { TEXT_MUTED })), + ])); + + // Row 11-12: Basin operations + lines.push(Line::from(vec![ + Span::styled(marker(*selected == 11), Style::default().fg(GREEN)), + Span::styled(format!("{} ", checkbox(*basin_read)), Style::default().fg(if *basin_read { GREEN } else { TEXT_MUTED })), + Span::styled("Basin Read ", Style::default().fg(if *selected == 11 { TEXT_PRIMARY } else { TEXT_MUTED })), + Span::styled(marker(*selected == 12), Style::default().fg(GREEN)), + Span::styled(format!("{} ", checkbox(*basin_write)), Style::default().fg(if *basin_write { GREEN } else { TEXT_MUTED })), + Span::styled("Write", Style::default().fg(if *selected == 12 { TEXT_PRIMARY } else { TEXT_MUTED })), + ])); + + // Row 13-14: Stream operations + lines.push(Line::from(vec![ + Span::styled(marker(*selected == 13), Style::default().fg(GREEN)), + Span::styled(format!("{} ", checkbox(*stream_read)), Style::default().fg(if *stream_read { GREEN } else { TEXT_MUTED })), + Span::styled("Stream Read ", Style::default().fg(if *selected == 13 { TEXT_PRIMARY } else { TEXT_MUTED })), + Span::styled(marker(*selected == 14), Style::default().fg(GREEN)), + Span::styled(format!("{} ", checkbox(*stream_write)), Style::default().fg(if *stream_write { GREEN } else { TEXT_MUTED })), + Span::styled("Write", Style::default().fg(if *selected == 14 { TEXT_PRIMARY } else { TEXT_MUTED })), + ])); + + // Options section + lines.push(Line::from("")); + lines.push(Line::from(Span::styled("── Options ──", Style::default().fg(TEXT_MUTED)))); + + // Row 15: Auto-prefix streams + lines.push(Line::from(vec![ + Span::styled(marker(*selected == 15), Style::default().fg(GREEN)), + Span::styled(format!("{} ", checkbox(*auto_prefix_streams)), Style::default().fg(if *auto_prefix_streams { GREEN } else { TEXT_MUTED })), + Span::styled("Auto-prefix streams", Style::default().fg(if *selected == 15 { TEXT_PRIMARY } else { TEXT_MUTED })), + ])); + + lines.push(Line::from("")); + + // Row 16: Submit button + let can_submit = !id.is_empty(); + let (btn_fg, btn_bg) = if *selected == 16 && can_submit { + (BG_DARK, SUCCESS) + } else { + (if can_submit { SUCCESS } else { TEXT_MUTED }, BG_PANEL) + }; + lines.push(Line::from(vec![ + Span::styled(marker(*selected == 16), Style::default().fg(GREEN)), + Span::styled(" ▶ ISSUE TOKEN ", Style::default().fg(btn_fg).bg(btn_bg).bold()), + ])); + + ( + " Issue Access Token ", + lines, + "↑↓ nav ←→ cycle space toggle ⏎ edit/submit esc", + ) + } + + InputMode::ConfirmRevokeToken { token_id } => ( + " Revoke Access Token ", + vec![ + Line::from(""), + Line::from(vec![ + Span::styled("Revoke token ", Style::default().fg(TEXT_SECONDARY)), + Span::styled(token_id, Style::default().fg(ERROR).bold()), + Span::styled("?", Style::default().fg(TEXT_SECONDARY)), + ]), + Line::from(""), + Line::from(vec![ + Span::styled("The token will be immediately invalidated.", Style::default().fg(TEXT_MUTED)), + ]), + Line::from(vec![ + Span::styled("This action cannot be undone.", Style::default().fg(ERROR)), + ]), + ], + "y confirm n/esc cancel", + ), + + InputMode::ShowIssuedToken { token } => ( + " Access Token Issued ", + vec![ + Line::from(""), + Line::from(Span::styled("Copy this token now - it won't be shown again!", Style::default().fg(WARNING).bold())), + Line::from(""), + Line::from(Span::styled(token, Style::default().fg(GREEN))), + Line::from(""), + ], + "press any key to dismiss", + ), }; let area = centered_rect(55, 85, f.area()); diff --git a/src/types.rs b/src/types.rs index a196e67..1acb247 100644 --- a/src/types.rs +++ b/src/types.rs @@ -695,7 +695,7 @@ impl From for AccessTokenScope { } } -#[derive(Debug, Clone, Serialize, clap::ValueEnum, strum::Display, strum::EnumString)] +#[derive(Debug, Clone, PartialEq, Eq, Serialize, clap::ValueEnum, strum::Display, strum::EnumString)] #[serde(rename_all = "snake_case")] #[clap(rename_all = "snake_case")] #[strum(serialize_all = "snake_case")] From 91fca8337f6200a42fa326270661a40cc5d59c61 Mon Sep 17 00:00:00 2001 From: Mehul Arora Date: Fri, 23 Jan 2026 14:44:07 -0500 Subject: [PATCH 06/31] . --- src/tui/app.rs | 330 +++++++++++++++++++++++++- src/tui/event.rs | 8 +- src/tui/ui.rs | 607 ++++++++++++++++++++++++++++++++++++++++++++++- 3 files changed, 938 insertions(+), 7 deletions(-) diff --git a/src/tui/app.rs b/src/tui/app.rs index 03e53c2..990eb3a 100644 --- a/src/tui/app.rs +++ b/src/tui/app.rs @@ -5,7 +5,7 @@ use base64ct::Encoding; use crossterm::event::{self, Event as CrosstermEvent, KeyCode, KeyEvent, KeyModifiers}; use futures::StreamExt; use ratatui::{Terminal, prelude::Backend}; -use s2_sdk::types::{AccessTokenId, AccessTokenInfo, BasinInfo, BasinName, StreamInfo, StreamName, StreamPosition}; +use s2_sdk::types::{AccessTokenId, AccessTokenInfo, BasinInfo, BasinMetricSet, BasinName, StreamInfo, StreamMetricSet, StreamName, StreamPosition, TimeRange}; use tokio::sync::mpsc; use crate::cli::{CreateBasinArgs, CreateStreamArgs, IssueAccessTokenArgs, ListAccessTokensArgs, ListBasinsArgs, ListStreamsArgs, ReadArgs, ReconfigureBasinArgs, ReconfigureStreamArgs}; @@ -38,6 +38,7 @@ pub enum Screen { ReadView(ReadViewState), AppendView(AppendViewState), AccessTokens(AccessTokensState), + MetricsView(MetricsViewState), } /// State for the basins list screen @@ -126,6 +127,66 @@ pub struct AccessTokensState { pub filter_active: bool, } +/// Type of metrics being viewed +#[derive(Debug, Clone)] +pub enum MetricsType { + Basin { basin_name: BasinName }, + Stream { basin_name: BasinName, stream_name: StreamName }, +} + +/// Which metric is currently selected +#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)] +pub enum MetricCategory { + #[default] + Storage, + AppendOps, + ReadOps, + AppendThroughput, + ReadThroughput, +} + +impl MetricCategory { + pub fn next(&self) -> Self { + match self { + Self::Storage => Self::AppendOps, + Self::AppendOps => Self::ReadOps, + Self::ReadOps => Self::AppendThroughput, + Self::AppendThroughput => Self::ReadThroughput, + Self::ReadThroughput => Self::Storage, + } + } + + pub fn prev(&self) -> Self { + match self { + Self::Storage => Self::ReadThroughput, + Self::AppendOps => Self::Storage, + Self::ReadOps => Self::AppendOps, + Self::AppendThroughput => Self::ReadOps, + Self::ReadThroughput => Self::AppendThroughput, + } + } + + pub fn as_str(&self) -> &'static str { + match self { + Self::Storage => "Storage", + Self::AppendOps => "Append Ops", + Self::ReadOps => "Read Ops", + Self::AppendThroughput => "Append Throughput", + Self::ReadThroughput => "Read Throughput", + } + } +} + +/// State for the metrics view +#[derive(Debug, Clone)] +pub struct MetricsViewState { + pub metrics_type: MetricsType, + pub metrics: Vec, + pub selected_category: MetricCategory, + pub loading: bool, + pub scroll: usize, +} + /// Status message level #[derive(Debug, Clone, Copy, PartialEq, Eq)] pub enum MessageLevel { @@ -256,6 +317,8 @@ pub enum InputMode { ConfirmRevokeToken { token_id: String }, /// Show issued token (one-time display) ShowIssuedToken { token: String }, + /// View access token details + ViewTokenDetail { token: AccessTokenInfo }, } /// Retention policy option for UI @@ -1049,6 +1112,40 @@ impl App { } } + Event::BasinMetricsLoaded(result) => { + if let Screen::MetricsView(state) = &mut self.screen { + state.loading = false; + match result { + Ok(metrics) => { + state.metrics = metrics; + } + Err(e) => { + self.message = Some(StatusMessage { + text: format!("Failed to load basin metrics: {e}"), + level: MessageLevel::Error, + }); + } + } + } + } + + Event::StreamMetricsLoaded(result) => { + if let Screen::MetricsView(state) = &mut self.screen { + state.loading = false; + match result { + Ok(metrics) => { + state.metrics = metrics; + } + Err(e) => { + self.message = Some(StatusMessage { + text: format!("Failed to load stream metrics: {e}"), + level: MessageLevel::Error, + }); + } + } + } + } + Event::Error(e) => { self.message = Some(StatusMessage { text: e.to_string(), @@ -1116,6 +1213,7 @@ impl App { Screen::ReadView(_) => self.handle_read_view_key(key, tx), Screen::AppendView(_) => self.handle_append_view_key(key, tx), Screen::AccessTokens(_) => self.handle_access_tokens_key(key, tx), + Screen::MetricsView(_) => self.handle_metrics_view_key(key, tx), } } @@ -1938,6 +2036,16 @@ impl App { _ => {} } } + + InputMode::ViewTokenDetail { .. } => { + // Esc or Enter to close detail view + match key.code { + KeyCode::Esc | KeyCode::Enter | KeyCode::Char('q') => { + self.input_mode = InputMode::Normal; + } + _ => {} + } + } } } @@ -2048,6 +2156,13 @@ impl App { self.load_basin_config(basin_name, tx); } } + KeyCode::Char('M') => { + // Basin Metrics for selected basin + if let Some(basin) = filtered.get(state.selected) { + let basin_name = basin.name.clone(); + self.open_basin_metrics(basin_name, tx); + } + } KeyCode::Esc => { if !state.filter.is_empty() { state.filter.clear(); @@ -2190,6 +2305,11 @@ impl App { self.load_stream_config_for_reconfig(basin_name, stream_name, tx); } } + KeyCode::Char('M') => { + // Basin Metrics + let basin_name = state.basin_name.clone(); + self.open_basin_metrics(basin_name, tx); + } _ => {} } } @@ -2282,6 +2402,12 @@ impl App { let stream_name = state.stream_name.clone(); self.open_trim_dialog(basin_name, stream_name); } + KeyCode::Char('M') => { + // Stream Metrics + let basin_name = state.basin_name.clone(); + let stream_name = state.stream_name.clone(); + self.open_stream_metrics(basin_name, stream_name, tx); + } _ => {} } } @@ -3640,6 +3766,14 @@ impl App { state.loading = true; self.load_access_tokens(tx); } + KeyCode::Char('i') | KeyCode::Enter => { + // View token details + if let Some(token) = filtered_tokens.get(state.selected) { + self.input_mode = InputMode::ViewTokenDetail { + token: (*token).clone(), + }; + } + } _ => {} } } @@ -3876,4 +4010,198 @@ impl App { } }); } + + /// Open basin metrics view + fn open_basin_metrics(&mut self, basin_name: BasinName, tx: mpsc::UnboundedSender) { + self.screen = Screen::MetricsView(MetricsViewState { + metrics_type: MetricsType::Basin { basin_name: basin_name.clone() }, + metrics: Vec::new(), + selected_category: MetricCategory::Storage, + loading: true, + scroll: 0, + }); + self.load_basin_metrics(basin_name, MetricCategory::Storage, tx); + } + + /// Open stream metrics view + fn open_stream_metrics(&mut self, basin_name: BasinName, stream_name: StreamName, tx: mpsc::UnboundedSender) { + self.screen = Screen::MetricsView(MetricsViewState { + metrics_type: MetricsType::Stream { basin_name: basin_name.clone(), stream_name: stream_name.clone() }, + metrics: Vec::new(), + selected_category: MetricCategory::Storage, + loading: true, + scroll: 0, + }); + self.load_stream_metrics(basin_name, stream_name, tx); + } + + /// Load basin metrics + fn load_basin_metrics(&self, basin_name: BasinName, category: MetricCategory, tx: mpsc::UnboundedSender) { + let s2 = self.s2.clone(); + + tokio::spawn(async move { + // Get metrics for last 24 hours + let now = std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .unwrap_or_default() + .as_secs() as u32; + let day_ago = now.saturating_sub(24 * 60 * 60); + + let set = match category { + MetricCategory::Storage => BasinMetricSet::Storage(TimeRange::new(day_ago, now)), + MetricCategory::AppendOps => BasinMetricSet::AppendOps( + s2_sdk::types::TimeRangeAndInterval::new(day_ago, now) + ), + MetricCategory::ReadOps => BasinMetricSet::ReadOps( + s2_sdk::types::TimeRangeAndInterval::new(day_ago, now) + ), + MetricCategory::AppendThroughput => BasinMetricSet::AppendThroughput( + s2_sdk::types::TimeRangeAndInterval::new(day_ago, now) + ), + MetricCategory::ReadThroughput => BasinMetricSet::ReadThroughput( + s2_sdk::types::TimeRangeAndInterval::new(day_ago, now) + ), + }; + + let input = s2_sdk::types::GetBasinMetricsInput::new(basin_name, set); + match s2.get_basin_metrics(input).await { + Ok(metrics) => { + let _ = tx.send(Event::BasinMetricsLoaded(Ok(metrics))); + } + Err(e) => { + let _ = tx.send(Event::BasinMetricsLoaded(Err(CliError::op( + crate::error::OpKind::GetBasinMetrics, + e, + )))); + } + } + }); + } + + /// Load stream metrics + fn load_stream_metrics(&self, basin_name: BasinName, stream_name: StreamName, tx: mpsc::UnboundedSender) { + let s2 = self.s2.clone(); + + tokio::spawn(async move { + // Get metrics for last 24 hours + let now = std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .unwrap_or_default() + .as_secs() as u32; + let day_ago = now.saturating_sub(24 * 60 * 60); + + let set = StreamMetricSet::Storage(TimeRange::new(day_ago, now)); + + let input = s2_sdk::types::GetStreamMetricsInput::new(basin_name, stream_name, set); + match s2.get_stream_metrics(input).await { + Ok(metrics) => { + let _ = tx.send(Event::StreamMetricsLoaded(Ok(metrics))); + } + Err(e) => { + let _ = tx.send(Event::StreamMetricsLoaded(Err(CliError::op( + crate::error::OpKind::GetStreamMetrics, + e, + )))); + } + } + }); + } + + /// Handle keys in metrics view + fn handle_metrics_view_key(&mut self, key: KeyEvent, tx: mpsc::UnboundedSender) { + // Extract data from state first to avoid borrow issues + let (metrics_type, selected_category) = { + let Screen::MetricsView(state) = &self.screen else { + return; + }; + (state.metrics_type.clone(), state.selected_category) + }; + + match key.code { + KeyCode::Esc | KeyCode::Char('q') => { + // Go back to previous screen + match &metrics_type { + MetricsType::Basin { basin_name } => { + let basin_name = basin_name.clone(); + self.screen = Screen::Streams(StreamsState { + basin_name: basin_name.clone(), + streams: Vec::new(), + selected: 0, + loading: true, + filter: String::new(), + filter_active: false, + }); + self.load_streams(basin_name, tx); + } + MetricsType::Stream { basin_name, stream_name } => { + let basin_name = basin_name.clone(); + let stream_name = stream_name.clone(); + self.screen = Screen::StreamDetail(StreamDetailState { + basin_name: basin_name.clone(), + stream_name: stream_name.clone(), + config: None, + tail_position: None, + selected_action: 0, + loading: true, + }); + self.load_stream_detail(basin_name, stream_name, tx); + } + } + } + KeyCode::Left | KeyCode::Char('h') => { + // Previous metric category (only for basin metrics) + if let MetricsType::Basin { basin_name } = &metrics_type { + let basin_name = basin_name.clone(); + let new_category = selected_category.prev(); + if let Screen::MetricsView(state) = &mut self.screen { + state.selected_category = new_category; + state.loading = true; + state.metrics.clear(); + } + self.load_basin_metrics(basin_name, new_category, tx); + } + } + KeyCode::Right | KeyCode::Char('l') => { + // Next metric category (only for basin metrics) + if let MetricsType::Basin { basin_name } = &metrics_type { + let basin_name = basin_name.clone(); + let new_category = selected_category.next(); + if let Screen::MetricsView(state) = &mut self.screen { + state.selected_category = new_category; + state.loading = true; + state.metrics.clear(); + } + self.load_basin_metrics(basin_name, new_category, tx); + } + } + KeyCode::Up | KeyCode::Char('k') => { + if let Screen::MetricsView(state) = &mut self.screen { + if state.scroll > 0 { + state.scroll -= 1; + } + } + } + KeyCode::Down | KeyCode::Char('j') => { + if let Screen::MetricsView(state) = &mut self.screen { + state.scroll += 1; + } + } + KeyCode::Char('r') => { + // Refresh + if let Screen::MetricsView(state) = &mut self.screen { + state.loading = true; + state.metrics.clear(); + } + match &metrics_type { + MetricsType::Basin { basin_name } => { + self.load_basin_metrics(basin_name.clone(), selected_category, tx); + } + MetricsType::Stream { basin_name, stream_name } => { + self.load_stream_metrics(basin_name.clone(), stream_name.clone(), tx); + } + } + } + _ => {} + } + } } diff --git a/src/tui/event.rs b/src/tui/event.rs index ec0cf5f..4c03eb6 100644 --- a/src/tui/event.rs +++ b/src/tui/event.rs @@ -1,4 +1,4 @@ -use s2_sdk::types::{AccessTokenInfo, BasinInfo, SequencedRecord, StreamInfo, StreamPosition}; +use s2_sdk::types::{AccessTokenInfo, BasinInfo, Metric, SequencedRecord, StreamInfo, StreamPosition}; use crate::error::CliError; use crate::types::{StorageClass, StreamConfig, TimestampingMode}; @@ -87,6 +87,12 @@ pub enum Event { /// Access token revoked successfully (token id) AccessTokenRevoked(Result), + /// Basin metrics loaded + BasinMetricsLoaded(Result, CliError>), + + /// Stream metrics loaded + StreamMetricsLoaded(Result, CliError>), + /// An error occurred in a background task Error(CliError), } diff --git a/src/tui/ui.rs b/src/tui/ui.rs index 6bc60f0..66fb8eb 100644 --- a/src/tui/ui.rs +++ b/src/tui/ui.rs @@ -8,7 +8,7 @@ use ratatui::{ use crate::types::{StorageClass, TimestampingMode}; -use super::app::{AccessTokensState, App, AgoUnit, AppendViewState, BasinsState, ExpiryOption, InputMode, MessageLevel, ReadStartFrom, ReadViewState, RetentionPolicyOption, ScopeOption, Screen, StreamDetailState, StreamsState, Tab}; +use super::app::{AccessTokensState, App, AgoUnit, AppendViewState, BasinsState, ExpiryOption, InputMode, MessageLevel, MetricCategory, MetricsType, MetricsViewState, ReadStartFrom, ReadViewState, RetentionPolicyOption, ScopeOption, Screen, StreamDetailState, StreamsState, Tab}; // S2 Console dark theme const GREEN: Color = Color::Rgb(34, 197, 94); // Active green @@ -81,6 +81,7 @@ pub fn draw(f: &mut Frame, app: &App) { Screen::ReadView(state) => draw_read_view(f, chunks[1], state), Screen::AppendView(state) => draw_append_view(f, chunks[1], state), Screen::AccessTokens(state) => draw_access_tokens(f, chunks[1], state), + Screen::MetricsView(state) => draw_metrics_view(f, chunks[1], state), } // Draw status bar @@ -330,6 +331,460 @@ fn format_scope_summary(token: &s2_sdk::types::AccessTokenInfo) -> String { parts.join(", ") } +/// Format a basin matcher for display +fn format_basin_matcher(matcher: &Option) -> String { + use s2_sdk::types::BasinMatcher; + match matcher { + None => "All".to_string(), + Some(BasinMatcher::None) => "None".to_string(), + Some(BasinMatcher::Prefix(p)) => format!("Prefix: {}", p), + Some(BasinMatcher::Exact(e)) => format!("Exact: {}", e), + } +} + +/// Format a stream matcher for display +fn format_stream_matcher(matcher: &Option) -> String { + use s2_sdk::types::StreamMatcher; + match matcher { + None => "All".to_string(), + Some(StreamMatcher::None) => "None".to_string(), + Some(StreamMatcher::Prefix(p)) => format!("Prefix: {}", p), + Some(StreamMatcher::Exact(e)) => format!("Exact: {}", e), + } +} + +/// Format an access token matcher for display +fn format_token_matcher(matcher: &Option) -> String { + use s2_sdk::types::AccessTokenMatcher; + match matcher { + None => "All".to_string(), + Some(AccessTokenMatcher::None) => "None".to_string(), + Some(AccessTokenMatcher::Prefix(p)) => format!("Prefix: {}", p), + Some(AccessTokenMatcher::Exact(e)) => format!("Exact: {}", e), + } +} + +/// Format an operation for display +fn format_operation(op: &s2_sdk::types::Operation) -> String { + use s2_sdk::types::Operation as SdkOp; + match op { + SdkOp::ListBasins => "list_basins", + SdkOp::CreateBasin => "create_basin", + SdkOp::DeleteBasin => "delete_basin", + SdkOp::GetBasinConfig => "get_basin_config", + SdkOp::ReconfigureBasin => "reconfigure_basin", + SdkOp::GetBasinMetrics => "get_basin_metrics", + SdkOp::ListStreams => "list_streams", + SdkOp::CreateStream => "create_stream", + SdkOp::DeleteStream => "delete_stream", + SdkOp::GetStreamConfig => "get_stream_config", + SdkOp::ReconfigureStream => "reconfigure_stream", + SdkOp::GetStreamMetrics => "get_stream_metrics", + SdkOp::CheckTail => "check_tail", + SdkOp::Read => "read", + SdkOp::Append => "append", + SdkOp::Fence => "fence", + SdkOp::Trim => "trim", + SdkOp::GetAccountMetrics => "get_account_metrics", + SdkOp::ListAccessTokens => "list_access_tokens", + SdkOp::IssueAccessToken => "issue_access_token", + SdkOp::RevokeAccessToken => "revoke_access_token", + }.to_string() +} + +/// Check if operation is account-level +fn is_account_op(op: &s2_sdk::types::Operation) -> bool { + use s2_sdk::types::Operation as SdkOp; + matches!(op, SdkOp::ListBasins | SdkOp::GetAccountMetrics) +} + +/// Check if operation is basin-level +fn is_basin_op(op: &s2_sdk::types::Operation) -> bool { + use s2_sdk::types::Operation as SdkOp; + matches!(op, + SdkOp::CreateBasin | SdkOp::DeleteBasin | + SdkOp::GetBasinConfig | SdkOp::ReconfigureBasin | + SdkOp::ListStreams | SdkOp::GetBasinMetrics) +} + +/// Check if operation is stream-level +fn is_stream_op(op: &s2_sdk::types::Operation) -> bool { + use s2_sdk::types::Operation as SdkOp; + matches!(op, + SdkOp::CreateStream | SdkOp::DeleteStream | + SdkOp::GetStreamConfig | SdkOp::ReconfigureStream | + SdkOp::Read | SdkOp::Append | SdkOp::CheckTail | + SdkOp::Fence | SdkOp::Trim | SdkOp::GetStreamMetrics) +} + +/// Check if operation is token-related +fn is_token_op(op: &s2_sdk::types::Operation) -> bool { + use s2_sdk::types::Operation as SdkOp; + matches!(op, SdkOp::ListAccessTokens | SdkOp::IssueAccessToken | SdkOp::RevokeAccessToken) +} + +fn draw_metrics_view(f: &mut Frame, area: Rect, state: &MetricsViewState) { + use s2_sdk::types::Metric; + + // Layout: Title, Category tabs, Sparkline, Stats, Bar chart + let chunks = Layout::default() + .direction(Direction::Vertical) + .constraints([ + Constraint::Length(3), // Title + Constraint::Length(3), // Category tabs or info + Constraint::Length(5), // Sparkline graph + Constraint::Length(4), // Stats summary + Constraint::Min(1), // Bar chart + ]) + .split(area); + + // === Title === + let title = match &state.metrics_type { + MetricsType::Basin { basin_name } => format!(" Basin Metrics: {} ", basin_name), + MetricsType::Stream { basin_name, stream_name } => format!(" Stream Metrics: {}/{} ", basin_name, stream_name), + }; + + let title_block = Block::default() + .borders(Borders::ALL) + .border_style(Style::default().fg(GREEN)) + .style(Style::default().bg(BG_PANEL)); + + let title_para = Paragraph::new(Line::from(Span::styled( + title, + Style::default().fg(TEXT_PRIMARY).bold(), + ))) + .block(title_block) + .alignment(Alignment::Center); + f.render_widget(title_para, chunks[0]); + + // === Category tabs (only for basin metrics) === + if matches!(state.metrics_type, MetricsType::Basin { .. }) { + let categories = [ + MetricCategory::Storage, + MetricCategory::AppendOps, + MetricCategory::ReadOps, + MetricCategory::AppendThroughput, + MetricCategory::ReadThroughput, + ]; + + let mut tabs: Vec = vec![ + Span::styled(" ◀ ", Style::default().fg(TEXT_MUTED)), + ]; + for cat in &categories { + let style = if *cat == state.selected_category { + Style::default().fg(BG_DARK).bg(GREEN).bold() + } else { + Style::default().fg(TEXT_MUTED) + }; + tabs.push(Span::styled(format!(" {} ", cat.as_str()), style)); + } + tabs.push(Span::styled(" ▶ ", Style::default().fg(TEXT_MUTED))); + + let tab_line = Line::from(tabs); + let tabs_block = Block::default() + .borders(Borders::ALL) + .border_style(Style::default().fg(BORDER)) + .title(Line::from(Span::styled(" ←/→ to switch ", Style::default().fg(TEXT_MUTED)))) + .style(Style::default().bg(BG_PANEL)); + + let tabs_para = Paragraph::new(tab_line) + .block(tabs_block) + .alignment(Alignment::Center); + f.render_widget(tabs_para, chunks[1]); + } else { + let info = Line::from(vec![ + Span::styled(" 📈 ", Style::default().fg(GREEN)), + Span::styled("Storage over last 24 hours", Style::default().fg(TEXT_PRIMARY)), + ]); + let info_block = Block::default() + .borders(Borders::ALL) + .border_style(Style::default().fg(BORDER)) + .style(Style::default().bg(BG_PANEL)); + let info_para = Paragraph::new(info).block(info_block).alignment(Alignment::Center); + f.render_widget(info_para, chunks[1]); + } + + // === Loading / Empty states === + if state.loading { + let loading_block = Block::default() + .borders(Borders::ALL) + .border_style(Style::default().fg(BORDER)) + .style(Style::default().bg(BG_DARK)); + let loading = Paragraph::new(vec![ + Line::from(""), + Line::from(Span::styled("⏳ Loading metrics...", Style::default().fg(TEXT_MUTED))), + ]) + .block(loading_block) + .alignment(Alignment::Center); + + // Render loading in remaining chunks + let remaining = Layout::default() + .direction(Direction::Vertical) + .constraints([Constraint::Min(1)]) + .split(chunks[2]); + f.render_widget(loading, remaining[0]); + return; + } + + if state.metrics.is_empty() { + let empty_block = Block::default() + .borders(Borders::ALL) + .border_style(Style::default().fg(BORDER)) + .style(Style::default().bg(BG_DARK)); + let empty = Paragraph::new(vec![ + Line::from(""), + Line::from(Span::styled("📭 No metrics data available", Style::default().fg(TEXT_MUTED))), + Line::from(""), + Line::from(Span::styled("Try writing some data to the stream first", Style::default().fg(TEXT_MUTED))), + ]) + .block(empty_block) + .alignment(Alignment::Center); + + let remaining = Layout::default() + .direction(Direction::Vertical) + .constraints([Constraint::Min(1)]) + .split(chunks[2]); + f.render_widget(empty, remaining[0]); + return; + } + + // Collect all time-series values for rendering + let mut all_values: Vec<(u32, f64)> = Vec::new(); + let mut metric_name = String::new(); + let mut metric_unit = s2_sdk::types::MetricUnit::Bytes; + + for metric in &state.metrics { + match metric { + Metric::Gauge(m) => { + metric_name = m.name.clone(); + metric_unit = m.unit; + all_values.extend(m.values.iter().cloned()); + } + Metric::Accumulation(m) => { + metric_name = m.name.clone(); + metric_unit = m.unit; + all_values.extend(m.values.iter().cloned()); + } + Metric::Scalar(m) => { + metric_name = m.name.clone(); + metric_unit = m.unit; + all_values.push((0, m.value)); + } + Metric::Label(_) => {} + } + } + + if all_values.is_empty() { + return; + } + + // Sort by timestamp + all_values.sort_by_key(|(ts, _)| *ts); + + // === Sparkline graph === + let sparkline_block = Block::default() + .borders(Borders::ALL) + .border_style(Style::default().fg(BORDER)) + .title(Line::from(vec![ + Span::styled(" ", Style::default()), + Span::styled("Trend", Style::default().fg(TEXT_PRIMARY).bold()), + Span::styled(" ", Style::default()), + ])) + .style(Style::default().bg(BG_DARK)); + + let sparkline_width = chunks[2].width.saturating_sub(4) as usize; + let sparkline = render_sparkline(&all_values, sparkline_width); + + let sparkline_para = Paragraph::new(vec![ + Line::from(""), + Line::from(Span::styled(sparkline, Style::default().fg(GREEN))), + Line::from(""), + ]) + .block(sparkline_block) + .alignment(Alignment::Center); + f.render_widget(sparkline_para, chunks[2]); + + // === Stats summary === + let values_only: Vec = all_values.iter().map(|(_, v)| *v).collect(); + let min_val = values_only.iter().cloned().fold(f64::MAX, f64::min); + let max_val = values_only.iter().cloned().fold(f64::MIN, f64::max); + let avg_val = if !values_only.is_empty() { + values_only.iter().sum::() / values_only.len() as f64 + } else { + 0.0 + }; + let latest_val = values_only.last().cloned().unwrap_or(0.0); + + let stats_block = Block::default() + .borders(Borders::ALL) + .border_style(Style::default().fg(BORDER)) + .title(Line::from(vec![ + Span::styled(" ", Style::default()), + Span::styled(&metric_name, Style::default().fg(TEXT_PRIMARY).bold()), + Span::styled(" ", Style::default()), + ])) + .style(Style::default().bg(BG_PANEL)); + + let stats_lines = vec![ + Line::from(""), + Line::from(vec![ + Span::styled(" Current: ", Style::default().fg(TEXT_MUTED)), + Span::styled(format_metric_value_f64(latest_val, metric_unit), Style::default().fg(GREEN).bold()), + Span::styled(" Min: ", Style::default().fg(TEXT_MUTED)), + Span::styled(format_metric_value_f64(min_val, metric_unit), Style::default().fg(YELLOW)), + Span::styled(" Max: ", Style::default().fg(TEXT_MUTED)), + Span::styled(format_metric_value_f64(max_val, metric_unit), Style::default().fg(YELLOW)), + Span::styled(" Avg: ", Style::default().fg(TEXT_MUTED)), + Span::styled(format_metric_value_f64(avg_val, metric_unit), Style::default().fg(TEXT_SECONDARY)), + ]), + ]; + + let stats_para = Paragraph::new(stats_lines).block(stats_block); + f.render_widget(stats_para, chunks[3]); + + // === Bar chart === + let chart_block = Block::default() + .borders(Borders::ALL) + .border_style(Style::default().fg(BORDER)) + .title(Line::from(vec![ + Span::styled(" ", Style::default()), + Span::styled("Timeline (scroll with j/k)", Style::default().fg(TEXT_MUTED)), + Span::styled(" ", Style::default()), + ])) + .style(Style::default().bg(BG_DARK)); + + let chart_inner = chart_block.inner(chunks[4]); + f.render_widget(chart_block, chunks[4]); + + // Render bar chart + let chart_width = chart_inner.width.saturating_sub(32) as usize; + let visible_height = chart_inner.height as usize; + + let bars: Vec = all_values + .iter() + .skip(state.scroll) + .take(visible_height) + .map(|(ts, value)| { + let bar_len = if max_val > 0.0 { + ((*value / max_val) * chart_width as f64) as usize + } else { + 0 + }; + + // Gradient bar with different characters based on value intensity + let intensity = if max_val > 0.0 { *value / max_val } else { 0.0 }; + let bar_char = if intensity > 0.8 { "█" } else if intensity > 0.5 { "▓" } else if intensity > 0.2 { "▒" } else { "░" }; + let bar = bar_char.repeat(bar_len); + + let time_str = format_metric_timestamp_short(*ts); + let bar_color = if intensity > 0.8 { + GREEN + } else if intensity > 0.5 { + Color::Rgb(34, 197, 94) // bright green + } else if intensity > 0.2 { + Color::Rgb(74, 222, 128) // lighter green + } else { + Color::Rgb(134, 239, 172) // very light green + }; + + Line::from(vec![ + Span::styled(format!(" {:>12} │", time_str), Style::default().fg(TEXT_MUTED)), + Span::styled(bar, Style::default().fg(bar_color)), + Span::styled(format!(" {}", format_metric_value_f64(*value, metric_unit)), Style::default().fg(TEXT_SECONDARY)), + ]) + }) + .collect(); + + let bars_para = Paragraph::new(bars); + f.render_widget(bars_para, chart_inner); +} + +/// Render a sparkline using Unicode block characters +fn render_sparkline(values: &[(u32, f64)], width: usize) -> String { + if values.is_empty() { + return "─".repeat(width); + } + + // Sparkline characters from lowest to highest + let spark_chars = ['▁', '▂', '▃', '▄', '▅', '▆', '▇', '█']; + + let values_only: Vec = values.iter().map(|(_, v)| *v).collect(); + let min_val = values_only.iter().cloned().fold(f64::MAX, f64::min); + let max_val = values_only.iter().cloned().fold(f64::MIN, f64::max); + let range = max_val - min_val; + + // Resample values to fit width + let step = values_only.len() as f64 / width as f64; + let mut sparkline = String::new(); + + for i in 0..width { + let idx = (i as f64 * step) as usize; + let val = values_only.get(idx).cloned().unwrap_or(0.0); + + let normalized = if range > 0.0 { + ((val - min_val) / range * 7.0) as usize + } else { + 4 // middle if no range + }; + + sparkline.push(spark_chars[normalized.min(7)]); + } + + sparkline +} + +/// Format timestamp in short form for bar chart +fn format_metric_timestamp_short(ts: u32) -> String { + use std::time::{Duration, UNIX_EPOCH}; + let time = UNIX_EPOCH + Duration::from_secs(ts as u64); + // Just show time portion + humantime::format_rfc3339_seconds(time) + .to_string() + .chars() + .skip(11) // Skip date portion + .take(8) // Take HH:MM:SS + .collect() +} + +/// Format a metric value (f64) with appropriate unit +fn format_metric_value_f64(value: f64, unit: s2_sdk::types::MetricUnit) -> String { + use s2_sdk::types::MetricUnit; + match unit { + MetricUnit::Bytes => format_bytes(value as u64), + MetricUnit::Operations => format_count(value as u64), + } +} + +/// Format bytes with appropriate unit +fn format_bytes(bytes: u64) -> String { + const KB: u64 = 1024; + const MB: u64 = KB * 1024; + const GB: u64 = MB * 1024; + const TB: u64 = GB * 1024; + + if bytes >= TB { + format!("{:.2} TB", bytes as f64 / TB as f64) + } else if bytes >= GB { + format!("{:.2} GB", bytes as f64 / GB as f64) + } else if bytes >= MB { + format!("{:.2} MB", bytes as f64 / MB as f64) + } else if bytes >= KB { + format!("{:.2} KB", bytes as f64 / KB as f64) + } else { + format!("{} B", bytes) + } +} + +/// Format count with K/M suffixes +fn format_count(count: u64) -> String { + if count >= 1_000_000 { + format!("{:.1}M", count as f64 / 1_000_000.0) + } else if count >= 1_000 { + format!("{:.1}K", count as f64 / 1_000.0) + } else { + count.to_string() + } +} fn draw_basins(f: &mut Frame, area: Rect, state: &BasinsState) { // Layout: Search bar, Header, Table rows let chunks = Layout::default() @@ -1290,9 +1745,9 @@ fn draw_append_view(f: &mut Frame, area: Rect, state: &AppendViewState) { fn draw_status_bar(f: &mut Frame, area: Rect, app: &App) { let hints = match &app.screen { Screen::Splash => "", // Never shown - Screen::Basins(_) => "/ filter | jk nav | ret open | c new | e cfg | d del | r ref | ? | q", - Screen::Streams(_) => "/ filter | jk nav | ret open | c new | e cfg | d del | r ref | esc", - Screen::StreamDetail(_) => "jk | ret | t tail | r read | a append | f fence | m trim | e cfg | esc", + Screen::Basins(_) => "/ filter | jk nav | ⏎ open | M metrics | c new | e cfg | d del | r ref | ?", + Screen::Streams(_) => "/ filter | jk nav | ⏎ open | M metrics | c new | e cfg | d del | esc", + Screen::StreamDetail(_) => "t tail | r read | a append | f fence | m trim | M metrics | e cfg | esc", Screen::ReadView(s) => { if s.show_detail { "esc/⏎ close" @@ -1314,6 +1769,13 @@ fn draw_status_bar(f: &mut Frame, area: Rect, app: &App) { } } Screen::AccessTokens(_) => "/ filter | jk nav | c issue | d revoke | r ref | ⇥ switch | ? | q", + Screen::MetricsView(state) => { + if matches!(state.metrics_type, MetricsType::Basin { .. }) { + "←→ category | jk scroll | r refresh | esc back | q quit" + } else { + "jk scroll | r refresh | esc back | q quit" + } + } }; let message_span = app @@ -1547,6 +2009,35 @@ fn draw_help_overlay(f: &mut Frame, screen: &Screen) { ]), Line::from(""), ], + Screen::MetricsView(state) => { + let mut lines = vec![ + Line::from(""), + Line::from(vec![ + Span::styled(" j/k ", Style::default().fg(GREEN).bold()), + Span::styled("Scroll", Style::default().fg(TEXT_SECONDARY)), + ]), + Line::from(vec![ + Span::styled(" r ", Style::default().fg(GREEN).bold()), + Span::styled("Refresh", Style::default().fg(TEXT_SECONDARY)), + ]), + ]; + if matches!(state.metrics_type, MetricsType::Basin { .. }) { + lines.push(Line::from(vec![ + Span::styled(" ←/→ ", Style::default().fg(GREEN).bold()), + Span::styled("Change metric", Style::default().fg(TEXT_SECONDARY)), + ])); + } + lines.push(Line::from(vec![ + Span::styled(" esc ", Style::default().fg(GREEN).bold()), + Span::styled("Back", Style::default().fg(TEXT_SECONDARY)), + ])); + lines.push(Line::from(vec![ + Span::styled(" q ", Style::default().fg(GREEN).bold()), + Span::styled("Quit", Style::default().fg(TEXT_SECONDARY)), + ])); + lines.push(Line::from("")); + lines + }, }; let block = Block::default() @@ -1844,7 +2335,7 @@ fn draw_input_dialog(f: &mut Frame, mode: &InputMode) { } => { // Stylish indicators let radio = |active: bool| if active { "●" } else { "○" }; - let check = |on: bool| if on { "✓" } else { " " }; + let check = |on: bool| if on { "x" } else { " " }; // Value display - clean with ∞ for unlimited let show_val = |value: &str, is_editing: bool, placeholder: &str| -> String { @@ -2393,6 +2884,112 @@ fn draw_input_dialog(f: &mut Frame, mode: &InputMode) { ], "press any key to dismiss", ), + + InputMode::ViewTokenDetail { token } => { + let mut lines = vec![ + Line::from(""), + Line::from(vec![ + Span::styled("Token ID: ", Style::default().fg(TEXT_MUTED)), + Span::styled(token.id.to_string(), Style::default().fg(TEXT_PRIMARY).bold()), + ]), + Line::from(vec![ + Span::styled("Expires At: ", Style::default().fg(TEXT_MUTED)), + Span::styled(token.expires_at.to_string(), Style::default().fg(TEXT_PRIMARY)), + ]), + Line::from(vec![ + Span::styled("Auto-prefix: ", Style::default().fg(TEXT_MUTED)), + Span::styled( + if token.auto_prefix_streams { "Yes" } else { "No" }, + Style::default().fg(if token.auto_prefix_streams { GREEN } else { TEXT_MUTED }), + ), + ]), + Line::from(""), + Line::from(Span::styled("─── Resource Scope ───", Style::default().fg(BORDER))), + ]; + + // Basins scope + let basins_str = format_basin_matcher(&token.scope.basins); + lines.push(Line::from(vec![ + Span::styled("Basins: ", Style::default().fg(TEXT_MUTED)), + Span::styled(basins_str, Style::default().fg(TEXT_PRIMARY)), + ])); + + // Streams scope + let streams_str = format_stream_matcher(&token.scope.streams); + lines.push(Line::from(vec![ + Span::styled("Streams: ", Style::default().fg(TEXT_MUTED)), + Span::styled(streams_str, Style::default().fg(TEXT_PRIMARY)), + ])); + + // Access tokens scope + let tokens_str = format_token_matcher(&token.scope.access_tokens); + lines.push(Line::from(vec![ + Span::styled("Tokens: ", Style::default().fg(TEXT_MUTED)), + Span::styled(tokens_str, Style::default().fg(TEXT_PRIMARY)), + ])); + + lines.push(Line::from("")); + lines.push(Line::from(Span::styled("─── Operations ───", Style::default().fg(BORDER)))); + + // Group operations by category + let ops = &token.scope.ops; + + // Account operations + let account_ops: Vec<_> = ops.iter() + .filter(|o| is_account_op(o)) + .map(format_operation) + .collect(); + if !account_ops.is_empty() { + lines.push(Line::from(vec![ + Span::styled("Account: ", Style::default().fg(TEXT_MUTED)), + Span::styled(account_ops.join(", "), Style::default().fg(TEXT_PRIMARY)), + ])); + } + + // Basin operations + let basin_ops: Vec<_> = ops.iter() + .filter(|o| is_basin_op(o)) + .map(format_operation) + .collect(); + if !basin_ops.is_empty() { + lines.push(Line::from(vec![ + Span::styled("Basin: ", Style::default().fg(TEXT_MUTED)), + Span::styled(basin_ops.join(", "), Style::default().fg(TEXT_PRIMARY)), + ])); + } + + // Stream operations + let stream_ops: Vec<_> = ops.iter() + .filter(|o| is_stream_op(o)) + .map(format_operation) + .collect(); + if !stream_ops.is_empty() { + lines.push(Line::from(vec![ + Span::styled("Stream: ", Style::default().fg(TEXT_MUTED)), + Span::styled(stream_ops.join(", "), Style::default().fg(TEXT_PRIMARY)), + ])); + } + + // Token operations + let token_ops: Vec<_> = ops.iter() + .filter(|o| is_token_op(o)) + .map(format_operation) + .collect(); + if !token_ops.is_empty() { + lines.push(Line::from(vec![ + Span::styled("Tokens: ", Style::default().fg(TEXT_MUTED)), + Span::styled(token_ops.join(", "), Style::default().fg(TEXT_PRIMARY)), + ])); + } + + lines.push(Line::from("")); + + ( + " Access Token Details ", + lines, + "esc/enter close", + ) + }, }; let area = centered_rect(55, 85, f.area()); From 3cb38e60cbf45ae0240c0775fed936f3fbb70e66 Mon Sep 17 00:00:00 2001 From: Mehul Arora Date: Fri, 23 Jan 2026 16:24:24 -0500 Subject: [PATCH 07/31] .. --- src/tui/app.rs | 263 +++++++++++++++++++--- src/tui/ui.rs | 591 ++++++++++++++++++++++++++++++++++++++----------- 2 files changed, 693 insertions(+), 161 deletions(-) diff --git a/src/tui/app.rs b/src/tui/app.rs index 990eb3a..6cefb5e 100644 --- a/src/tui/app.rs +++ b/src/tui/app.rs @@ -12,7 +12,7 @@ use crate::cli::{CreateBasinArgs, CreateStreamArgs, IssueAccessTokenArgs, ListAc use crate::error::CliError; use crate::ops; use crate::record_format::{RecordFormat, RecordsOut}; -use crate::types::{BasinConfig, Operation, S2BasinAndMaybeStreamUri, S2BasinAndStreamUri, S2BasinUri, StorageClass, StreamConfig, TimestampingMode}; +use crate::types::{BasinConfig, Operation, RetentionPolicy, S2BasinAndMaybeStreamUri, S2BasinAndStreamUri, S2BasinUri, StorageClass, StreamConfig, TimestampingConfig, TimestampingMode}; use super::event::{BasinConfigInfo, Event, StreamConfigInfo}; use super::ui; @@ -208,7 +208,21 @@ pub enum InputMode { /// Not in input mode Normal, /// Creating a new basin - CreateBasin { input: String }, + CreateBasin { + name: String, + // Basin-level settings + create_stream_on_append: bool, + create_stream_on_read: bool, + // Default stream config + storage_class: Option, + retention_policy: RetentionPolicyOption, + retention_age_input: String, + timestamping_mode: Option, + timestamping_uncapped: bool, + // UI state + selected: usize, + editing: bool, + }, /// Creating a new stream CreateStream { basin: BasinName, input: String }, /// Confirming basin deletion @@ -559,6 +573,48 @@ pub struct App { should_quit: bool, } +/// Build a basin config from form values +fn build_basin_config( + create_stream_on_append: bool, + create_stream_on_read: bool, + storage_class: Option, + retention_policy: RetentionPolicyOption, + retention_age_input: String, + timestamping_mode: Option, + timestamping_uncapped: bool, +) -> BasinConfig { + // Parse retention policy + let retention = match retention_policy { + RetentionPolicyOption::Infinite => None, + RetentionPolicyOption::Age => { + humantime::parse_duration(&retention_age_input) + .ok() + .map(RetentionPolicy::Age) + } + }; + + // Build timestamping config if specified + let timestamping = if timestamping_mode.is_some() || timestamping_uncapped { + Some(TimestampingConfig { + timestamping_mode, + timestamping_uncapped: if timestamping_uncapped { Some(true) } else { None }, + }) + } else { + None + }; + + BasinConfig { + default_stream_config: StreamConfig { + storage_class, + retention_policy: retention, + timestamping, + delete_on_empty: None, + }, + create_stream_on_append, + create_stream_on_read, + } +} + impl App { pub fn new(s2: s2_sdk::S2) -> Self { Self { @@ -1290,27 +1346,174 @@ impl App { match &mut self.input_mode { InputMode::Normal => {} - InputMode::CreateBasin { input } => { - match key.code { - KeyCode::Esc => { - self.input_mode = InputMode::Normal; - } - KeyCode::Enter => { - if !input.is_empty() { - let name = input.clone(); - self.create_basin(name, tx.clone()); + InputMode::CreateBasin { + name, + create_stream_on_append, + create_stream_on_read, + storage_class, + retention_policy, + retention_age_input, + timestamping_mode, + timestamping_uncapped, + selected, + editing, + } => { + // Form fields: + // 0: Name (text) + // 1: Storage Class (cycle: None/Standard/Express) + // 2: Retention Policy (cycle: Infinite/Age) + // 3: Retention Age (text, only if Age) + // 4: Timestamping Mode (cycle: None/ClientPrefer/ClientRequire/Arrival) + // 5: Timestamping Uncapped (toggle) + // 6: Create Stream On Append (toggle) + // 7: Create Stream On Read (toggle) + // 8: Create button + const FIELD_COUNT: usize = 9; + + if *editing { + // Text editing mode + match key.code { + KeyCode::Esc | KeyCode::Enter => { + *editing = false; } + KeyCode::Backspace => { + if *selected == 0 { + name.pop(); + } else if *selected == 3 { + retention_age_input.pop(); + } + } + KeyCode::Char(c) => { + if *selected == 0 { + // Basin names: lowercase letters, numbers, hyphens + if c.is_ascii_lowercase() || c.is_ascii_digit() || c == '-' { + name.push(c); + } + } else if *selected == 3 { + // Retention age: alphanumeric for duration parsing + if c.is_ascii_alphanumeric() { + retention_age_input.push(c); + } + } + } + _ => {} } - KeyCode::Backspace => { - input.pop(); - } - KeyCode::Char(c) => { - // Basin names: lowercase letters, numbers, hyphens - if c.is_ascii_lowercase() || c.is_ascii_digit() || c == '-' { - input.push(c); + } else { + match key.code { + KeyCode::Esc => { + self.input_mode = InputMode::Normal; } + KeyCode::Up | KeyCode::Char('k') => { + if *selected > 0 { + *selected -= 1; + // Skip retention age if not using Age policy + if *selected == 3 && *retention_policy != RetentionPolicyOption::Age { + *selected = 2; + } + } + } + KeyCode::Down | KeyCode::Char('j') => { + if *selected < FIELD_COUNT - 1 { + *selected += 1; + // Skip retention age if not using Age policy + if *selected == 3 && *retention_policy != RetentionPolicyOption::Age { + *selected = 4; + } + } + } + KeyCode::Enter => { + match *selected { + 0 => *editing = true, // Edit name + 3 => { + if *retention_policy == RetentionPolicyOption::Age { + *editing = true; // Edit retention age + } + } + 8 => { + // Create button - validate and submit + if name.len() >= 8 { + // Extract all values to avoid borrow conflict + let basin_name = name.clone(); + let csoa = *create_stream_on_append; + let csor = *create_stream_on_read; + let sc = storage_class.clone(); + let rp = retention_policy.clone(); + let rai = retention_age_input.clone(); + let tm = timestamping_mode.clone(); + let tu = *timestamping_uncapped; + + let config = build_basin_config(csoa, csor, sc, rp, rai, tm, tu); + self.create_basin_with_config(basin_name, config, tx.clone()); + } + } + _ => {} + } + } + KeyCode::Char(' ') => { + // Toggle for boolean fields + match *selected { + 5 => *timestamping_uncapped = !*timestamping_uncapped, + 6 => *create_stream_on_append = !*create_stream_on_append, + 7 => *create_stream_on_read = !*create_stream_on_read, + _ => {} + } + } + KeyCode::Left | KeyCode::Char('h') => { + // Cycle left for enum fields + match *selected { + 1 => { + *storage_class = match storage_class { + None => Some(StorageClass::Express), + Some(StorageClass::Standard) => None, + Some(StorageClass::Express) => Some(StorageClass::Standard), + }; + } + 2 => { + *retention_policy = match retention_policy { + RetentionPolicyOption::Infinite => RetentionPolicyOption::Age, + RetentionPolicyOption::Age => RetentionPolicyOption::Infinite, + }; + } + 4 => { + *timestamping_mode = match timestamping_mode { + None => Some(TimestampingMode::Arrival), + Some(TimestampingMode::ClientPrefer) => None, + Some(TimestampingMode::ClientRequire) => Some(TimestampingMode::ClientPrefer), + Some(TimestampingMode::Arrival) => Some(TimestampingMode::ClientRequire), + }; + } + _ => {} + } + } + KeyCode::Right | KeyCode::Char('l') => { + // Cycle right for enum fields + match *selected { + 1 => { + *storage_class = match storage_class { + None => Some(StorageClass::Standard), + Some(StorageClass::Standard) => Some(StorageClass::Express), + Some(StorageClass::Express) => None, + }; + } + 2 => { + *retention_policy = match retention_policy { + RetentionPolicyOption::Infinite => RetentionPolicyOption::Age, + RetentionPolicyOption::Age => RetentionPolicyOption::Infinite, + }; + } + 4 => { + *timestamping_mode = match timestamping_mode { + None => Some(TimestampingMode::ClientPrefer), + Some(TimestampingMode::ClientPrefer) => Some(TimestampingMode::ClientRequire), + Some(TimestampingMode::ClientRequire) => Some(TimestampingMode::Arrival), + Some(TimestampingMode::Arrival) => None, + }; + } + _ => {} + } + } + _ => {} } - _ => {} } } @@ -2127,7 +2330,18 @@ impl App { self.load_basins(tx); } KeyCode::Char('c') => { - self.input_mode = InputMode::CreateBasin { input: String::new() }; + self.input_mode = InputMode::CreateBasin { + name: String::new(), + create_stream_on_append: false, + create_stream_on_read: false, + storage_class: None, + retention_policy: RetentionPolicyOption::Infinite, + retention_age_input: "7d".to_string(), + timestamping_mode: None, + timestamping_uncapped: false, + selected: 0, + editing: false, + }; } KeyCode::Char('d') => { if let Some(basin) = filtered.get(state.selected) { @@ -2580,7 +2794,8 @@ impl App { }); } - fn create_basin(&mut self, name: String, tx: mpsc::UnboundedSender) { + fn create_basin_with_config(&mut self, name: String, config: BasinConfig, tx: mpsc::UnboundedSender) { + self.input_mode = InputMode::Normal; let s2 = self.s2.clone(); let tx_refresh = tx.clone(); tokio::spawn(async move { @@ -2594,11 +2809,7 @@ impl App { }; let args = CreateBasinArgs { basin: S2BasinUri(basin_name), - config: BasinConfig { - default_stream_config: StreamConfig::default(), - create_stream_on_append: false, - create_stream_on_read: false, - }, + config, }; match ops::create_basin(&s2, args).await { Ok(info) => { diff --git a/src/tui/ui.rs b/src/tui/ui.rs index 66fb8eb..18a686b 100644 --- a/src/tui/ui.rs +++ b/src/tui/ui.rs @@ -99,6 +99,9 @@ pub fn draw(f: &mut Frame, app: &App) { } fn draw_splash(f: &mut Frame, area: Rect) { + // Draw aurora background effect + draw_aurora_background(f, area); + // S2 logo let logo = vec![ " █████████████████████████ ", @@ -154,6 +157,51 @@ fn draw_splash(f: &mut Frame, area: Rect) { f.render_widget(logo_widget, centered_area); } +/// Draw a subtle aurora/gradient background effect +fn draw_aurora_background(f: &mut Frame, area: Rect) { + let width = area.width as f64; + let height = area.height as f64; + + for row in 0..area.height { + let mut spans: Vec = Vec::new(); + for col in 0..area.width { + // Normalize coordinates + let x = col as f64 / width; + let y = row as f64 / height; + + // Create aurora effect - subtle glow from bottom-right and center + // Distance from bottom-right corner + let dist_br = ((x - 0.8).powi(2) + (y - 0.9).powi(2)).sqrt(); + // Distance from center-bottom + let dist_cb = ((x - 0.5).powi(2) + (y - 0.85).powi(2)).sqrt(); + + // Aurora intensity (stronger near bottom) + let intensity_br = (1.0 - dist_br * 1.5).max(0.0) * 0.4; + let intensity_cb = (1.0 - dist_cb * 1.8).max(0.0) * 0.3; + let intensity = (intensity_br + intensity_cb).min(1.0); + + // Base dark color with subtle blue/teal tint + let base_r = 8; + let base_g = 12; + let base_b = 18; + + // Aurora colors (teal/cyan) + let aurora_r = 0; + let aurora_g = 40; + let aurora_b = 60; + + let r = base_r + ((aurora_r - base_r as i32) as f64 * intensity) as u8; + let g = base_g + ((aurora_g - base_g as i32) as f64 * intensity) as u8; + let b = base_b + ((aurora_b - base_b as i32) as f64 * intensity) as u8; + + spans.push(Span::styled(" ", Style::default().bg(Color::Rgb(r, g, b)))); + } + let line = Line::from(spans); + let row_area = Rect::new(area.x, area.y + row, area.width, 1); + f.render_widget(Paragraph::new(line), row_area); + } +} + fn draw_tab_bar(f: &mut Frame, area: Rect, current_tab: Tab) { let basins_style = if current_tab == Tab::Basins { Style::default().fg(GREEN).bold() @@ -426,38 +474,23 @@ fn is_token_op(op: &s2_sdk::types::Operation) -> bool { fn draw_metrics_view(f: &mut Frame, area: Rect, state: &MetricsViewState) { use s2_sdk::types::Metric; - // Layout: Title, Category tabs, Sparkline, Stats, Bar chart + // Layout: Title+tabs, Stats header, Main graph area, Timeline let chunks = Layout::default() .direction(Direction::Vertical) .constraints([ - Constraint::Length(3), // Title - Constraint::Length(3), // Category tabs or info - Constraint::Length(5), // Sparkline graph - Constraint::Length(4), // Stats summary - Constraint::Min(1), // Bar chart + Constraint::Length(3), // Title with tabs + Constraint::Length(3), // Stats header row + Constraint::Min(12), // Main graph (area chart) + Constraint::Length(6), // Timeline (scrollable) ]) .split(area); - // === Title === + // === Title with integrated category tabs === let title = match &state.metrics_type { - MetricsType::Basin { basin_name } => format!(" Basin Metrics: {} ", basin_name), - MetricsType::Stream { basin_name, stream_name } => format!(" Stream Metrics: {}/{} ", basin_name, stream_name), + MetricsType::Basin { basin_name } => basin_name.to_string(), + MetricsType::Stream { basin_name, stream_name } => format!("{}/{}", basin_name, stream_name), }; - let title_block = Block::default() - .borders(Borders::ALL) - .border_style(Style::default().fg(GREEN)) - .style(Style::default().bg(BG_PANEL)); - - let title_para = Paragraph::new(Line::from(Span::styled( - title, - Style::default().fg(TEXT_PRIMARY).bold(), - ))) - .block(title_block) - .alignment(Alignment::Center); - f.render_widget(title_para, chunks[0]); - - // === Category tabs (only for basin metrics) === if matches!(state.metrics_type, MetricsType::Basin { .. }) { let categories = [ MetricCategory::Storage, @@ -467,41 +500,49 @@ fn draw_metrics_view(f: &mut Frame, area: Rect, state: &MetricsViewState) { MetricCategory::ReadThroughput, ]; - let mut tabs: Vec = vec![ - Span::styled(" ◀ ", Style::default().fg(TEXT_MUTED)), + let mut title_spans: Vec = vec![ + Span::styled(" [ ", Style::default().fg(BORDER)), + Span::styled(&title, Style::default().fg(GREEN).bold()), + Span::styled(" ] ", Style::default().fg(BORDER)), ]; - for cat in &categories { + + for (i, cat) in categories.iter().enumerate() { + if i > 0 { + title_spans.push(Span::styled(" | ", Style::default().fg(BORDER))); + } let style = if *cat == state.selected_category { Style::default().fg(BG_DARK).bg(GREEN).bold() } else { Style::default().fg(TEXT_MUTED) }; - tabs.push(Span::styled(format!(" {} ", cat.as_str()), style)); + title_spans.push(Span::styled(format!(" {} ", cat.as_str()), style)); } - tabs.push(Span::styled(" ▶ ", Style::default().fg(TEXT_MUTED))); - let tab_line = Line::from(tabs); - let tabs_block = Block::default() + let title_block = Block::default() .borders(Borders::ALL) - .border_style(Style::default().fg(BORDER)) - .title(Line::from(Span::styled(" ←/→ to switch ", Style::default().fg(TEXT_MUTED)))) + .border_style(Style::default().fg(GREEN)) + .title_bottom(Line::from(Span::styled(" ←/→ switch category ", Style::default().fg(TEXT_MUTED)))) .style(Style::default().bg(BG_PANEL)); - let tabs_para = Paragraph::new(tab_line) - .block(tabs_block) + let title_para = Paragraph::new(Line::from(title_spans)) + .block(title_block) .alignment(Alignment::Center); - f.render_widget(tabs_para, chunks[1]); + f.render_widget(title_para, chunks[0]); } else { - let info = Line::from(vec![ - Span::styled(" 📈 ", Style::default().fg(GREEN)), - Span::styled("Storage over last 24 hours", Style::default().fg(TEXT_PRIMARY)), - ]); - let info_block = Block::default() + let title_block = Block::default() .borders(Borders::ALL) - .border_style(Style::default().fg(BORDER)) + .border_style(Style::default().fg(GREEN)) .style(Style::default().bg(BG_PANEL)); - let info_para = Paragraph::new(info).block(info_block).alignment(Alignment::Center); - f.render_widget(info_para, chunks[1]); + + let title_para = Paragraph::new(Line::from(vec![ + Span::styled(" [ ", Style::default().fg(BORDER)), + Span::styled(&title, Style::default().fg(GREEN).bold()), + Span::styled(" ] ", Style::default().fg(BORDER)), + Span::styled("Storage (24h)", Style::default().fg(TEXT_PRIMARY)), + ])) + .block(title_block) + .alignment(Alignment::Center); + f.render_widget(title_para, chunks[0]); } // === Loading / Empty states === @@ -512,16 +553,15 @@ fn draw_metrics_view(f: &mut Frame, area: Rect, state: &MetricsViewState) { .style(Style::default().bg(BG_DARK)); let loading = Paragraph::new(vec![ Line::from(""), - Line::from(Span::styled("⏳ Loading metrics...", Style::default().fg(TEXT_MUTED))), + Line::from(Span::styled("Loading metrics...", Style::default().fg(TEXT_MUTED))), ]) .block(loading_block) .alignment(Alignment::Center); - // Render loading in remaining chunks let remaining = Layout::default() .direction(Direction::Vertical) .constraints([Constraint::Min(1)]) - .split(chunks[2]); + .split(chunks[1]); f.render_widget(loading, remaining[0]); return; } @@ -533,9 +573,9 @@ fn draw_metrics_view(f: &mut Frame, area: Rect, state: &MetricsViewState) { .style(Style::default().bg(BG_DARK)); let empty = Paragraph::new(vec![ Line::from(""), - Line::from(Span::styled("📭 No metrics data available", Style::default().fg(TEXT_MUTED))), + Line::from(Span::styled("No metrics data available", Style::default().fg(TEXT_MUTED))), Line::from(""), - Line::from(Span::styled("Try writing some data to the stream first", Style::default().fg(TEXT_MUTED))), + Line::from(Span::styled("Try writing some data first", Style::default().fg(TEXT_MUTED))), ]) .block(empty_block) .alignment(Alignment::Center); @@ -543,7 +583,7 @@ fn draw_metrics_view(f: &mut Frame, area: Rect, state: &MetricsViewState) { let remaining = Layout::default() .direction(Direction::Vertical) .constraints([Constraint::Min(1)]) - .split(chunks[2]); + .split(chunks[1]); f.render_widget(empty, remaining[0]); return; } @@ -581,30 +621,7 @@ fn draw_metrics_view(f: &mut Frame, area: Rect, state: &MetricsViewState) { // Sort by timestamp all_values.sort_by_key(|(ts, _)| *ts); - // === Sparkline graph === - let sparkline_block = Block::default() - .borders(Borders::ALL) - .border_style(Style::default().fg(BORDER)) - .title(Line::from(vec![ - Span::styled(" ", Style::default()), - Span::styled("Trend", Style::default().fg(TEXT_PRIMARY).bold()), - Span::styled(" ", Style::default()), - ])) - .style(Style::default().bg(BG_DARK)); - - let sparkline_width = chunks[2].width.saturating_sub(4) as usize; - let sparkline = render_sparkline(&all_values, sparkline_width); - - let sparkline_para = Paragraph::new(vec![ - Line::from(""), - Line::from(Span::styled(sparkline, Style::default().fg(GREEN))), - Line::from(""), - ]) - .block(sparkline_block) - .alignment(Alignment::Center); - f.render_widget(sparkline_para, chunks[2]); - - // === Stats summary === + // Calculate stats let values_only: Vec = all_values.iter().map(|(_, v)| *v).collect(); let min_val = values_only.iter().cloned().fold(f64::MAX, f64::min); let max_val = values_only.iter().cloned().fold(f64::MIN, f64::max); @@ -614,99 +631,272 @@ fn draw_metrics_view(f: &mut Frame, area: Rect, state: &MetricsViewState) { 0.0 }; let latest_val = values_only.last().cloned().unwrap_or(0.0); + let first_val = values_only.first().cloned().unwrap_or(0.0); + // Calculate change for trend indicator + let change = if first_val > 0.0 { + ((latest_val - first_val) / first_val) * 100.0 + } else if latest_val > 0.0 { + 100.0 + } else { + 0.0 + }; + + // Time range + let first_ts = all_values.first().map(|(ts, _)| *ts).unwrap_or(0); + let last_ts = all_values.last().map(|(ts, _)| *ts).unwrap_or(0); + + // === Stats header row === let stats_block = Block::default() .borders(Borders::ALL) .border_style(Style::default().fg(BORDER)) + .style(Style::default().bg(BG_PANEL)); + + let stats_inner = stats_block.inner(chunks[1]); + f.render_widget(stats_block, chunks[1]); + + // Trend indicator + let (trend_arrow, trend_color) = if change > 1.0 { + ("^", Color::Rgb(34, 197, 94)) + } else if change < -1.0 { + ("v", Color::Rgb(239, 68, 68)) + } else { + ("=", TEXT_MUTED) + }; + let trend_text = if change.abs() > 0.1 { + format!("{:+.1}%", change) + } else { + "stable".to_string() + }; + + let stats_line = Line::from(vec![ + Span::styled(" NOW ", Style::default().fg(BG_DARK).bg(GREEN).bold()), + Span::styled(format!(" {} ", format_metric_value_f64(latest_val, metric_unit)), Style::default().fg(GREEN).bold()), + Span::styled(trend_arrow, Style::default().fg(trend_color).bold()), + Span::styled(format!("{} ", trend_text), Style::default().fg(trend_color)), + Span::styled(" | ", Style::default().fg(BORDER)), + Span::styled("min ", Style::default().fg(TEXT_MUTED)), + Span::styled(format_metric_value_f64(min_val, metric_unit), Style::default().fg(Color::Rgb(96, 165, 250))), + Span::styled(" max ", Style::default().fg(TEXT_MUTED)), + Span::styled(format_metric_value_f64(max_val, metric_unit), Style::default().fg(Color::Rgb(251, 191, 36))), + Span::styled(" avg ", Style::default().fg(TEXT_MUTED)), + Span::styled(format_metric_value_f64(avg_val, metric_unit), Style::default().fg(Color::Rgb(167, 139, 250))), + Span::styled(format!(" | {} pts", all_values.len()), Style::default().fg(TEXT_MUTED)), + ]); + let stats_para = Paragraph::new(stats_line).alignment(Alignment::Center); + f.render_widget(stats_para, stats_inner); + + // === Main Area Chart === + let chart_block = Block::default() + .borders(Borders::ALL) + .border_style(Style::default().fg(GREEN)) .title(Line::from(vec![ Span::styled(" ", Style::default()), - Span::styled(&metric_name, Style::default().fg(TEXT_PRIMARY).bold()), + Span::styled(&metric_name, Style::default().fg(GREEN).bold()), Span::styled(" ", Style::default()), ])) - .style(Style::default().bg(BG_PANEL)); + .style(Style::default().bg(BG_DARK)); - let stats_lines = vec![ - Line::from(""), - Line::from(vec![ - Span::styled(" Current: ", Style::default().fg(TEXT_MUTED)), - Span::styled(format_metric_value_f64(latest_val, metric_unit), Style::default().fg(GREEN).bold()), - Span::styled(" Min: ", Style::default().fg(TEXT_MUTED)), - Span::styled(format_metric_value_f64(min_val, metric_unit), Style::default().fg(YELLOW)), - Span::styled(" Max: ", Style::default().fg(TEXT_MUTED)), - Span::styled(format_metric_value_f64(max_val, metric_unit), Style::default().fg(YELLOW)), - Span::styled(" Avg: ", Style::default().fg(TEXT_MUTED)), - Span::styled(format_metric_value_f64(avg_val, metric_unit), Style::default().fg(TEXT_SECONDARY)), - ]), - ]; + let chart_inner = chart_block.inner(chunks[2]); + f.render_widget(chart_block, chunks[2]); - let stats_para = Paragraph::new(stats_lines).block(stats_block); - f.render_widget(stats_para, chunks[3]); + // Render the area chart + render_area_chart(f, chart_inner, &all_values, min_val, max_val, metric_unit, first_ts, last_ts); - // === Bar chart === - let chart_block = Block::default() + // === Timeline (scrollable detail) === + let timeline_block = Block::default() .borders(Borders::ALL) .border_style(Style::default().fg(BORDER)) .title(Line::from(vec![ - Span::styled(" ", Style::default()), - Span::styled("Timeline (scroll with j/k)", Style::default().fg(TEXT_MUTED)), - Span::styled(" ", Style::default()), + Span::styled(" Data Points ", Style::default().fg(TEXT_PRIMARY)), + Span::styled(format!("[{}/{}]", state.scroll + 1, all_values.len()), Style::default().fg(TEXT_MUTED)), ])) + .title_bottom(Line::from(Span::styled(" j/k scroll ", Style::default().fg(TEXT_MUTED)))) .style(Style::default().bg(BG_DARK)); - let chart_inner = chart_block.inner(chunks[4]); - f.render_widget(chart_block, chunks[4]); + let timeline_inner = timeline_block.inner(chunks[3]); + f.render_widget(timeline_block, chunks[3]); - // Render bar chart - let chart_width = chart_inner.width.saturating_sub(32) as usize; - let visible_height = chart_inner.height as usize; + // Compact timeline bars + let bar_width = timeline_inner.width.saturating_sub(26) as usize; + let visible_rows = timeline_inner.height as usize; let bars: Vec = all_values .iter() .skip(state.scroll) - .take(visible_height) + .take(visible_rows) .map(|(ts, value)| { let bar_len = if max_val > 0.0 { - ((*value / max_val) * chart_width as f64) as usize + ((*value / max_val) * bar_width as f64) as usize } else { 0 }; - - // Gradient bar with different characters based on value intensity let intensity = if max_val > 0.0 { *value / max_val } else { 0.0 }; - let bar_char = if intensity > 0.8 { "█" } else if intensity > 0.5 { "▓" } else if intensity > 0.2 { "▒" } else { "░" }; - let bar = bar_char.repeat(bar_len); + + // Gradient color based on intensity + let bar_color = intensity_to_color(intensity); + + let bar: String = (0..bar_len).map(|i| { + let pos = i as f64 / bar_len.max(1) as f64; + if pos > 0.9 { '█' } else if pos > 0.7 { '▓' } else if pos > 0.4 { '▒' } else { '░' } + }).collect(); let time_str = format_metric_timestamp_short(*ts); - let bar_color = if intensity > 0.8 { - GREEN - } else if intensity > 0.5 { - Color::Rgb(34, 197, 94) // bright green - } else if intensity > 0.2 { - Color::Rgb(74, 222, 128) // lighter green - } else { - Color::Rgb(134, 239, 172) // very light green - }; Line::from(vec![ - Span::styled(format!(" {:>12} │", time_str), Style::default().fg(TEXT_MUTED)), + Span::styled(format!(" {:>8} ", time_str), Style::default().fg(TEXT_MUTED)), Span::styled(bar, Style::default().fg(bar_color)), - Span::styled(format!(" {}", format_metric_value_f64(*value, metric_unit)), Style::default().fg(TEXT_SECONDARY)), + Span::styled(format!(" {:>10}", format_metric_value_f64(*value, metric_unit)), Style::default().fg(TEXT_SECONDARY)), ]) }) .collect(); let bars_para = Paragraph::new(bars); - f.render_widget(bars_para, chart_inner); + f.render_widget(bars_para, timeline_inner); +} + +/// Convert intensity (0.0-1.0) to a green gradient color +fn intensity_to_color(intensity: f64) -> Color { + if intensity > 0.8 { + Color::Rgb(34, 197, 94) // bright green + } else if intensity > 0.6 { + Color::Rgb(74, 222, 128) + } else if intensity > 0.4 { + Color::Rgb(134, 239, 172) + } else if intensity > 0.2 { + Color::Rgb(187, 247, 208) + } else { + Color::Rgb(220, 252, 231) // pale green + } +} + +/// Render a beautiful area chart with Y-axis, filled area, and X-axis +fn render_area_chart( + f: &mut Frame, + area: Rect, + values: &[(u32, f64)], + min_val: f64, + max_val: f64, + unit: s2_sdk::types::MetricUnit, + first_ts: u32, + last_ts: u32, +) { + let height = area.height.saturating_sub(1) as usize; // Leave room for X-axis + let y_axis_width = 10u16; + let width = area.width.saturating_sub(y_axis_width + 1) as usize; + + if height < 2 || width < 10 { + return; + } + + // Calculate value range with some padding + let chart_min = if min_val > 0.0 { 0.0 } else { min_val }; + let chart_max = max_val * 1.1; // 10% headroom + let chart_range = chart_max - chart_min; + + // Resample values to fit width + let values_only: Vec = values.iter().map(|(_, v)| *v).collect(); + let step = values_only.len() as f64 / width as f64; + + // Build the chart row by row (top to bottom) + let mut lines: Vec = Vec::new(); + + // Block characters for smooth area fill + // Using vertical eighths: ' ', '▁', '▂', '▃', '▄', '▅', '▆', '▇', '█' + let fill_chars = [' ', '▁', '▂', '▃', '▄', '▅', '▆', '▇', '█']; + + for row in 0..height { + let y_frac_top = 1.0 - (row as f64 / height as f64); + let y_frac_bot = 1.0 - ((row + 1) as f64 / height as f64); + + // Y-axis label (only on certain rows) + let y_label: String = if row == 0 { + format!("{:>9} ", format_metric_value_f64(chart_max, unit)) + } else if row == height / 2 { + format!("{:>9} ", format_metric_value_f64((chart_max + chart_min) / 2.0, unit)) + } else if row == height - 1 { + format!("{:>9} ", format_metric_value_f64(chart_min, unit)) + } else { + " ".to_string() + }; + + let mut spans: Vec = vec![ + Span::styled(y_label, Style::default().fg(TEXT_MUTED)), + ]; + + // Draw each column + for col in 0..width { + let idx = ((col as f64) * step) as usize; + let val = values_only.get(idx).cloned().unwrap_or(0.0); + + // Normalize value to chart coordinates + let val_norm = (val - chart_min) / chart_range; + let val_y = val_norm; // 0.0 = bottom, 1.0 = top + + // Determine what character to draw + let char_and_color = if val_y >= y_frac_top { + // Value is above this row - full fill + ('█', intensity_to_color(val_norm)) + } else if val_y > y_frac_bot { + // Value is within this row - partial fill + let fill_frac = (val_y - y_frac_bot) / (y_frac_top - y_frac_bot); + let char_idx = (fill_frac * 8.0) as usize; + (fill_chars[char_idx.min(8)], intensity_to_color(val_norm)) + } else { + // Value is below this row - empty or grid + if col % 10 == 0 { + ('·', Color::Rgb(50, 50, 50)) + } else { + (' ', BG_DARK) + } + }; + + spans.push(Span::styled( + char_and_color.0.to_string(), + Style::default().fg(char_and_color.1), + )); + } + + lines.push(Line::from(spans)); + } + + // X-axis with time labels + let first_time = format_metric_timestamp_short(first_ts); + let last_time = format_metric_timestamp_short(last_ts); + let mid_ts = first_ts + (last_ts - first_ts) / 2; + let mid_time = format_metric_timestamp_short(mid_ts); + + let x_axis_padding = " ".repeat(y_axis_width as usize); + + let mut x_axis_spans = vec![ + Span::styled(&x_axis_padding, Style::default()), + Span::styled(&first_time, Style::default().fg(TEXT_MUTED)), + ]; + + let remaining_after_first = width.saturating_sub(first_time.len() + mid_time.len() / 2); + let padding_to_mid = remaining_after_first / 2; + x_axis_spans.push(Span::styled(" ".repeat(padding_to_mid), Style::default())); + x_axis_spans.push(Span::styled(&mid_time, Style::default().fg(TEXT_MUTED))); + + let remaining_after_mid = width.saturating_sub(first_time.len() + padding_to_mid + mid_time.len() + last_time.len()); + x_axis_spans.push(Span::styled(" ".repeat(remaining_after_mid), Style::default())); + x_axis_spans.push(Span::styled(&last_time, Style::default().fg(TEXT_MUTED))); + + lines.push(Line::from(x_axis_spans)); + + let chart_para = Paragraph::new(lines); + f.render_widget(chart_para, area); } -/// Render a sparkline using Unicode block characters -fn render_sparkline(values: &[(u32, f64)], width: usize) -> String { +/// Render a sparkline with gradient coloring (unused but kept for reference) +#[allow(dead_code)] +fn render_sparkline_gradient(values: &[(u32, f64)], width: usize) -> String { if values.is_empty() { - return "─".repeat(width); + return "-".repeat(width); } // Sparkline characters from lowest to highest - let spark_chars = ['▁', '▂', '▃', '▄', '▅', '▆', '▇', '█']; + let spark_chars = [' ', '▁', '▂', '▃', '▄', '▅', '▆', '▇', '█']; let values_only: Vec = values.iter().map(|(_, v)| *v).collect(); let min_val = values_only.iter().cloned().fold(f64::MAX, f64::min); @@ -722,17 +912,17 @@ fn render_sparkline(values: &[(u32, f64)], width: usize) -> String { let val = values_only.get(idx).cloned().unwrap_or(0.0); let normalized = if range > 0.0 { - ((val - min_val) / range * 7.0) as usize + ((val - min_val) / range).clamp(0.0, 1.0) } else { - 4 // middle if no range + 0.5 }; - sparkline.push(spark_chars[normalized.min(7)]); + let char_idx = (normalized * (spark_chars.len() - 1) as f64) as usize; + sparkline.push(spark_chars[char_idx]); } sparkline } - /// Format timestamp in short form for bar chart fn format_metric_timestamp_short(ts: u32) -> String { use std::time::{Duration, UNIX_EPOCH}; @@ -2076,22 +2266,153 @@ fn draw_input_dialog(f: &mut Frame, mode: &InputMode) { let (title, content, hint) = match mode { InputMode::Normal => return, - InputMode::CreateBasin { input } => ( - " Create Basin ", - vec![ + InputMode::CreateBasin { + name, + create_stream_on_append, + create_stream_on_read, + storage_class, + retention_policy, + retention_age_input, + timestamping_mode, + timestamping_uncapped, + selected, + editing, + } => { + let cursor = |is_editing: bool| if is_editing { "▎" } else { "" }; + let marker = |sel: bool| if sel { "▸ " } else { " " }; + let check = |on: bool| if on { "x" } else { " " }; + + // Storage class display + let storage_str = match storage_class { + None => "default", + Some(StorageClass::Standard) => "Standard", + Some(StorageClass::Express) => "Express", + }; + + // Timestamping mode display + let ts_mode_str = match timestamping_mode { + None => "default", + Some(TimestampingMode::ClientPrefer) => "ClientPrefer", + Some(TimestampingMode::ClientRequire) => "ClientRequire", + Some(TimestampingMode::Arrival) => "Arrival", + }; + + let name_valid = name.len() >= 8 && name.len() <= 48; + let name_color = if name_valid { GREEN } else if name.is_empty() { TEXT_MUTED } else { ERROR }; + + let mut lines = vec![ Line::from(""), + // Row 0: Name Line::from(vec![ + Span::styled(marker(*selected == 0), Style::default().fg(GREEN)), Span::styled("Name: ", Style::default().fg(TEXT_MUTED)), - Span::styled(input, Style::default().fg(TEXT_PRIMARY)), - Span::styled("_", Style::default().fg(GREEN)), + Span::styled(name, Style::default().fg(name_color)), + Span::styled( + if *selected == 0 && *editing { cursor(true) } else { "" }, + Style::default().fg(GREEN) + ), + ]), + Line::from(vec![ + Span::styled(" ", Style::default()), + Span::styled( + if name.is_empty() { + "8-48 chars: lowercase, numbers, hyphens".to_string() + } else { + format!("{}/48 chars", name.len()) + }, + Style::default().fg(TEXT_MUTED) + ), ]), Line::from(""), Line::from(vec![ - Span::styled("8-48 chars: lowercase, numbers, hyphens", Style::default().fg(TEXT_MUTED)), + Span::styled(" -- Default Stream Config --", Style::default().fg(TEXT_MUTED)), ]), - ], - "enter confirm esc cancel", - ), + Line::from(""), + // Row 1: Storage Class + Line::from(vec![ + Span::styled(marker(*selected == 1), Style::default().fg(GREEN)), + Span::styled("Storage Class: ", Style::default().fg(TEXT_MUTED)), + Span::styled(format!("< {} >", storage_str), Style::default().fg(YELLOW)), + ]), + // Row 2: Retention Policy + Line::from(vec![ + Span::styled(marker(*selected == 2), Style::default().fg(GREEN)), + Span::styled("Retention: ", Style::default().fg(TEXT_MUTED)), + Span::styled( + format!("< {} >", if *retention_policy == RetentionPolicyOption::Infinite { "Infinite" } else { "Age" }), + Style::default().fg(YELLOW) + ), + ]), + ]; + + // Row 3: Retention Age (only if Age policy) + if *retention_policy == RetentionPolicyOption::Age { + lines.push(Line::from(vec![ + Span::styled(marker(*selected == 3), Style::default().fg(GREEN)), + Span::styled(" Age: ", Style::default().fg(TEXT_MUTED)), + Span::styled(retention_age_input, Style::default().fg(TEXT_PRIMARY)), + Span::styled( + if *selected == 3 && *editing { cursor(true) } else { "" }, + Style::default().fg(GREEN) + ), + Span::styled(" (e.g. 7d, 30d, 1y)", Style::default().fg(TEXT_MUTED)), + ])); + } + + // Row 4: Timestamping Mode + lines.push(Line::from(vec![ + Span::styled(marker(*selected == 4), Style::default().fg(GREEN)), + Span::styled("Timestamping: ", Style::default().fg(TEXT_MUTED)), + Span::styled(format!("< {} >", ts_mode_str), Style::default().fg(YELLOW)), + ])); + + // Row 5: Timestamping Uncapped + lines.push(Line::from(vec![ + Span::styled(marker(*selected == 5), Style::default().fg(GREEN)), + Span::styled("Uncapped Timestamps: ", Style::default().fg(TEXT_MUTED)), + Span::styled(format!("[{}]", check(*timestamping_uncapped)), Style::default().fg(if *timestamping_uncapped { GREEN } else { TEXT_MUTED })), + ])); + + lines.push(Line::from("")); + lines.push(Line::from(vec![ + Span::styled(" -- Basin Behavior --", Style::default().fg(TEXT_MUTED)), + ])); + lines.push(Line::from("")); + + // Row 6: Create Stream On Append + lines.push(Line::from(vec![ + Span::styled(marker(*selected == 6), Style::default().fg(GREEN)), + Span::styled("Auto-create on Append: ", Style::default().fg(TEXT_MUTED)), + Span::styled(format!("[{}]", check(*create_stream_on_append)), Style::default().fg(if *create_stream_on_append { GREEN } else { TEXT_MUTED })), + ])); + + // Row 7: Create Stream On Read + lines.push(Line::from(vec![ + Span::styled(marker(*selected == 7), Style::default().fg(GREEN)), + Span::styled("Auto-create on Read: ", Style::default().fg(TEXT_MUTED)), + Span::styled(format!("[{}]", check(*create_stream_on_read)), Style::default().fg(if *create_stream_on_read { GREEN } else { TEXT_MUTED })), + ])); + + lines.push(Line::from("")); + + // Row 8: Create button + let can_create = name_valid; + let (btn_fg, btn_bg) = if *selected == 8 && can_create { + (BG_DARK, GREEN) + } else { + (if can_create { GREEN } else { TEXT_MUTED }, BG_PANEL) + }; + lines.push(Line::from(vec![ + Span::styled(marker(*selected == 8), Style::default().fg(GREEN)), + Span::styled(" CREATE BASIN ", Style::default().fg(btn_fg).bg(btn_bg).bold()), + ])); + + ( + " Create Basin ", + lines, + "jk nav hl cycle space toggle Enter edit/submit esc", + ) + } InputMode::CreateStream { basin, input } => ( " Create Stream ", From 7ed7579ecac14ee2f4c6180dc9c63b9ca5387354 Mon Sep 17 00:00:00 2001 From: Mehul Arora Date: Fri, 23 Jan 2026 21:50:35 -0500 Subject: [PATCH 08/31] . --- src/tui/app.rs | 222 +++++++++++++++++++++++++++++++++++++------ src/tui/event.rs | 3 + src/tui/ui.rs | 238 ++++++++++++++++++++++++++++++++++++++--------- 3 files changed, 390 insertions(+), 73 deletions(-) diff --git a/src/tui/app.rs b/src/tui/app.rs index 6cefb5e..c88bd47 100644 --- a/src/tui/app.rs +++ b/src/tui/app.rs @@ -12,7 +12,7 @@ use crate::cli::{CreateBasinArgs, CreateStreamArgs, IssueAccessTokenArgs, ListAc use crate::error::CliError; use crate::ops; use crate::record_format::{RecordFormat, RecordsOut}; -use crate::types::{BasinConfig, Operation, RetentionPolicy, S2BasinAndMaybeStreamUri, S2BasinAndStreamUri, S2BasinUri, StorageClass, StreamConfig, TimestampingConfig, TimestampingMode}; +use crate::types::{BasinConfig, DeleteOnEmptyConfig, Operation, RetentionPolicy, S2BasinAndMaybeStreamUri, S2BasinAndStreamUri, S2BasinUri, StorageClass, StreamConfig, TimestampingConfig, TimestampingMode}; use super::event::{BasinConfigInfo, Event, StreamConfigInfo}; use super::ui; @@ -130,11 +130,12 @@ pub struct AccessTokensState { /// Type of metrics being viewed #[derive(Debug, Clone)] pub enum MetricsType { + Account, Basin { basin_name: BasinName }, Stream { basin_name: BasinName, stream_name: StreamName }, } -/// Which metric is currently selected +/// Which metric is currently selected (for basin/stream) #[derive(Debug, Clone, Copy, PartialEq, Eq, Default)] pub enum MetricCategory { #[default] @@ -143,6 +144,9 @@ pub enum MetricCategory { ReadOps, AppendThroughput, ReadThroughput, + // Account-level metrics + ActiveBasins, + AccountOps, } impl MetricCategory { @@ -153,6 +157,9 @@ impl MetricCategory { Self::ReadOps => Self::AppendThroughput, Self::AppendThroughput => Self::ReadThroughput, Self::ReadThroughput => Self::Storage, + // Account metrics cycle + Self::ActiveBasins => Self::AccountOps, + Self::AccountOps => Self::ActiveBasins, } } @@ -163,6 +170,9 @@ impl MetricCategory { Self::ReadOps => Self::AppendOps, Self::AppendThroughput => Self::ReadOps, Self::ReadThroughput => Self::AppendThroughput, + // Account metrics cycle + Self::ActiveBasins => Self::AccountOps, + Self::AccountOps => Self::ActiveBasins, } } @@ -173,6 +183,8 @@ impl MetricCategory { Self::ReadOps => "Read Ops", Self::AppendThroughput => "Append Throughput", Self::ReadThroughput => "Read Throughput", + Self::ActiveBasins => "Active Basins", + Self::AccountOps => "Account Ops", } } } @@ -219,6 +231,9 @@ pub enum InputMode { retention_age_input: String, timestamping_mode: Option, timestamping_uncapped: bool, + // Delete-on-empty config + delete_on_empty_enabled: bool, + delete_on_empty_min_age: String, // UI state selected: usize, editing: bool, @@ -582,6 +597,8 @@ fn build_basin_config( retention_age_input: String, timestamping_mode: Option, timestamping_uncapped: bool, + delete_on_empty_enabled: bool, + delete_on_empty_min_age: String, ) -> BasinConfig { // Parse retention policy let retention = match retention_policy { @@ -603,12 +620,21 @@ fn build_basin_config( None }; + // Build delete-on-empty config if enabled + let delete_on_empty = if delete_on_empty_enabled { + humantime::parse_duration(&delete_on_empty_min_age) + .ok() + .map(|d| DeleteOnEmptyConfig { delete_on_empty_min_age: d }) + } else { + None + }; + BasinConfig { default_stream_config: StreamConfig { storage_class, retention_policy: retention, timestamping, - delete_on_empty: None, + delete_on_empty, }, create_stream_on_append, create_stream_on_read, @@ -1168,6 +1194,23 @@ impl App { } } + Event::AccountMetricsLoaded(result) => { + if let Screen::MetricsView(state) = &mut self.screen { + state.loading = false; + match result { + Ok(metrics) => { + state.metrics = metrics; + } + Err(e) => { + self.message = Some(StatusMessage { + text: format!("Failed to load account metrics: {e}"), + level: MessageLevel::Error, + }); + } + } + } + } + Event::BasinMetricsLoaded(result) => { if let Screen::MetricsView(state) = &mut self.screen { state.loading = false; @@ -1355,6 +1398,8 @@ impl App { retention_age_input, timestamping_mode, timestamping_uncapped, + delete_on_empty_enabled, + delete_on_empty_min_age, selected, editing, } => { @@ -1365,10 +1410,12 @@ impl App { // 3: Retention Age (text, only if Age) // 4: Timestamping Mode (cycle: None/ClientPrefer/ClientRequire/Arrival) // 5: Timestamping Uncapped (toggle) - // 6: Create Stream On Append (toggle) - // 7: Create Stream On Read (toggle) - // 8: Create button - const FIELD_COUNT: usize = 9; + // 6: Delete-on-empty (toggle) + // 7: Delete-on-empty Min Age (text, only if enabled) + // 8: Create Stream On Append (toggle) + // 9: Create Stream On Read (toggle) + // 10: Create button + const FIELD_COUNT: usize = 11; if *editing { // Text editing mode @@ -1381,6 +1428,8 @@ impl App { name.pop(); } else if *selected == 3 { retention_age_input.pop(); + } else if *selected == 7 { + delete_on_empty_min_age.pop(); } } KeyCode::Char(c) => { @@ -1394,6 +1443,11 @@ impl App { if c.is_ascii_alphanumeric() { retention_age_input.push(c); } + } else if *selected == 7 { + // Delete-on-empty min age: alphanumeric for duration parsing + if c.is_ascii_alphanumeric() { + delete_on_empty_min_age.push(c); + } } } _ => {} @@ -1406,6 +1460,10 @@ impl App { KeyCode::Up | KeyCode::Char('k') => { if *selected > 0 { *selected -= 1; + // Skip delete-on-empty min age if not enabled + if *selected == 7 && !*delete_on_empty_enabled { + *selected = 6; + } // Skip retention age if not using Age policy if *selected == 3 && *retention_policy != RetentionPolicyOption::Age { *selected = 2; @@ -1419,6 +1477,10 @@ impl App { if *selected == 3 && *retention_policy != RetentionPolicyOption::Age { *selected = 4; } + // Skip delete-on-empty min age if not enabled + if *selected == 7 && !*delete_on_empty_enabled { + *selected = 8; + } } } KeyCode::Enter => { @@ -1429,7 +1491,12 @@ impl App { *editing = true; // Edit retention age } } - 8 => { + 7 => { + if *delete_on_empty_enabled { + *editing = true; // Edit delete-on-empty min age + } + } + 10 => { // Create button - validate and submit if name.len() >= 8 { // Extract all values to avoid borrow conflict @@ -1441,8 +1508,10 @@ impl App { let rai = retention_age_input.clone(); let tm = timestamping_mode.clone(); let tu = *timestamping_uncapped; + let doe = *delete_on_empty_enabled; + let doema = delete_on_empty_min_age.clone(); - let config = build_basin_config(csoa, csor, sc, rp, rai, tm, tu); + let config = build_basin_config(csoa, csor, sc, rp, rai, tm, tu, doe, doema); self.create_basin_with_config(basin_name, config, tx.clone()); } } @@ -1453,8 +1522,9 @@ impl App { // Toggle for boolean fields match *selected { 5 => *timestamping_uncapped = !*timestamping_uncapped, - 6 => *create_stream_on_append = !*create_stream_on_append, - 7 => *create_stream_on_read = !*create_stream_on_read, + 6 => *delete_on_empty_enabled = !*delete_on_empty_enabled, + 8 => *create_stream_on_append = !*create_stream_on_append, + 9 => *create_stream_on_read = !*create_stream_on_read, _ => {} } } @@ -2339,6 +2409,8 @@ impl App { retention_age_input: "7d".to_string(), timestamping_mode: None, timestamping_uncapped: false, + delete_on_empty_enabled: false, + delete_on_empty_min_age: "7d".to_string(), selected: 0, editing: false, }; @@ -2377,6 +2449,10 @@ impl App { self.open_basin_metrics(basin_name, tx); } } + KeyCode::Char('A') => { + // Account Metrics + self.open_account_metrics(tx); + } KeyCode::Esc => { if !state.filter.is_empty() { state.filter.clear(); @@ -4223,6 +4299,18 @@ impl App { } /// Open basin metrics view + /// Open account metrics view + fn open_account_metrics(&mut self, tx: mpsc::UnboundedSender) { + self.screen = Screen::MetricsView(MetricsViewState { + metrics_type: MetricsType::Account, + metrics: Vec::new(), + selected_category: MetricCategory::ActiveBasins, + loading: true, + scroll: 0, + }); + self.load_account_metrics(MetricCategory::ActiveBasins, tx); + } + fn open_basin_metrics(&mut self, basin_name: BasinName, tx: mpsc::UnboundedSender) { self.screen = Screen::MetricsView(MetricsViewState { metrics_type: MetricsType::Basin { basin_name: basin_name.clone() }, @@ -4247,6 +4335,43 @@ impl App { } /// Load basin metrics + /// Load account metrics + fn load_account_metrics(&self, category: MetricCategory, tx: mpsc::UnboundedSender) { + use s2_sdk::types::AccountMetricSet; + + let s2 = self.s2.clone(); + + tokio::spawn(async move { + // Get metrics for last 24 hours + let now = std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .unwrap_or_default() + .as_secs() as u32; + let day_ago = now.saturating_sub(24 * 60 * 60); + + let set = match category { + MetricCategory::ActiveBasins => AccountMetricSet::ActiveBasins(TimeRange::new(day_ago, now)), + MetricCategory::AccountOps => AccountMetricSet::AccountOps( + s2_sdk::types::TimeRangeAndInterval::new(day_ago, now) + ), + _ => return, // Other categories not valid for account + }; + + let input = s2_sdk::types::GetAccountMetricsInput::new(set); + match s2.get_account_metrics(input).await { + Ok(metrics) => { + let _ = tx.send(Event::AccountMetricsLoaded(Ok(metrics))); + } + Err(e) => { + let _ = tx.send(Event::AccountMetricsLoaded(Err(CliError::op( + crate::error::OpKind::GetAccountMetrics, + e, + )))); + } + } + }); + } + fn load_basin_metrics(&self, basin_name: BasinName, category: MetricCategory, tx: mpsc::UnboundedSender) { let s2 = self.s2.clone(); @@ -4272,6 +4397,7 @@ impl App { MetricCategory::ReadThroughput => BasinMetricSet::ReadThroughput( s2_sdk::types::TimeRangeAndInterval::new(day_ago, now) ), + _ => return, // Account metrics not valid for basin }; let input = s2_sdk::types::GetBasinMetricsInput::new(basin_name, set); @@ -4332,6 +4458,17 @@ impl App { KeyCode::Esc | KeyCode::Char('q') => { // Go back to previous screen match &metrics_type { + MetricsType::Account => { + // Go back to basins list + self.screen = Screen::Basins(BasinsState { + basins: Vec::new(), + selected: 0, + loading: true, + filter: String::new(), + filter_active: false, + }); + self.load_basins(tx); + } MetricsType::Basin { basin_name } => { let basin_name = basin_name.clone(); self.screen = Screen::Streams(StreamsState { @@ -4360,29 +4497,53 @@ impl App { } } KeyCode::Left | KeyCode::Char('h') => { - // Previous metric category (only for basin metrics) - if let MetricsType::Basin { basin_name } = &metrics_type { - let basin_name = basin_name.clone(); - let new_category = selected_category.prev(); - if let Screen::MetricsView(state) = &mut self.screen { - state.selected_category = new_category; - state.loading = true; - state.metrics.clear(); + // Previous metric category (for basin or account metrics) + match &metrics_type { + MetricsType::Account => { + let new_category = selected_category.prev(); + if let Screen::MetricsView(state) = &mut self.screen { + state.selected_category = new_category; + state.loading = true; + state.metrics.clear(); + } + self.load_account_metrics(new_category, tx); + } + MetricsType::Basin { basin_name } => { + let basin_name = basin_name.clone(); + let new_category = selected_category.prev(); + if let Screen::MetricsView(state) = &mut self.screen { + state.selected_category = new_category; + state.loading = true; + state.metrics.clear(); + } + self.load_basin_metrics(basin_name, new_category, tx); } - self.load_basin_metrics(basin_name, new_category, tx); + MetricsType::Stream { .. } => {} // No category switching for stream } } KeyCode::Right | KeyCode::Char('l') => { - // Next metric category (only for basin metrics) - if let MetricsType::Basin { basin_name } = &metrics_type { - let basin_name = basin_name.clone(); - let new_category = selected_category.next(); - if let Screen::MetricsView(state) = &mut self.screen { - state.selected_category = new_category; - state.loading = true; - state.metrics.clear(); + // Next metric category (for basin or account metrics) + match &metrics_type { + MetricsType::Account => { + let new_category = selected_category.next(); + if let Screen::MetricsView(state) = &mut self.screen { + state.selected_category = new_category; + state.loading = true; + state.metrics.clear(); + } + self.load_account_metrics(new_category, tx); + } + MetricsType::Basin { basin_name } => { + let basin_name = basin_name.clone(); + let new_category = selected_category.next(); + if let Screen::MetricsView(state) = &mut self.screen { + state.selected_category = new_category; + state.loading = true; + state.metrics.clear(); + } + self.load_basin_metrics(basin_name, new_category, tx); } - self.load_basin_metrics(basin_name, new_category, tx); + MetricsType::Stream { .. } => {} // No category switching for stream } } KeyCode::Up | KeyCode::Char('k') => { @@ -4404,6 +4565,9 @@ impl App { state.metrics.clear(); } match &metrics_type { + MetricsType::Account => { + self.load_account_metrics(selected_category, tx); + } MetricsType::Basin { basin_name } => { self.load_basin_metrics(basin_name.clone(), selected_category, tx); } diff --git a/src/tui/event.rs b/src/tui/event.rs index 4c03eb6..1d335a1 100644 --- a/src/tui/event.rs +++ b/src/tui/event.rs @@ -87,6 +87,9 @@ pub enum Event { /// Access token revoked successfully (token id) AccessTokenRevoked(Result), + /// Account metrics loaded + AccountMetricsLoaded(Result, CliError>), + /// Basin metrics loaded BasinMetricsLoaded(Result, CliError>), diff --git a/src/tui/ui.rs b/src/tui/ui.rs index 18a686b..78d665b 100644 --- a/src/tui/ui.rs +++ b/src/tui/ui.rs @@ -487,11 +487,47 @@ fn draw_metrics_view(f: &mut Frame, area: Rect, state: &MetricsViewState) { // === Title with integrated category tabs === let title = match &state.metrics_type { + MetricsType::Account => "Account".to_string(), MetricsType::Basin { basin_name } => basin_name.to_string(), MetricsType::Stream { basin_name, stream_name } => format!("{}/{}", basin_name, stream_name), }; - if matches!(state.metrics_type, MetricsType::Basin { .. }) { + if matches!(state.metrics_type, MetricsType::Account) { + // Account metrics have different categories + let categories = [ + MetricCategory::ActiveBasins, + MetricCategory::AccountOps, + ]; + + let mut title_spans: Vec = vec![ + Span::styled(" [ ", Style::default().fg(BORDER)), + Span::styled(&title, Style::default().fg(GREEN).bold()), + Span::styled(" ] ", Style::default().fg(BORDER)), + ]; + + for (i, cat) in categories.iter().enumerate() { + if i > 0 { + title_spans.push(Span::styled(" | ", Style::default().fg(BORDER))); + } + let style = if *cat == state.selected_category { + Style::default().fg(BG_DARK).bg(GREEN).bold() + } else { + Style::default().fg(TEXT_MUTED) + }; + title_spans.push(Span::styled(format!(" {} ", cat.as_str()), style)); + } + + let title_block = Block::default() + .borders(Borders::ALL) + .border_style(Style::default().fg(GREEN)) + .title_bottom(Line::from(Span::styled(" ←/→ switch category ", Style::default().fg(TEXT_MUTED)))) + .style(Style::default().bg(BG_PANEL)); + + let title_para = Paragraph::new(Line::from(title_spans)) + .block(title_block) + .alignment(Alignment::Center); + f.render_widget(title_para, chunks[0]); + } else if matches!(state.metrics_type, MetricsType::Basin { .. }) { let categories = [ MetricCategory::Storage, MetricCategory::AppendOps, @@ -1933,40 +1969,10 @@ fn draw_append_view(f: &mut Frame, area: Rect, state: &AppendViewState) { } fn draw_status_bar(f: &mut Frame, area: Rect, app: &App) { - let hints = match &app.screen { - Screen::Splash => "", // Never shown - Screen::Basins(_) => "/ filter | jk nav | ⏎ open | M metrics | c new | e cfg | d del | r ref | ?", - Screen::Streams(_) => "/ filter | jk nav | ⏎ open | M metrics | c new | e cfg | d del | esc", - Screen::StreamDetail(_) => "t tail | r read | a append | f fence | m trim | M metrics | e cfg | esc", - Screen::ReadView(s) => { - if s.show_detail { - "esc/⏎ close" - } else if s.is_tailing { - "jk nav | h headers | ⇥ list | space pause | gG top/bot | esc" - } else { - "jk nav | h headers | ⇥ list | gG top/bot | esc" - } - } - Screen::AppendView(s) => { - if s.editing { - if s.selected == 1 { - "type | ⇥ key/val | ⏎ add | esc done" - } else { - "type | ⏎ done | esc cancel" - } - } else { - "jk nav | ⏎ edit/send | d del header | esc back" - } - } - Screen::AccessTokens(_) => "/ filter | jk nav | c issue | d revoke | r ref | ⇥ switch | ? | q", - Screen::MetricsView(state) => { - if matches!(state.metrics_type, MetricsType::Basin { .. }) { - "←→ category | jk scroll | r refresh | esc back | q quit" - } else { - "jk scroll | r refresh | esc back | q quit" - } - } - }; + let width = area.width as usize; + + // Get hints based on available width - full, medium, or compact + let hints = get_responsive_hints(&app.screen, width); let message_span = app .message @@ -1980,20 +1986,129 @@ fn draw_status_bar(f: &mut Frame, area: Rect, app: &App) { Span::styled(&m.text, Style::default().fg(color)) }); + // Calculate available width for hints after message + let msg_len = app.message.as_ref().map(|m| m.text.len() + 2).unwrap_or(0); + let available = width.saturating_sub(msg_len); + + // Truncate hints if needed + let display_hints: String = if hints.len() > available && available > 3 { + format!("{}...", &hints[..available.saturating_sub(3)]) + } else { + hints + }; + let line = if let Some(msg) = message_span { Line::from(vec![ msg, Span::styled(" ", Style::default()), - Span::styled(hints, Style::default().fg(TEXT_MUTED)), + Span::styled(display_hints, Style::default().fg(TEXT_MUTED)), ]) } else { - Line::from(Span::styled(hints, Style::default().fg(TEXT_MUTED))) + Line::from(Span::styled(display_hints, Style::default().fg(TEXT_MUTED))) }; let status = Paragraph::new(line); f.render_widget(status, area); } +/// Get responsive hints based on screen width +fn get_responsive_hints(screen: &Screen, width: usize) -> String { + // Width thresholds + let wide = width >= 100; + let medium = width >= 60; + + match screen { + Screen::Splash => String::new(), + Screen::Basins(_) => { + if wide { + "/ filter | jk nav | ⏎ open | M basin metrics | A acct metrics | c new | e cfg | d del | r ref | ?".to_string() + } else if medium { + "/ | jk ⏎ | M basin | A acct | c d e r ?".to_string() + } else { + "jk ⏎ M A c d ?".to_string() + } + } + Screen::Streams(_) => { + if wide { + "/ filter | jk nav | ⏎ open | M metrics | c new | e cfg | d del | esc".to_string() + } else if medium { + "/ filter | jk nav | ⏎ open | M c e d | esc".to_string() + } else { + "jk ⏎ M c d esc".to_string() + } + } + Screen::StreamDetail(_) => { + if wide { + "t tail | r read | a append | f fence | m trim | M metrics | e cfg | esc".to_string() + } else if medium { + "t tail | r read | a append | f m M e | esc".to_string() + } else { + "t r a f m M esc".to_string() + } + } + Screen::ReadView(s) => { + if s.show_detail { + "esc/⏎ close".to_string() + } else if s.is_tailing { + if wide { + "jk nav | h headers | ⇥ list | space pause | gG top/bot | esc".to_string() + } else if medium { + "jk nav | h hdrs | ⇥ | space | gG | esc".to_string() + } else { + "jk h ⇥ space gG esc".to_string() + } + } else { + if wide { + "jk nav | h headers | ⇥ list | gG top/bot | esc".to_string() + } else if medium { + "jk nav | h hdrs | ⇥ | gG | esc".to_string() + } else { + "jk h ⇥ gG esc".to_string() + } + } + } + Screen::AppendView(s) => { + if s.editing { + if s.selected == 1 { + "type | ⇥ key/val | ⏎ add | esc done".to_string() + } else { + "type | ⏎ done | esc cancel".to_string() + } + } else { + if wide { + "jk nav | ⏎ edit/send | d del header | esc back".to_string() + } else { + "jk ⏎ edit | d del | esc".to_string() + } + } + } + Screen::AccessTokens(_) => { + if wide { + "/ filter | jk nav | c issue | d revoke | r ref | ⇥ switch | ? | q".to_string() + } else if medium { + "/ | jk | c issue | d rev | r | ⇥ | ? q".to_string() + } else { + "jk c d r ⇥ ? q".to_string() + } + } + Screen::MetricsView(state) => { + if matches!(state.metrics_type, MetricsType::Basin { .. } | MetricsType::Account) { + if wide { + "←→ category | jk scroll | r refresh | esc back | q quit".to_string() + } else { + "←→ cat | jk | r | esc q".to_string() + } + } else { + if wide { + "jk scroll | r refresh | esc back | q quit".to_string() + } else { + "jk | r | esc q".to_string() + } + } + } + } +} + fn draw_help_overlay(f: &mut Frame, screen: &Screen) { let area = centered_rect(50, 50, f.area()); @@ -2033,6 +2148,14 @@ fn draw_help_overlay(f: &mut Frame, screen: &Screen) { Span::styled(" r ", Style::default().fg(GREEN).bold()), Span::styled("Refresh", Style::default().fg(TEXT_SECONDARY)), ]), + Line::from(vec![ + Span::styled(" M ", Style::default().fg(GREEN).bold()), + Span::styled("Basin metrics", Style::default().fg(TEXT_SECONDARY)), + ]), + Line::from(vec![ + Span::styled(" A ", Style::default().fg(GREEN).bold()), + Span::styled("Account metrics", Style::default().fg(TEXT_SECONDARY)), + ]), Line::from(vec![ Span::styled(" tab ", Style::default().fg(GREEN).bold()), Span::styled("Switch to Access Tokens", Style::default().fg(TEXT_SECONDARY)), @@ -2113,6 +2236,10 @@ fn draw_help_overlay(f: &mut Frame, screen: &Screen) { Span::styled(" e ", Style::default().fg(GREEN).bold()), Span::styled("Reconfigure stream", Style::default().fg(TEXT_SECONDARY)), ]), + Line::from(vec![ + Span::styled(" M ", Style::default().fg(GREEN).bold()), + Span::styled("Stream metrics", Style::default().fg(TEXT_SECONDARY)), + ]), Line::from(vec![ Span::styled(" esc ", Style::default().fg(GREEN).bold()), Span::styled("Back", Style::default().fg(TEXT_SECONDARY)), @@ -2275,6 +2402,8 @@ fn draw_input_dialog(f: &mut Frame, mode: &InputMode) { retention_age_input, timestamping_mode, timestamping_uncapped, + delete_on_empty_enabled, + delete_on_empty_min_age, selected, editing, } => { @@ -2373,37 +2502,58 @@ fn draw_input_dialog(f: &mut Frame, mode: &InputMode) { Span::styled(format!("[{}]", check(*timestamping_uncapped)), Style::default().fg(if *timestamping_uncapped { GREEN } else { TEXT_MUTED })), ])); + // Row 6: Delete-on-empty toggle + lines.push(Line::from(vec![ + Span::styled(marker(*selected == 6), Style::default().fg(GREEN)), + Span::styled("Delete-on-empty: ", Style::default().fg(TEXT_MUTED)), + Span::styled(format!("[{}]", check(*delete_on_empty_enabled)), Style::default().fg(if *delete_on_empty_enabled { GREEN } else { TEXT_MUTED })), + ])); + + // Row 7: Delete-on-empty Min Age (only if enabled) + if *delete_on_empty_enabled { + lines.push(Line::from(vec![ + Span::styled(marker(*selected == 7), Style::default().fg(GREEN)), + Span::styled(" Min Age: ", Style::default().fg(TEXT_MUTED)), + Span::styled(delete_on_empty_min_age, Style::default().fg(TEXT_PRIMARY)), + Span::styled( + if *selected == 7 && *editing { cursor(true) } else { "" }, + Style::default().fg(GREEN) + ), + Span::styled(" (e.g. 7d, 1h)", Style::default().fg(TEXT_MUTED)), + ])); + } + lines.push(Line::from("")); lines.push(Line::from(vec![ Span::styled(" -- Basin Behavior --", Style::default().fg(TEXT_MUTED)), ])); lines.push(Line::from("")); - // Row 6: Create Stream On Append + // Row 8: Create Stream On Append lines.push(Line::from(vec![ - Span::styled(marker(*selected == 6), Style::default().fg(GREEN)), + Span::styled(marker(*selected == 8), Style::default().fg(GREEN)), Span::styled("Auto-create on Append: ", Style::default().fg(TEXT_MUTED)), Span::styled(format!("[{}]", check(*create_stream_on_append)), Style::default().fg(if *create_stream_on_append { GREEN } else { TEXT_MUTED })), ])); - // Row 7: Create Stream On Read + // Row 9: Create Stream On Read lines.push(Line::from(vec![ - Span::styled(marker(*selected == 7), Style::default().fg(GREEN)), + Span::styled(marker(*selected == 9), Style::default().fg(GREEN)), Span::styled("Auto-create on Read: ", Style::default().fg(TEXT_MUTED)), Span::styled(format!("[{}]", check(*create_stream_on_read)), Style::default().fg(if *create_stream_on_read { GREEN } else { TEXT_MUTED })), ])); lines.push(Line::from("")); - // Row 8: Create button + // Row 10: Create button let can_create = name_valid; - let (btn_fg, btn_bg) = if *selected == 8 && can_create { + let (btn_fg, btn_bg) = if *selected == 10 && can_create { (BG_DARK, GREEN) } else { (if can_create { GREEN } else { TEXT_MUTED }, BG_PANEL) }; lines.push(Line::from(vec![ - Span::styled(marker(*selected == 8), Style::default().fg(GREEN)), + Span::styled(marker(*selected == 10), Style::default().fg(GREEN)), Span::styled(" CREATE BASIN ", Style::default().fg(btn_fg).bg(btn_bg).bold()), ])); From e50530f9c698d92a260d185f744d7e9052926e3f Mon Sep 17 00:00:00 2001 From: Mehul Arora Date: Fri, 23 Jan 2026 23:16:00 -0500 Subject: [PATCH 09/31] . --- src/tui/app.rs | 113 ++++++++++------ src/tui/ui.rs | 353 ++++++++++++++++++++++++++++++++++--------------- 2 files changed, 315 insertions(+), 151 deletions(-) diff --git a/src/tui/app.rs b/src/tui/app.rs index c88bd47..5e80683 100644 --- a/src/tui/app.rs +++ b/src/tui/app.rs @@ -8,7 +8,7 @@ use ratatui::{Terminal, prelude::Backend}; use s2_sdk::types::{AccessTokenId, AccessTokenInfo, BasinInfo, BasinMetricSet, BasinName, StreamInfo, StreamMetricSet, StreamName, StreamPosition, TimeRange}; use tokio::sync::mpsc; -use crate::cli::{CreateBasinArgs, CreateStreamArgs, IssueAccessTokenArgs, ListAccessTokensArgs, ListBasinsArgs, ListStreamsArgs, ReadArgs, ReconfigureBasinArgs, ReconfigureStreamArgs}; +use crate::cli::{CreateStreamArgs, IssueAccessTokenArgs, ListAccessTokensArgs, ListBasinsArgs, ListStreamsArgs, ReadArgs, ReconfigureBasinArgs, ReconfigureStreamArgs}; use crate::error::CliError; use crate::ops; use crate::record_format::{RecordFormat, RecordsOut}; @@ -222,6 +222,8 @@ pub enum InputMode { /// Creating a new basin CreateBasin { name: String, + // Basin scope (cloud provider/region) + scope: BasinScopeOption, // Basin-level settings create_stream_on_append: bool, create_stream_on_read: bool, @@ -363,6 +365,13 @@ impl Default for RetentionPolicyOption { } } +/// Basin scope option for UI (cloud provider/region) +#[derive(Debug, Clone, Copy, PartialEq, Default)] +pub enum BasinScopeOption { + #[default] + AwsUsEast1, +} + /// Expiry options for access tokens #[derive(Debug, Clone, Copy, PartialEq, Default)] pub enum ExpiryOption { @@ -1391,6 +1400,7 @@ impl App { InputMode::CreateBasin { name, + scope, create_stream_on_append, create_stream_on_read, storage_class, @@ -1405,17 +1415,18 @@ impl App { } => { // Form fields: // 0: Name (text) - // 1: Storage Class (cycle: None/Standard/Express) - // 2: Retention Policy (cycle: Infinite/Age) - // 3: Retention Age (text, only if Age) - // 4: Timestamping Mode (cycle: None/ClientPrefer/ClientRequire/Arrival) - // 5: Timestamping Uncapped (toggle) - // 6: Delete-on-empty (toggle) - // 7: Delete-on-empty Min Age (text, only if enabled) - // 8: Create Stream On Append (toggle) - // 9: Create Stream On Read (toggle) - // 10: Create button - const FIELD_COUNT: usize = 11; + // 1: Scope (cycle: AWS us-east-1) + // 2: Storage Class (cycle: None/Standard/Express) + // 3: Retention Policy (cycle: Infinite/Age) + // 4: Retention Age (text, only if Age) + // 5: Timestamping Mode (cycle: None/ClientPrefer/ClientRequire/Arrival) + // 6: Timestamping Uncapped (toggle) + // 7: Delete-on-empty (toggle) + // 8: Delete-on-empty Min Age (text, only if enabled) + // 9: Create Stream On Append (toggle) + // 10: Create Stream On Read (toggle) + // 11: Create button + const FIELD_COUNT: usize = 12; if *editing { // Text editing mode @@ -1426,9 +1437,9 @@ impl App { KeyCode::Backspace => { if *selected == 0 { name.pop(); - } else if *selected == 3 { + } else if *selected == 4 { retention_age_input.pop(); - } else if *selected == 7 { + } else if *selected == 8 { delete_on_empty_min_age.pop(); } } @@ -1438,12 +1449,12 @@ impl App { if c.is_ascii_lowercase() || c.is_ascii_digit() || c == '-' { name.push(c); } - } else if *selected == 3 { + } else if *selected == 4 { // Retention age: alphanumeric for duration parsing if c.is_ascii_alphanumeric() { retention_age_input.push(c); } - } else if *selected == 7 { + } else if *selected == 8 { // Delete-on-empty min age: alphanumeric for duration parsing if c.is_ascii_alphanumeric() { delete_on_empty_min_age.push(c); @@ -1461,12 +1472,12 @@ impl App { if *selected > 0 { *selected -= 1; // Skip delete-on-empty min age if not enabled - if *selected == 7 && !*delete_on_empty_enabled { - *selected = 6; + if *selected == 8 && !*delete_on_empty_enabled { + *selected = 7; } // Skip retention age if not using Age policy - if *selected == 3 && *retention_policy != RetentionPolicyOption::Age { - *selected = 2; + if *selected == 4 && *retention_policy != RetentionPolicyOption::Age { + *selected = 3; } } } @@ -1474,33 +1485,34 @@ impl App { if *selected < FIELD_COUNT - 1 { *selected += 1; // Skip retention age if not using Age policy - if *selected == 3 && *retention_policy != RetentionPolicyOption::Age { - *selected = 4; + if *selected == 4 && *retention_policy != RetentionPolicyOption::Age { + *selected = 5; } // Skip delete-on-empty min age if not enabled - if *selected == 7 && !*delete_on_empty_enabled { - *selected = 8; + if *selected == 8 && !*delete_on_empty_enabled { + *selected = 9; } } } KeyCode::Enter => { match *selected { 0 => *editing = true, // Edit name - 3 => { + 4 => { if *retention_policy == RetentionPolicyOption::Age { *editing = true; // Edit retention age } } - 7 => { + 8 => { if *delete_on_empty_enabled { *editing = true; // Edit delete-on-empty min age } } - 10 => { + 11 => { // Create button - validate and submit if name.len() >= 8 { // Extract all values to avoid borrow conflict let basin_name = name.clone(); + let basin_scope = *scope; let csoa = *create_stream_on_append; let csor = *create_stream_on_read; let sc = storage_class.clone(); @@ -1512,7 +1524,7 @@ impl App { let doema = delete_on_empty_min_age.clone(); let config = build_basin_config(csoa, csor, sc, rp, rai, tm, tu, doe, doema); - self.create_basin_with_config(basin_name, config, tx.clone()); + self.create_basin_with_config(basin_name, basin_scope, config, tx.clone()); } } _ => {} @@ -1521,10 +1533,9 @@ impl App { KeyCode::Char(' ') => { // Toggle for boolean fields match *selected { - 5 => *timestamping_uncapped = !*timestamping_uncapped, - 6 => *delete_on_empty_enabled = !*delete_on_empty_enabled, - 8 => *create_stream_on_append = !*create_stream_on_append, - 9 => *create_stream_on_read = !*create_stream_on_read, + 6 => *timestamping_uncapped = !*timestamping_uncapped, + 9 => *create_stream_on_append = !*create_stream_on_append, + 10 => *create_stream_on_read = !*create_stream_on_read, _ => {} } } @@ -1532,19 +1543,22 @@ impl App { // Cycle left for enum fields match *selected { 1 => { + // Scope: currently only AWS us-east-1, so no cycling + } + 2 => { *storage_class = match storage_class { None => Some(StorageClass::Express), Some(StorageClass::Standard) => None, Some(StorageClass::Express) => Some(StorageClass::Standard), }; } - 2 => { + 3 => { *retention_policy = match retention_policy { RetentionPolicyOption::Infinite => RetentionPolicyOption::Age, RetentionPolicyOption::Age => RetentionPolicyOption::Infinite, }; } - 4 => { + 5 => { *timestamping_mode = match timestamping_mode { None => Some(TimestampingMode::Arrival), Some(TimestampingMode::ClientPrefer) => None, @@ -1552,6 +1566,10 @@ impl App { Some(TimestampingMode::Arrival) => Some(TimestampingMode::ClientRequire), }; } + 7 => { + // Delete on empty: Never <-> After threshold + *delete_on_empty_enabled = !*delete_on_empty_enabled; + } _ => {} } } @@ -1559,19 +1577,22 @@ impl App { // Cycle right for enum fields match *selected { 1 => { + // Scope: currently only AWS us-east-1, so no cycling + } + 2 => { *storage_class = match storage_class { None => Some(StorageClass::Standard), Some(StorageClass::Standard) => Some(StorageClass::Express), Some(StorageClass::Express) => None, }; } - 2 => { + 3 => { *retention_policy = match retention_policy { RetentionPolicyOption::Infinite => RetentionPolicyOption::Age, RetentionPolicyOption::Age => RetentionPolicyOption::Infinite, }; } - 4 => { + 5 => { *timestamping_mode = match timestamping_mode { None => Some(TimestampingMode::ClientPrefer), Some(TimestampingMode::ClientPrefer) => Some(TimestampingMode::ClientRequire), @@ -1579,6 +1600,10 @@ impl App { Some(TimestampingMode::Arrival) => None, }; } + 7 => { + // Delete on empty: Never <-> After threshold + *delete_on_empty_enabled = !*delete_on_empty_enabled; + } _ => {} } } @@ -2402,6 +2427,7 @@ impl App { KeyCode::Char('c') => { self.input_mode = InputMode::CreateBasin { name: String::new(), + scope: BasinScopeOption::AwsUsEast1, create_stream_on_append: false, create_stream_on_read: false, storage_class: None, @@ -2870,7 +2896,7 @@ impl App { }); } - fn create_basin_with_config(&mut self, name: String, config: BasinConfig, tx: mpsc::UnboundedSender) { + fn create_basin_with_config(&mut self, name: String, scope: BasinScopeOption, config: BasinConfig, tx: mpsc::UnboundedSender) { self.input_mode = InputMode::Normal; let s2 = self.s2.clone(); let tx_refresh = tx.clone(); @@ -2883,11 +2909,16 @@ impl App { return; } }; - let args = CreateBasinArgs { - basin: S2BasinUri(basin_name), - config, + + // Build CreateBasinInput with scope + let sdk_scope = match scope { + BasinScopeOption::AwsUsEast1 => s2_sdk::types::BasinScope::AwsUsEast1, }; - match ops::create_basin(&s2, args).await { + let input = s2_sdk::types::CreateBasinInput::new(basin_name) + .with_config(config.into()) + .with_scope(sdk_scope); + + match s2.create_basin(input).await.map_err(|e| CliError::op(crate::error::OpKind::CreateBasin, e)) { Ok(info) => { let _ = tx.send(Event::BasinCreated(Ok(info))); // Trigger refresh diff --git a/src/tui/ui.rs b/src/tui/ui.rs index 78d665b..f7eb55c 100644 --- a/src/tui/ui.rs +++ b/src/tui/ui.rs @@ -15,6 +15,7 @@ const GREEN: Color = Color::Rgb(34, 197, 94); // Active green const GREEN_DIM: Color = Color::Rgb(22, 163, 74); // Dimmer green const YELLOW: Color = Color::Rgb(250, 204, 21); // Warning yellow const RED: Color = Color::Rgb(239, 68, 68); // Error red +const CYAN: Color = Color::Rgb(34, 211, 238); // Cyan accent const WHITE: Color = Color::Rgb(255, 255, 255); // Pure white const GRAY_100: Color = Color::Rgb(243, 244, 246); // Near white const GRAY_500: Color = Color::Rgb(107, 114, 128); // Muted gray @@ -2395,6 +2396,7 @@ fn draw_input_dialog(f: &mut Frame, mode: &InputMode) { InputMode::CreateBasin { name, + scope, create_stream_on_append, create_stream_on_read, storage_class, @@ -2407,160 +2409,291 @@ fn draw_input_dialog(f: &mut Frame, mode: &InputMode) { selected, editing, } => { - let cursor = |is_editing: bool| if is_editing { "▎" } else { "" }; - let marker = |sel: bool| if sel { "▸ " } else { " " }; - let check = |on: bool| if on { "x" } else { " " }; + use crate::tui::app::BasinScopeOption; - // Storage class display - let storage_str = match storage_class { - None => "default", - Some(StorageClass::Standard) => "Standard", - Some(StorageClass::Express) => "Express", + let cursor = "▎"; + let name_valid = name.len() >= 8 && name.len() <= 48; + + // Modern toggle switch rendering + let toggle = |on: bool, selected: bool| -> Vec> { + if on { + vec![ + Span::styled("", Style::default().fg(if selected { GREEN } else { Color::Rgb(60, 60, 60) })), + Span::styled(" ON ", Style::default().fg(BG_DARK).bg(GREEN).bold()), + Span::styled("", Style::default().fg(GREEN)), + ] + } else { + vec![ + Span::styled("", Style::default().fg(if selected { TEXT_MUTED } else { Color::Rgb(60, 60, 60) })), + Span::styled(" OFF ", Style::default().fg(TEXT_MUTED).bg(Color::Rgb(60, 60, 60))), + Span::styled("", Style::default().fg(Color::Rgb(60, 60, 60))), + ] + } }; - // Timestamping mode display - let ts_mode_str = match timestamping_mode { - None => "default", - Some(TimestampingMode::ClientPrefer) => "ClientPrefer", - Some(TimestampingMode::ClientRequire) => "ClientRequire", - Some(TimestampingMode::Arrival) => "Arrival", + // Pill-style selector for enum options + let pill = |label: &str, is_selected: bool, is_active: bool| -> Span<'static> { + let label = label.to_string(); + if is_active { + Span::styled(format!(" {} ", label), Style::default().fg(BG_DARK).bg(GREEN).bold()) + } else if is_selected { + Span::styled(format!(" {} ", label), Style::default().fg(TEXT_PRIMARY).bg(Color::Rgb(50, 50, 50))) + } else { + Span::styled(format!(" {} ", label), Style::default().fg(TEXT_MUTED)) + } }; - let name_valid = name.len() >= 8 && name.len() <= 48; - let name_color = if name_valid { GREEN } else if name.is_empty() { TEXT_MUTED } else { ERROR }; - let mut lines = vec![ - Line::from(""), - // Row 0: Name - Line::from(vec![ - Span::styled(marker(*selected == 0), Style::default().fg(GREEN)), - Span::styled("Name: ", Style::default().fg(TEXT_MUTED)), - Span::styled(name, Style::default().fg(name_color)), - Span::styled( - if *selected == 0 && *editing { cursor(true) } else { "" }, - Style::default().fg(GREEN) - ), - ]), - Line::from(vec![ - Span::styled(" ", Style::default()), - Span::styled( - if name.is_empty() { - "8-48 chars: lowercase, numbers, hyphens".to_string() - } else { - format!("{}/48 chars", name.len()) - }, - Style::default().fg(TEXT_MUTED) - ), - ]), - Line::from(""), - Line::from(vec![ - Span::styled(" -- Default Stream Config --", Style::default().fg(TEXT_MUTED)), - ]), - Line::from(""), - // Row 1: Storage Class - Line::from(vec![ - Span::styled(marker(*selected == 1), Style::default().fg(GREEN)), - Span::styled("Storage Class: ", Style::default().fg(TEXT_MUTED)), - Span::styled(format!("< {} >", storage_str), Style::default().fg(YELLOW)), - ]), - // Row 2: Retention Policy - Line::from(vec![ - Span::styled(marker(*selected == 2), Style::default().fg(GREEN)), - Span::styled("Retention: ", Style::default().fg(TEXT_MUTED)), - Span::styled( - format!("< {} >", if *retention_policy == RetentionPolicyOption::Infinite { "Infinite" } else { "Age" }), - Style::default().fg(YELLOW) - ), - ]), + // Field row with label and value + let field_row = |idx: usize, label: &str, sel: usize| -> (Span<'static>, Span<'static>) { + let is_sel = sel == idx; + let indicator = if is_sel { + Span::styled(" > ", Style::default().fg(GREEN).bold()) + } else { + Span::styled(" ", Style::default()) + }; + let label_style = if is_sel { + Style::default().fg(TEXT_PRIMARY).bold() + } else { + Style::default().fg(TEXT_MUTED) + }; + (indicator, Span::styled(label.to_string(), label_style)) + }; + + // Scope options + let scope_opts = [ + ("AWS us-east-1", *scope == BasinScopeOption::AwsUsEast1), ]; - // Row 3: Retention Age (only if Age policy) - if *retention_policy == RetentionPolicyOption::Age { - lines.push(Line::from(vec![ - Span::styled(marker(*selected == 3), Style::default().fg(GREEN)), - Span::styled(" Age: ", Style::default().fg(TEXT_MUTED)), - Span::styled(retention_age_input, Style::default().fg(TEXT_PRIMARY)), - Span::styled( - if *selected == 3 && *editing { cursor(true) } else { "" }, - Style::default().fg(GREEN) - ), - Span::styled(" (e.g. 7d, 30d, 1y)", Style::default().fg(TEXT_MUTED)), - ])); - } + // Storage class options + let storage_opts = [ + ("Default", storage_class.is_none()), + ("Standard", matches!(storage_class, Some(StorageClass::Standard))), + ("Express", matches!(storage_class, Some(StorageClass::Express))), + ]; + + // Timestamping mode options + let ts_opts = [ + ("Default", timestamping_mode.is_none()), + ("ClientPrefer", matches!(timestamping_mode, Some(TimestampingMode::ClientPrefer))), + ("ClientRequire", matches!(timestamping_mode, Some(TimestampingMode::ClientRequire))), + ("Arrival", matches!(timestamping_mode, Some(TimestampingMode::Arrival))), + ]; + + // Retention options + let ret_opts = [ + ("Infinite", *retention_policy == RetentionPolicyOption::Infinite), + ("Age-based", *retention_policy == RetentionPolicyOption::Age), + ]; - // Row 4: Timestamping Mode + let mut lines = vec![]; + + // ═══════════════════════════════════════════════════════════════ + // BASIN NAME SECTION + // ═══════════════════════════════════════════════════════════════ + lines.push(Line::from("")); lines.push(Line::from(vec![ - Span::styled(marker(*selected == 4), Style::default().fg(GREEN)), - Span::styled("Timestamping: ", Style::default().fg(TEXT_MUTED)), - Span::styled(format!("< {} >", ts_mode_str), Style::default().fg(YELLOW)), + Span::styled(" Basin name ", Style::default().fg(CYAN).bold()), + Span::styled("─".repeat(35), Style::default().fg(Color::Rgb(50, 50, 50))), ])); + lines.push(Line::from("")); - // Row 5: Timestamping Uncapped + // Basin Name + let (ind, lbl) = field_row(0, "Name", *selected); + let name_display = if name.is_empty() { + Span::styled("enter name...", Style::default().fg(Color::Rgb(80, 80, 80)).italic()) + } else { + let color = if name_valid { GREEN } else { YELLOW }; + Span::styled(name.clone(), Style::default().fg(color)) + }; + let cursor_span = if *selected == 0 && *editing { + Span::styled(cursor, Style::default().fg(GREEN)) + } else { + Span::raw("") + }; + + lines.push(Line::from(vec![ind, lbl, Span::raw(" "), name_display, cursor_span])); + + // Validation hint + let hint_text = if name.is_empty() { + "lowercase, numbers, hyphens (8-48 chars)".to_string() + } else if name.len() < 8 { + format!("{} more chars needed", 8 - name.len()) + } else if name.len() > 48 { + "name too long".to_string() + } else { + format!("{}/48 chars", name.len()) + }; + let hint_color = if name_valid { Color::Rgb(80, 80, 80) } else if name.is_empty() { Color::Rgb(80, 80, 80) } else { YELLOW }; lines.push(Line::from(vec![ - Span::styled(marker(*selected == 5), Style::default().fg(GREEN)), - Span::styled("Uncapped Timestamps: ", Style::default().fg(TEXT_MUTED)), - Span::styled(format!("[{}]", check(*timestamping_uncapped)), Style::default().fg(if *timestamping_uncapped { GREEN } else { TEXT_MUTED })), + Span::raw(" "), + Span::styled(hint_text, Style::default().fg(hint_color).italic()), ])); - // Row 6: Delete-on-empty toggle + // Basin Scope (Cloud Provider/Region) + lines.push(Line::from("")); + let (ind, lbl) = field_row(1, "Region", *selected); + let mut scope_spans = vec![ind, lbl, Span::raw(" ")]; + for (label, active) in &scope_opts { + scope_spans.push(pill(label, *selected == 1, *active)); + scope_spans.push(Span::raw(" ")); + } + lines.push(Line::from(scope_spans)); + + // ═══════════════════════════════════════════════════════════════ + // DEFAULT STREAM CONFIGURATION SECTION + // ═══════════════════════════════════════════════════════════════ + lines.push(Line::from("")); lines.push(Line::from(vec![ - Span::styled(marker(*selected == 6), Style::default().fg(GREEN)), - Span::styled("Delete-on-empty: ", Style::default().fg(TEXT_MUTED)), - Span::styled(format!("[{}]", check(*delete_on_empty_enabled)), Style::default().fg(if *delete_on_empty_enabled { GREEN } else { TEXT_MUTED })), + Span::styled(" Default stream configuration ", Style::default().fg(CYAN).bold()), + Span::styled("─".repeat(17), Style::default().fg(Color::Rgb(50, 50, 50))), ])); + lines.push(Line::from("")); + + // Storage Class + let (ind, lbl) = field_row(2, "Storage", *selected); + let mut storage_spans = vec![ind, lbl, Span::raw(" ")]; + for (label, active) in &storage_opts { + storage_spans.push(pill(label, *selected == 2, *active)); + storage_spans.push(Span::raw(" ")); + } + lines.push(Line::from(storage_spans)); + + // Retention + lines.push(Line::from("")); + let (ind, lbl) = field_row(3, "Retention", *selected); + let mut ret_spans = vec![ind, lbl, Span::raw(" ")]; + for (label, active) in &ret_opts { + ret_spans.push(pill(label, *selected == 3, *active)); + ret_spans.push(Span::raw(" ")); + } + lines.push(Line::from(ret_spans)); + + // Retention Age (conditional) + if *retention_policy == RetentionPolicyOption::Age { + let (ind, lbl) = field_row(4, " Duration", *selected); + let age_cursor = if *selected == 4 && *editing { cursor } else { "" }; + lines.push(Line::from(vec![ + ind, lbl, Span::raw(" "), + Span::styled(retention_age_input.clone(), Style::default().fg(YELLOW)), + Span::styled(age_cursor, Style::default().fg(GREEN)), + Span::styled(" e.g. 7d, 30d, 1y", Style::default().fg(Color::Rgb(80, 80, 80)).italic()), + ])); + } - // Row 7: Delete-on-empty Min Age (only if enabled) + // Timestamping Mode + lines.push(Line::from("")); + let (ind, lbl) = field_row(5, "Timestamps", *selected); + let mut ts_spans = vec![ind, lbl, Span::raw(" ")]; + for (label, active) in &ts_opts { + ts_spans.push(pill(label, *selected == 5, *active)); + ts_spans.push(Span::raw(" ")); + } + lines.push(Line::from(ts_spans)); + + // Uncapped Timestamps + let (ind, lbl) = field_row(6, " Uncapped", *selected); + let mut uncapped_spans = vec![ind, lbl, Span::raw(" ")]; + uncapped_spans.extend(toggle(*timestamping_uncapped, *selected == 6)); + lines.push(Line::from(uncapped_spans)); + + // Delete on Empty + let delete_opts = [ + ("Never", !*delete_on_empty_enabled), + ("After threshold", *delete_on_empty_enabled), + ]; + lines.push(Line::from("")); + let (ind, lbl) = field_row(7, "Delete on empty", *selected); + let mut del_spans = vec![ind, lbl, Span::raw(" ")]; + for (label, active) in &delete_opts { + del_spans.push(pill(label, *selected == 7, *active)); + del_spans.push(Span::raw(" ")); + } + lines.push(Line::from(del_spans)); + + // Delete on Empty Threshold (conditional) if *delete_on_empty_enabled { + let (ind, lbl) = field_row(8, " Threshold", *selected); + let age_cursor = if *selected == 8 && *editing { cursor } else { "" }; lines.push(Line::from(vec![ - Span::styled(marker(*selected == 7), Style::default().fg(GREEN)), - Span::styled(" Min Age: ", Style::default().fg(TEXT_MUTED)), - Span::styled(delete_on_empty_min_age, Style::default().fg(TEXT_PRIMARY)), - Span::styled( - if *selected == 7 && *editing { cursor(true) } else { "" }, - Style::default().fg(GREEN) - ), - Span::styled(" (e.g. 7d, 1h)", Style::default().fg(TEXT_MUTED)), + ind, lbl, Span::raw(" "), + Span::styled(delete_on_empty_min_age.clone(), Style::default().fg(YELLOW)), + Span::styled(age_cursor, Style::default().fg(GREEN)), + Span::styled(" e.g. 1h, 7d", Style::default().fg(Color::Rgb(80, 80, 80)).italic()), ])); } + // ═══════════════════════════════════════════════════════════════ + // CREATE STREAMS AUTOMATICALLY SECTION + // ═══════════════════════════════════════════════════════════════ lines.push(Line::from("")); lines.push(Line::from(vec![ - Span::styled(" -- Basin Behavior --", Style::default().fg(TEXT_MUTED)), + Span::styled(" Create streams automatically ", Style::default().fg(CYAN).bold()), + Span::styled("─".repeat(17), Style::default().fg(Color::Rgb(50, 50, 50))), ])); lines.push(Line::from("")); - // Row 8: Create Stream On Append + // Auto-create on Append + let (ind, lbl) = field_row(9, "Create on Append", *selected); + let mut append_spans = vec![ind, lbl, Span::raw(" ")]; + append_spans.extend(toggle(*create_stream_on_append, *selected == 9)); + lines.push(Line::from(append_spans)); lines.push(Line::from(vec![ - Span::styled(marker(*selected == 8), Style::default().fg(GREEN)), - Span::styled("Auto-create on Append: ", Style::default().fg(TEXT_MUTED)), - Span::styled(format!("[{}]", check(*create_stream_on_append)), Style::default().fg(if *create_stream_on_append { GREEN } else { TEXT_MUTED })), + Span::raw(" "), + Span::styled("auto-create streams when appending", Style::default().fg(Color::Rgb(80, 80, 80)).italic()), ])); - // Row 9: Create Stream On Read + // Auto-create on Read + lines.push(Line::from("")); + let (ind, lbl) = field_row(10, "Create on Read", *selected); + let mut read_spans = vec![ind, lbl, Span::raw(" ")]; + read_spans.extend(toggle(*create_stream_on_read, *selected == 10)); + lines.push(Line::from(read_spans)); lines.push(Line::from(vec![ - Span::styled(marker(*selected == 9), Style::default().fg(GREEN)), - Span::styled("Auto-create on Read: ", Style::default().fg(TEXT_MUTED)), - Span::styled(format!("[{}]", check(*create_stream_on_read)), Style::default().fg(if *create_stream_on_read { GREEN } else { TEXT_MUTED })), + Span::raw(" "), + Span::styled("auto-create streams when reading", Style::default().fg(Color::Rgb(80, 80, 80)).italic()), ])); + // ═══════════════════════════════════════════════════════════════ + // CREATE BUTTON + // ═══════════════════════════════════════════════════════════════ + lines.push(Line::from("")); + lines.push(Line::from(vec![ + Span::styled("─".repeat(52), Style::default().fg(Color::Rgb(50, 50, 50))), + ])); lines.push(Line::from("")); - // Row 10: Create button let can_create = name_valid; - let (btn_fg, btn_bg) = if *selected == 10 && can_create { - (BG_DARK, GREEN) + let btn_style = if *selected == 11 && can_create { + Style::default().fg(BG_DARK).bg(GREEN).bold() + } else if can_create { + Style::default().fg(GREEN).bold() + } else { + Style::default().fg(Color::Rgb(80, 80, 80)) + }; + + let btn_indicator = if *selected == 11 { + Span::styled(" > ", Style::default().fg(GREEN).bold()) } else { - (if can_create { GREEN } else { TEXT_MUTED }, BG_PANEL) + Span::raw(" ") }; + lines.push(Line::from(vec![ - Span::styled(marker(*selected == 10), Style::default().fg(GREEN)), - Span::styled(" CREATE BASIN ", Style::default().fg(btn_fg).bg(btn_bg).bold()), + btn_indicator, + Span::styled(" CREATE BASIN ", btn_style), + if !can_create { + Span::styled(" (enter valid name)", Style::default().fg(Color::Rgb(80, 80, 80)).italic()) + } else { + Span::raw("") + }, ])); + lines.push(Line::from("")); + ( " Create Basin ", lines, - "jk nav hl cycle space toggle Enter edit/submit esc", + "j/k navigate | h/l cycle | Space toggle | Enter edit | Esc cancel", ) } @@ -3463,7 +3596,7 @@ fn draw_input_dialog(f: &mut Frame, mode: &InputMode) { }, }; - let area = centered_rect(55, 85, f.area()); + let area = centered_rect(60, 85, f.area()); let block = Block::default() .title(Line::from(Span::styled(title, Style::default().fg(TEXT_PRIMARY).bold()))) From 17e4261473e5493dc6c5e9bafa6b4850c74857b5 Mon Sep 17 00:00:00 2001 From: Mehul Arora Date: Fri, 23 Jan 2026 23:25:37 -0500 Subject: [PATCH 10/31] . --- src/tui/app.rs | 151 +++++++++++++++---- src/tui/ui.rs | 382 ++++++++++++++++++++++++++++++++++--------------- 2 files changed, 389 insertions(+), 144 deletions(-) diff --git a/src/tui/app.rs b/src/tui/app.rs index 5e80683..ad9b759 100644 --- a/src/tui/app.rs +++ b/src/tui/app.rs @@ -1674,6 +1674,16 @@ impl App { editing_age, age_input, } => { + // Field indices: + // 0: Storage class + // 1: Retention policy + // 2: Retention age (if Age-based) + // 3: Timestamping mode + // 4: Timestamping uncapped + // 5: Create on append + // 6: Create on read + const BASIN_MAX_ROW: usize = 6; + // If editing age, handle number input if *editing_age { match key.code { @@ -1695,9 +1705,6 @@ impl App { return; } - // Basin has 7 rows: append, read, storage, retention_type, retention_age, ts_mode, ts_uncapped - const BASIN_MAX_ROW: usize = 6; - match key.code { KeyCode::Esc => { self.input_mode = InputMode::Normal; @@ -1705,41 +1712,81 @@ impl App { KeyCode::Up | KeyCode::Char('k') => { if *selected > 0 { *selected -= 1; + // Skip retention age if not using Age policy + if *selected == 2 && *retention_policy != RetentionPolicyOption::Age { + *selected = 1; + } } } KeyCode::Down | KeyCode::Char('j') => { if *selected < BASIN_MAX_ROW { *selected += 1; + // Skip retention age if not using Age policy + if *selected == 2 && *retention_policy != RetentionPolicyOption::Age { + *selected = 3; + } } } - KeyCode::Char(' ') | KeyCode::Enter => { + KeyCode::Char(' ') => { + // Toggle for boolean fields match *selected { - 0 => *create_stream_on_append = Some(!create_stream_on_append.unwrap_or(false)), - 1 => *create_stream_on_read = Some(!create_stream_on_read.unwrap_or(false)), - 2 => { - // Cycle storage class + 4 => *timestamping_uncapped = Some(!timestamping_uncapped.unwrap_or(false)), + 5 => *create_stream_on_append = Some(!create_stream_on_append.unwrap_or(false)), + 6 => *create_stream_on_read = Some(!create_stream_on_read.unwrap_or(false)), + _ => {} + } + } + KeyCode::Enter => { + // Edit text fields + if *selected == 2 && *retention_policy == RetentionPolicyOption::Age { + *editing_age = true; + *age_input = retention_age_secs.to_string(); + } + } + KeyCode::Left | KeyCode::Char('h') => { + // Cycle left for enum fields + match *selected { + 0 => { *storage_class = match storage_class { None => Some(StorageClass::Express), - Some(StorageClass::Express) => Some(StorageClass::Standard), Some(StorageClass::Standard) => None, + Some(StorageClass::Express) => Some(StorageClass::Standard), }; } - 3 => { - // Toggle retention policy + 1 => { *retention_policy = match retention_policy { RetentionPolicyOption::Infinite => RetentionPolicyOption::Age, RetentionPolicyOption::Age => RetentionPolicyOption::Infinite, }; } - 4 => { - // Edit retention age - if *retention_policy == RetentionPolicyOption::Age { - *editing_age = true; - *age_input = retention_age_secs.to_string(); - } + 3 => { + *timestamping_mode = match timestamping_mode { + None => Some(TimestampingMode::Arrival), + Some(TimestampingMode::ClientPrefer) => None, + Some(TimestampingMode::ClientRequire) => Some(TimestampingMode::ClientPrefer), + Some(TimestampingMode::Arrival) => Some(TimestampingMode::ClientRequire), + }; + } + _ => {} + } + } + KeyCode::Right | KeyCode::Char('l') => { + // Cycle right for enum fields + match *selected { + 0 => { + *storage_class = match storage_class { + None => Some(StorageClass::Standard), + Some(StorageClass::Standard) => Some(StorageClass::Express), + Some(StorageClass::Express) => None, + }; + } + 1 => { + *retention_policy = match retention_policy { + RetentionPolicyOption::Infinite => RetentionPolicyOption::Age, + RetentionPolicyOption::Age => RetentionPolicyOption::Infinite, + }; } - 5 => { - // Cycle timestamping mode + 3 => { *timestamping_mode = match timestamping_mode { None => Some(TimestampingMode::ClientPrefer), Some(TimestampingMode::ClientPrefer) => Some(TimestampingMode::ClientRequire), @@ -1747,7 +1794,6 @@ impl App { Some(TimestampingMode::Arrival) => None, }; } - 6 => *timestamping_uncapped = Some(!timestamping_uncapped.unwrap_or(false)), _ => {} } } @@ -1800,7 +1846,12 @@ impl App { return; } - // Stream has 5 rows: storage, retention_type, retention_age, ts_mode, ts_uncapped + // Stream has 5 rows: + // 0: Storage class + // 1: Retention policy + // 2: Retention age (if Age-based) + // 3: Timestamping mode + // 4: Timestamping uncapped const STREAM_MAX_ROW: usize = 4; match key.code { @@ -1810,20 +1861,42 @@ impl App { KeyCode::Up | KeyCode::Char('k') => { if *selected > 0 { *selected -= 1; + // Skip retention age if not using Age policy + if *selected == 2 && *retention_policy != RetentionPolicyOption::Age { + *selected = 1; + } } } KeyCode::Down | KeyCode::Char('j') => { if *selected < STREAM_MAX_ROW { *selected += 1; + // Skip retention age if not using Age policy + if *selected == 2 && *retention_policy != RetentionPolicyOption::Age { + *selected = 3; + } } } - KeyCode::Char(' ') | KeyCode::Enter => { + KeyCode::Char(' ') => { + // Toggle for boolean fields + if *selected == 4 { + *timestamping_uncapped = Some(!timestamping_uncapped.unwrap_or(false)); + } + } + KeyCode::Enter => { + // Edit text fields + if *selected == 2 && *retention_policy == RetentionPolicyOption::Age { + *editing_age = true; + *age_input = retention_age_secs.to_string(); + } + } + KeyCode::Left | KeyCode::Char('h') => { + // Cycle left for enum fields match *selected { 0 => { *storage_class = match storage_class { None => Some(StorageClass::Express), - Some(StorageClass::Express) => Some(StorageClass::Standard), Some(StorageClass::Standard) => None, + Some(StorageClass::Express) => Some(StorageClass::Standard), }; } 1 => { @@ -1832,11 +1905,32 @@ impl App { RetentionPolicyOption::Age => RetentionPolicyOption::Infinite, }; } - 2 => { - if *retention_policy == RetentionPolicyOption::Age { - *editing_age = true; - *age_input = retention_age_secs.to_string(); - } + 3 => { + *timestamping_mode = match timestamping_mode { + None => Some(TimestampingMode::Arrival), + Some(TimestampingMode::ClientPrefer) => None, + Some(TimestampingMode::ClientRequire) => Some(TimestampingMode::ClientPrefer), + Some(TimestampingMode::Arrival) => Some(TimestampingMode::ClientRequire), + }; + } + _ => {} + } + } + KeyCode::Right | KeyCode::Char('l') => { + // Cycle right for enum fields + match *selected { + 0 => { + *storage_class = match storage_class { + None => Some(StorageClass::Standard), + Some(StorageClass::Standard) => Some(StorageClass::Express), + Some(StorageClass::Express) => None, + }; + } + 1 => { + *retention_policy = match retention_policy { + RetentionPolicyOption::Infinite => RetentionPolicyOption::Age, + RetentionPolicyOption::Age => RetentionPolicyOption::Infinite, + }; } 3 => { *timestamping_mode = match timestamping_mode { @@ -1846,7 +1940,6 @@ impl App { Some(TimestampingMode::Arrival) => None, }; } - 4 => *timestamping_uncapped = Some(!timestamping_uncapped.unwrap_or(false)), _ => {} } } diff --git a/src/tui/ui.rs b/src/tui/ui.rs index f7eb55c..6f5e933 100644 --- a/src/tui/ui.rs +++ b/src/tui/ui.rs @@ -2633,26 +2633,18 @@ fn draw_input_dialog(f: &mut Frame, mode: &InputMode) { ])); lines.push(Line::from("")); - // Auto-create on Append - let (ind, lbl) = field_row(9, "Create on Append", *selected); + // On Append + let (ind, lbl) = field_row(9, "On append", *selected); let mut append_spans = vec![ind, lbl, Span::raw(" ")]; append_spans.extend(toggle(*create_stream_on_append, *selected == 9)); lines.push(Line::from(append_spans)); - lines.push(Line::from(vec![ - Span::raw(" "), - Span::styled("auto-create streams when appending", Style::default().fg(Color::Rgb(80, 80, 80)).italic()), - ])); - // Auto-create on Read + // On Read lines.push(Line::from("")); - let (ind, lbl) = field_row(10, "Create on Read", *selected); + let (ind, lbl) = field_row(10, "On read", *selected); let mut read_spans = vec![ind, lbl, Span::raw(" ")]; read_spans.extend(toggle(*create_stream_on_read, *selected == 10)); lines.push(Line::from(read_spans)); - lines.push(Line::from(vec![ - Span::raw(" "), - Span::styled("auto-create streams when reading", Style::default().fg(Color::Rgb(80, 80, 80)).italic()), - ])); // ═══════════════════════════════════════════════════════════════ // CREATE BUTTON @@ -2770,77 +2762,162 @@ fn draw_input_dialog(f: &mut Frame, mode: &InputMode) { editing_age, age_input, } => { - let checkbox = |checked: bool| if checked { "[x]" } else { "[ ]" }; - let sel = |idx: usize, s: &usize| if idx == *s { ">" } else { " " }; - let sc_str = match storage_class { - None => "default", - Some(StorageClass::Express) => "express", - Some(StorageClass::Standard) => "standard", + let cursor = "▎"; + + // Modern toggle switch rendering + let toggle = |on: bool, is_selected: bool| -> Vec> { + if on { + vec![ + Span::styled("", Style::default().fg(if is_selected { GREEN } else { Color::Rgb(60, 60, 60) })), + Span::styled(" ON ", Style::default().fg(BG_DARK).bg(GREEN).bold()), + Span::styled("", Style::default().fg(GREEN)), + ] + } else { + vec![ + Span::styled("", Style::default().fg(if is_selected { TEXT_MUTED } else { Color::Rgb(60, 60, 60) })), + Span::styled(" OFF ", Style::default().fg(TEXT_MUTED).bg(Color::Rgb(60, 60, 60))), + Span::styled("", Style::default().fg(Color::Rgb(60, 60, 60))), + ] + } }; - let ts_str = match timestamping_mode { - None => "default", - Some(TimestampingMode::ClientPrefer) => "client-prefer", - Some(TimestampingMode::ClientRequire) => "client-require", - Some(TimestampingMode::Arrival) => "arrival", + + // Pill-style selector + let pill = |label: &str, is_selected: bool, is_active: bool| -> Span<'static> { + let label = label.to_string(); + if is_active { + Span::styled(format!(" {} ", label), Style::default().fg(BG_DARK).bg(GREEN).bold()) + } else if is_selected { + Span::styled(format!(" {} ", label), Style::default().fg(TEXT_PRIMARY).bg(Color::Rgb(50, 50, 50))) + } else { + Span::styled(format!(" {} ", label), Style::default().fg(TEXT_MUTED)) + } }; - let mut lines = vec![ - Line::from(vec![ - Span::styled(basin.to_string(), Style::default().fg(GREEN).bold()), - ]), - Line::from(""), - Line::from(Span::styled("-- Create Streams Automatically --", Style::default().fg(TEXT_MUTED))), - Line::from(vec![ - Span::styled(sel(0, selected), Style::default().fg(GREEN)), - Span::styled(format!(" {} on append", checkbox(create_stream_on_append.unwrap_or(false))), Style::default().fg(TEXT_SECONDARY)), - ]), - Line::from(vec![ - Span::styled(sel(1, selected), Style::default().fg(GREEN)), - Span::styled(format!(" {} on read", checkbox(create_stream_on_read.unwrap_or(false))), Style::default().fg(TEXT_SECONDARY)), - ]), - Line::from(""), - Line::from(Span::styled("-- Default Stream Config --", Style::default().fg(TEXT_MUTED))), - Line::from(vec![ - Span::styled(sel(2, selected), Style::default().fg(GREEN)), - Span::styled(format!(" Storage class: < {} >", sc_str), Style::default().fg(TEXT_SECONDARY)), - ]), - Line::from(vec![ - Span::styled(sel(3, selected), Style::default().fg(GREEN)), - Span::styled(format!(" Retention: < {} >", if *retention_policy == RetentionPolicyOption::Infinite { "infinite" } else { "age-based" }), Style::default().fg(TEXT_SECONDARY)), - ]), + // Field row helper + let field_row = |idx: usize, label: &str, sel: usize| -> (Span<'static>, Span<'static>) { + let is_sel = sel == idx; + let indicator = if is_sel { + Span::styled(" > ", Style::default().fg(GREEN).bold()) + } else { + Span::styled(" ", Style::default()) + }; + let label_style = if is_sel { + Style::default().fg(TEXT_PRIMARY).bold() + } else { + Style::default().fg(TEXT_MUTED) + }; + (indicator, Span::styled(label.to_string(), label_style)) + }; + + // Options + let storage_opts = [ + ("Default", storage_class.is_none()), + ("Standard", matches!(storage_class, Some(StorageClass::Standard))), + ("Express", matches!(storage_class, Some(StorageClass::Express))), ]; + let ret_opts = [ + ("Infinite", *retention_policy == RetentionPolicyOption::Infinite), + ("Age-based", *retention_policy == RetentionPolicyOption::Age), + ]; + let ts_opts = [ + ("Default", timestamping_mode.is_none()), + ("ClientPrefer", matches!(timestamping_mode, Some(TimestampingMode::ClientPrefer))), + ("ClientRequire", matches!(timestamping_mode, Some(TimestampingMode::ClientRequire))), + ("Arrival", matches!(timestamping_mode, Some(TimestampingMode::Arrival))), + ]; + + let mut lines = vec![]; + + // Basin name header + lines.push(Line::from("")); + lines.push(Line::from(vec![ + Span::styled(" ", Style::default()), + Span::styled(basin.to_string(), Style::default().fg(GREEN).bold()), + ])); + + // Default stream configuration section + lines.push(Line::from("")); + lines.push(Line::from(vec![ + Span::styled(" Default stream configuration ", Style::default().fg(CYAN).bold()), + Span::styled("─".repeat(17), Style::default().fg(Color::Rgb(50, 50, 50))), + ])); + lines.push(Line::from("")); + + // Storage Class + let (ind, lbl) = field_row(0, "Storage", *selected); + let mut storage_spans = vec![ind, lbl, Span::raw(" ")]; + for (label, active) in &storage_opts { + storage_spans.push(pill(label, *selected == 0, *active)); + storage_spans.push(Span::raw(" ")); + } + lines.push(Line::from(storage_spans)); + // Retention + lines.push(Line::from("")); + let (ind, lbl) = field_row(1, "Retention", *selected); + let mut ret_spans = vec![ind, lbl, Span::raw(" ")]; + for (label, active) in &ret_opts { + ret_spans.push(pill(label, *selected == 1, *active)); + ret_spans.push(Span::raw(" ")); + } + lines.push(Line::from(ret_spans)); + + // Retention Age (conditional) if *retention_policy == RetentionPolicyOption::Age { - let age_display = if *editing_age { - format!("{}_ secs", age_input) - } else { - format!("{} secs", retention_age_secs) - }; - lines.push(Line::from(vec![ - Span::styled(sel(4, selected), Style::default().fg(GREEN)), - Span::styled(format!(" Age: {}", age_display), Style::default().fg(if *editing_age { GREEN } else { TEXT_SECONDARY })), - ])); - } else { + let (ind, lbl) = field_row(2, " Duration", *selected); + let age_cursor = if *selected == 2 && *editing_age { cursor } else { "" }; + let age_display = if *editing_age { age_input.clone() } else { format!("{}s", retention_age_secs) }; lines.push(Line::from(vec![ - Span::styled(" Age: (n/a)", Style::default().fg(BORDER)), + ind, lbl, Span::raw(" "), + Span::styled(age_display, Style::default().fg(YELLOW)), + Span::styled(age_cursor, Style::default().fg(GREEN)), + Span::styled(" e.g. 604800 (7 days)", Style::default().fg(Color::Rgb(80, 80, 80)).italic()), ])); } - lines.extend(vec![ - Line::from(vec![ - Span::styled(sel(5, selected), Style::default().fg(GREEN)), - Span::styled(format!(" Timestamping: < {} >", ts_str), Style::default().fg(TEXT_SECONDARY)), - ]), - Line::from(vec![ - Span::styled(sel(6, selected), Style::default().fg(GREEN)), - Span::styled(format!(" {} Allow ts > arrival", checkbox(timestamping_uncapped.unwrap_or(false))), Style::default().fg(TEXT_SECONDARY)), - ]), - ]); + // Timestamping Mode + lines.push(Line::from("")); + let (ind, lbl) = field_row(3, "Timestamps", *selected); + let mut ts_spans = vec![ind, lbl, Span::raw(" ")]; + for (label, active) in &ts_opts { + ts_spans.push(pill(label, *selected == 3, *active)); + ts_spans.push(Span::raw(" ")); + } + lines.push(Line::from(ts_spans)); + + // Uncapped Timestamps + let (ind, lbl) = field_row(4, " Uncapped", *selected); + let mut uncapped_spans = vec![ind, lbl, Span::raw(" ")]; + uncapped_spans.extend(toggle(timestamping_uncapped.unwrap_or(false), *selected == 4)); + lines.push(Line::from(uncapped_spans)); + + // Create streams automatically section + lines.push(Line::from("")); + lines.push(Line::from(vec![ + Span::styled(" Create streams automatically ", Style::default().fg(CYAN).bold()), + Span::styled("─".repeat(17), Style::default().fg(Color::Rgb(50, 50, 50))), + ])); + lines.push(Line::from("")); + + // On Append + let (ind, lbl) = field_row(5, "On append", *selected); + let mut append_spans = vec![ind, lbl, Span::raw(" ")]; + append_spans.extend(toggle(create_stream_on_append.unwrap_or(false), *selected == 5)); + lines.push(Line::from(append_spans)); + + // On Read + lines.push(Line::from("")); + let (ind, lbl) = field_row(6, "On read", *selected); + let mut read_spans = vec![ind, lbl, Span::raw(" ")]; + read_spans.extend(toggle(create_stream_on_read.unwrap_or(false), *selected == 6)); + lines.push(Line::from(read_spans)); + + lines.push(Line::from("")); ( " Reconfigure Basin ", lines, - "jk nav | space/enter toggle | s save | esc cancel", + "j/k navigate | h/l cycle | Space toggle | Enter edit | s save | Esc cancel", ) } @@ -2856,66 +2933,141 @@ fn draw_input_dialog(f: &mut Frame, mode: &InputMode) { editing_age, age_input, } => { - let checkbox = |checked: bool| if checked { "[x]" } else { "[ ]" }; - let sel = |idx: usize, s: &usize| if idx == *s { ">" } else { " " }; - let sc_str = match storage_class { - None => "default", - Some(StorageClass::Express) => "express", - Some(StorageClass::Standard) => "standard", + let cursor = "▎"; + + // Modern toggle switch rendering + let toggle = |on: bool, is_selected: bool| -> Vec> { + if on { + vec![ + Span::styled("", Style::default().fg(if is_selected { GREEN } else { Color::Rgb(60, 60, 60) })), + Span::styled(" ON ", Style::default().fg(BG_DARK).bg(GREEN).bold()), + Span::styled("", Style::default().fg(GREEN)), + ] + } else { + vec![ + Span::styled("", Style::default().fg(if is_selected { TEXT_MUTED } else { Color::Rgb(60, 60, 60) })), + Span::styled(" OFF ", Style::default().fg(TEXT_MUTED).bg(Color::Rgb(60, 60, 60))), + Span::styled("", Style::default().fg(Color::Rgb(60, 60, 60))), + ] + } }; - let ts_str = match timestamping_mode { - None => "default", - Some(TimestampingMode::ClientPrefer) => "client-prefer", - Some(TimestampingMode::ClientRequire) => "client-require", - Some(TimestampingMode::Arrival) => "arrival", + + // Pill-style selector + let pill = |label: &str, is_selected: bool, is_active: bool| -> Span<'static> { + let label = label.to_string(); + if is_active { + Span::styled(format!(" {} ", label), Style::default().fg(BG_DARK).bg(GREEN).bold()) + } else if is_selected { + Span::styled(format!(" {} ", label), Style::default().fg(TEXT_PRIMARY).bg(Color::Rgb(50, 50, 50))) + } else { + Span::styled(format!(" {} ", label), Style::default().fg(TEXT_MUTED)) + } }; - let mut lines = vec![ - Line::from(vec![ - Span::styled(format!("{}/{}", basin, stream), Style::default().fg(GREEN).bold()), - ]), - Line::from(""), - Line::from(vec![ - Span::styled(sel(0, selected), Style::default().fg(GREEN)), - Span::styled(format!(" Storage class: < {} >", sc_str), Style::default().fg(TEXT_SECONDARY)), - ]), - Line::from(vec![ - Span::styled(sel(1, selected), Style::default().fg(GREEN)), - Span::styled(format!(" Retention: < {} >", if *retention_policy == RetentionPolicyOption::Infinite { "infinite" } else { "age-based" }), Style::default().fg(TEXT_SECONDARY)), - ]), + // Field row helper + let field_row = |idx: usize, label: &str, sel: usize| -> (Span<'static>, Span<'static>) { + let is_sel = sel == idx; + let indicator = if is_sel { + Span::styled(" > ", Style::default().fg(GREEN).bold()) + } else { + Span::styled(" ", Style::default()) + }; + let label_style = if is_sel { + Style::default().fg(TEXT_PRIMARY).bold() + } else { + Style::default().fg(TEXT_MUTED) + }; + (indicator, Span::styled(label.to_string(), label_style)) + }; + + // Options + let storage_opts = [ + ("Default", storage_class.is_none()), + ("Standard", matches!(storage_class, Some(StorageClass::Standard))), + ("Express", matches!(storage_class, Some(StorageClass::Express))), + ]; + let ret_opts = [ + ("Infinite", *retention_policy == RetentionPolicyOption::Infinite), + ("Age-based", *retention_policy == RetentionPolicyOption::Age), + ]; + let ts_opts = [ + ("Default", timestamping_mode.is_none()), + ("ClientPrefer", matches!(timestamping_mode, Some(TimestampingMode::ClientPrefer))), + ("ClientRequire", matches!(timestamping_mode, Some(TimestampingMode::ClientRequire))), + ("Arrival", matches!(timestamping_mode, Some(TimestampingMode::Arrival))), ]; + let mut lines = vec![]; + + // Stream name header + lines.push(Line::from("")); + lines.push(Line::from(vec![ + Span::styled(" ", Style::default()), + Span::styled(format!("{}/{}", basin, stream), Style::default().fg(GREEN).bold()), + ])); + + // Stream configuration section + lines.push(Line::from("")); + lines.push(Line::from(vec![ + Span::styled(" Stream configuration ", Style::default().fg(CYAN).bold()), + Span::styled("─".repeat(25), Style::default().fg(Color::Rgb(50, 50, 50))), + ])); + lines.push(Line::from("")); + + // Storage Class + let (ind, lbl) = field_row(0, "Storage", *selected); + let mut storage_spans = vec![ind, lbl, Span::raw(" ")]; + for (label, active) in &storage_opts { + storage_spans.push(pill(label, *selected == 0, *active)); + storage_spans.push(Span::raw(" ")); + } + lines.push(Line::from(storage_spans)); + + // Retention + lines.push(Line::from("")); + let (ind, lbl) = field_row(1, "Retention", *selected); + let mut ret_spans = vec![ind, lbl, Span::raw(" ")]; + for (label, active) in &ret_opts { + ret_spans.push(pill(label, *selected == 1, *active)); + ret_spans.push(Span::raw(" ")); + } + lines.push(Line::from(ret_spans)); + + // Retention Age (conditional) if *retention_policy == RetentionPolicyOption::Age { - let age_display = if *editing_age { - format!("{}_ secs", age_input) - } else { - format!("{} secs", retention_age_secs) - }; + let (ind, lbl) = field_row(2, " Duration", *selected); + let age_cursor = if *selected == 2 && *editing_age { cursor } else { "" }; + let age_display = if *editing_age { age_input.clone() } else { format!("{}s", retention_age_secs) }; lines.push(Line::from(vec![ - Span::styled(sel(2, selected), Style::default().fg(GREEN)), - Span::styled(format!(" Age: {}", age_display), Style::default().fg(if *editing_age { GREEN } else { TEXT_SECONDARY })), - ])); - } else { - lines.push(Line::from(vec![ - Span::styled(" Age: (n/a)", Style::default().fg(BORDER)), + ind, lbl, Span::raw(" "), + Span::styled(age_display, Style::default().fg(YELLOW)), + Span::styled(age_cursor, Style::default().fg(GREEN)), + Span::styled(" e.g. 604800 (7 days)", Style::default().fg(Color::Rgb(80, 80, 80)).italic()), ])); } - lines.extend(vec![ - Line::from(vec![ - Span::styled(sel(3, selected), Style::default().fg(GREEN)), - Span::styled(format!(" Timestamping: < {} >", ts_str), Style::default().fg(TEXT_SECONDARY)), - ]), - Line::from(vec![ - Span::styled(sel(4, selected), Style::default().fg(GREEN)), - Span::styled(format!(" {} Allow ts > arrival", checkbox(timestamping_uncapped.unwrap_or(false))), Style::default().fg(TEXT_SECONDARY)), - ]), - ]); + // Timestamping Mode + lines.push(Line::from("")); + let (ind, lbl) = field_row(3, "Timestamps", *selected); + let mut ts_spans = vec![ind, lbl, Span::raw(" ")]; + for (label, active) in &ts_opts { + ts_spans.push(pill(label, *selected == 3, *active)); + ts_spans.push(Span::raw(" ")); + } + lines.push(Line::from(ts_spans)); + + // Uncapped Timestamps + let (ind, lbl) = field_row(4, " Uncapped", *selected); + let mut uncapped_spans = vec![ind, lbl, Span::raw(" ")]; + uncapped_spans.extend(toggle(timestamping_uncapped.unwrap_or(false), *selected == 4)); + lines.push(Line::from(uncapped_spans)); + + lines.push(Line::from("")); ( " Reconfigure Stream ", lines, - "jk nav | space/enter toggle | s save | esc cancel", + "j/k navigate | h/l cycle | Space toggle | Enter edit | s save | Esc cancel", ) } From 5568a6caf3d5a9bc41dcc218c81dd7fe598fd77e Mon Sep 17 00:00:00 2001 From: Mehul Arora Date: Fri, 23 Jan 2026 23:37:09 -0500 Subject: [PATCH 11/31] . --- src/tui/app.rs | 370 +++++++++++++++++++++++++++++++++++++++++++---- src/tui/event.rs | 1 + src/tui/ui.rs | 325 +++++++++++++++++++++++++++++++++++++---- 3 files changed, 637 insertions(+), 59 deletions(-) diff --git a/src/tui/app.rs b/src/tui/app.rs index ad9b759..9894d85 100644 --- a/src/tui/app.rs +++ b/src/tui/app.rs @@ -241,7 +241,22 @@ pub enum InputMode { editing: bool, }, /// Creating a new stream - CreateStream { basin: BasinName, input: String }, + CreateStream { + basin: BasinName, + name: String, + // Stream config + storage_class: Option, + retention_policy: RetentionPolicyOption, + retention_age_input: String, + timestamping_mode: Option, + timestamping_uncapped: bool, + // Delete-on-empty config + delete_on_empty_enabled: bool, + delete_on_empty_min_age: String, + // UI state + selected: usize, + editing: bool, + }, /// Confirming basin deletion ConfirmDeleteBasin { basin: BasinName }, /// Confirming stream deletion @@ -272,6 +287,9 @@ pub enum InputMode { retention_age_secs: u64, timestamping_mode: Option, timestamping_uncapped: Option, + // Delete-on-empty config + delete_on_empty_enabled: bool, + delete_on_empty_min_age: String, // UI state selected: usize, editing_age: bool, @@ -578,6 +596,8 @@ pub struct StreamReconfigureConfig { pub retention_age_secs: u64, pub timestamping_mode: Option, pub timestamping_uncapped: Option, + pub delete_on_empty_enabled: bool, + pub delete_on_empty_min_age: String, } impl Default for InputMode { @@ -650,6 +670,52 @@ fn build_basin_config( } } +fn build_stream_config( + storage_class: Option, + retention_policy: RetentionPolicyOption, + retention_age_input: String, + timestamping_mode: Option, + timestamping_uncapped: bool, + delete_on_empty_enabled: bool, + delete_on_empty_min_age: String, +) -> StreamConfig { + // Parse retention policy + let retention = match retention_policy { + RetentionPolicyOption::Infinite => None, + RetentionPolicyOption::Age => { + humantime::parse_duration(&retention_age_input) + .ok() + .map(RetentionPolicy::Age) + } + }; + + // Build timestamping config if specified + let timestamping = if timestamping_mode.is_some() || timestamping_uncapped { + Some(TimestampingConfig { + timestamping_mode, + timestamping_uncapped: if timestamping_uncapped { Some(true) } else { None }, + }) + } else { + None + }; + + // Build delete-on-empty config if enabled + let delete_on_empty = if delete_on_empty_enabled { + humantime::parse_duration(&delete_on_empty_min_age) + .ok() + .map(|d| DeleteOnEmptyConfig { delete_on_empty_min_age: d }) + } else { + None + }; + + StreamConfig { + storage_class, + retention_policy: retention, + timestamping, + delete_on_empty, + } +} + impl App { pub fn new(s2: s2_sdk::S2) -> Self { Self { @@ -1020,6 +1086,8 @@ impl App { retention_age_secs, timestamping_mode, timestamping_uncapped, + delete_on_empty_enabled, + delete_on_empty_min_age, age_input, .. } = &mut self.input_mode { @@ -1035,6 +1103,13 @@ impl App { } *timestamping_mode = info.timestamping_mode; *timestamping_uncapped = Some(info.timestamping_uncapped); + // Delete on empty + if let Some(min_age_secs) = info.delete_on_empty_min_age_secs { + *delete_on_empty_enabled = true; + *delete_on_empty_min_age = format!("{}s", min_age_secs); + } else { + *delete_on_empty_enabled = false; + } } Err(e) => { self.input_mode = InputMode::Normal; @@ -1612,25 +1687,198 @@ impl App { } } - InputMode::CreateStream { basin, input } => { - match key.code { - KeyCode::Esc => { - self.input_mode = InputMode::Normal; - } - KeyCode::Enter => { - if !input.is_empty() { - let name = input.clone(); - let basin = basin.clone(); - self.create_stream(basin, name, tx.clone()); + InputMode::CreateStream { + basin, + name, + storage_class, + retention_policy, + retention_age_input, + timestamping_mode, + timestamping_uncapped, + delete_on_empty_enabled, + delete_on_empty_min_age, + selected, + editing, + } => { + // Form fields: + // 0: Name (text) + // 1: Storage Class (cycle: None/Standard/Express) + // 2: Retention Policy (cycle: Infinite/Age) + // 3: Retention Age (text, only if Age) + // 4: Timestamping Mode (cycle: None/ClientPrefer/ClientRequire/Arrival) + // 5: Timestamping Uncapped (toggle) + // 6: Delete-on-empty (cycle: Never/After threshold) + // 7: Delete-on-empty Min Age (text, only if enabled) + // 8: Create button + const FIELD_COUNT: usize = 9; + + if *editing { + // Text editing mode + match key.code { + KeyCode::Esc | KeyCode::Enter => { + *editing = false; } + KeyCode::Backspace => { + if *selected == 0 { + name.pop(); + } else if *selected == 3 { + retention_age_input.pop(); + } else if *selected == 7 { + delete_on_empty_min_age.pop(); + } + } + KeyCode::Char(c) => { + if *selected == 0 { + // Stream names: allow most characters + name.push(c); + } else if *selected == 3 { + // Retention age: alphanumeric for duration parsing + if c.is_ascii_alphanumeric() { + retention_age_input.push(c); + } + } else if *selected == 7 { + // Delete-on-empty min age: alphanumeric for duration parsing + if c.is_ascii_alphanumeric() { + delete_on_empty_min_age.push(c); + } + } + } + _ => {} } - KeyCode::Backspace => { - input.pop(); - } - KeyCode::Char(c) => { - input.push(c); + } else { + match key.code { + KeyCode::Esc => { + self.input_mode = InputMode::Normal; + } + KeyCode::Up | KeyCode::Char('k') => { + if *selected > 0 { + *selected -= 1; + // Skip delete-on-empty min age if not enabled + if *selected == 7 && !*delete_on_empty_enabled { + *selected = 6; + } + // Skip retention age if not using Age policy + if *selected == 3 && *retention_policy != RetentionPolicyOption::Age { + *selected = 2; + } + } + } + KeyCode::Down | KeyCode::Char('j') => { + if *selected < FIELD_COUNT - 1 { + *selected += 1; + // Skip retention age if not using Age policy + if *selected == 3 && *retention_policy != RetentionPolicyOption::Age { + *selected = 4; + } + // Skip delete-on-empty min age if not enabled + if *selected == 7 && !*delete_on_empty_enabled { + *selected = 8; + } + } + } + KeyCode::Enter => { + match *selected { + 0 => *editing = true, // Edit name + 3 => { + if *retention_policy == RetentionPolicyOption::Age { + *editing = true; // Edit retention age + } + } + 7 => { + if *delete_on_empty_enabled { + *editing = true; // Edit delete-on-empty min age + } + } + 8 => { + // Create button - validate and submit + if !name.is_empty() { + let basin_name = basin.clone(); + let stream_name = name.clone(); + let sc = storage_class.clone(); + let rp = retention_policy.clone(); + let rai = retention_age_input.clone(); + let tm = timestamping_mode.clone(); + let tu = *timestamping_uncapped; + let doe = *delete_on_empty_enabled; + let doema = delete_on_empty_min_age.clone(); + + let config = build_stream_config(sc, rp, rai, tm, tu, doe, doema); + self.create_stream_with_config(basin_name, stream_name, config, tx.clone()); + } + } + _ => {} + } + } + KeyCode::Char(' ') => { + // Toggle for boolean fields + if *selected == 5 { + *timestamping_uncapped = !*timestamping_uncapped; + } + } + KeyCode::Left | KeyCode::Char('h') => { + // Cycle left for enum fields + match *selected { + 1 => { + *storage_class = match storage_class { + None => Some(StorageClass::Express), + Some(StorageClass::Standard) => None, + Some(StorageClass::Express) => Some(StorageClass::Standard), + }; + } + 2 => { + *retention_policy = match retention_policy { + RetentionPolicyOption::Infinite => RetentionPolicyOption::Age, + RetentionPolicyOption::Age => RetentionPolicyOption::Infinite, + }; + } + 4 => { + *timestamping_mode = match timestamping_mode { + None => Some(TimestampingMode::Arrival), + Some(TimestampingMode::ClientPrefer) => None, + Some(TimestampingMode::ClientRequire) => Some(TimestampingMode::ClientPrefer), + Some(TimestampingMode::Arrival) => Some(TimestampingMode::ClientRequire), + }; + } + 6 => { + // Delete on empty: Never <-> After threshold + *delete_on_empty_enabled = !*delete_on_empty_enabled; + } + _ => {} + } + } + KeyCode::Right | KeyCode::Char('l') => { + // Cycle right for enum fields + match *selected { + 1 => { + *storage_class = match storage_class { + None => Some(StorageClass::Standard), + Some(StorageClass::Standard) => Some(StorageClass::Express), + Some(StorageClass::Express) => None, + }; + } + 2 => { + *retention_policy = match retention_policy { + RetentionPolicyOption::Infinite => RetentionPolicyOption::Age, + RetentionPolicyOption::Age => RetentionPolicyOption::Infinite, + }; + } + 4 => { + *timestamping_mode = match timestamping_mode { + None => Some(TimestampingMode::ClientPrefer), + Some(TimestampingMode::ClientPrefer) => Some(TimestampingMode::ClientRequire), + Some(TimestampingMode::ClientRequire) => Some(TimestampingMode::Arrival), + Some(TimestampingMode::Arrival) => None, + }; + } + 6 => { + // Delete on empty: Never <-> After threshold + *delete_on_empty_enabled = !*delete_on_empty_enabled; + } + _ => {} + } + } + _ => {} } - _ => {} } } @@ -1822,37 +2070,55 @@ impl App { retention_age_secs, timestamping_mode, timestamping_uncapped, + delete_on_empty_enabled, + delete_on_empty_min_age, selected, editing_age, age_input, } => { - // If editing age, handle number input + // If editing age or delete-on-empty min age, handle text input if *editing_age { match key.code { KeyCode::Esc | KeyCode::Enter => { - if let Ok(secs) = age_input.parse::() { - *retention_age_secs = secs; + // Check which field we're editing + if *selected == 2 { + // Retention age + if let Ok(secs) = age_input.parse::() { + *retention_age_secs = secs; + } + } else if *selected == 6 { + // Delete-on-empty min age - no parsing needed, store as string } *editing_age = false; } KeyCode::Backspace => { - age_input.pop(); + if *selected == 2 { + age_input.pop(); + } else if *selected == 6 { + delete_on_empty_min_age.pop(); + } } - KeyCode::Char(c) if c.is_ascii_digit() => { - age_input.push(c); + KeyCode::Char(c) => { + if *selected == 2 && c.is_ascii_digit() { + age_input.push(c); + } else if *selected == 6 && c.is_ascii_alphanumeric() { + delete_on_empty_min_age.push(c); + } } _ => {} } return; } - // Stream has 5 rows: + // Stream has 7 rows: // 0: Storage class // 1: Retention policy // 2: Retention age (if Age-based) // 3: Timestamping mode // 4: Timestamping uncapped - const STREAM_MAX_ROW: usize = 4; + // 5: Delete on empty + // 6: Delete on empty threshold (if enabled) + const STREAM_MAX_ROW: usize = 6; match key.code { KeyCode::Esc => { @@ -1861,6 +2127,10 @@ impl App { KeyCode::Up | KeyCode::Char('k') => { if *selected > 0 { *selected -= 1; + // Skip delete-on-empty threshold if not enabled + if *selected == 6 && !*delete_on_empty_enabled { + *selected = 5; + } // Skip retention age if not using Age policy if *selected == 2 && *retention_policy != RetentionPolicyOption::Age { *selected = 1; @@ -1874,6 +2144,11 @@ impl App { if *selected == 2 && *retention_policy != RetentionPolicyOption::Age { *selected = 3; } + // Skip delete-on-empty threshold if not enabled + if *selected == 6 && !*delete_on_empty_enabled { + // Already at max, stay at 5 + *selected = 5; + } } } KeyCode::Char(' ') => { @@ -1887,6 +2162,8 @@ impl App { if *selected == 2 && *retention_policy == RetentionPolicyOption::Age { *editing_age = true; *age_input = retention_age_secs.to_string(); + } else if *selected == 6 && *delete_on_empty_enabled { + *editing_age = true; } } KeyCode::Left | KeyCode::Char('h') => { @@ -1913,6 +2190,10 @@ impl App { Some(TimestampingMode::Arrival) => Some(TimestampingMode::ClientRequire), }; } + 5 => { + // Delete on empty: Never <-> After threshold + *delete_on_empty_enabled = !*delete_on_empty_enabled; + } _ => {} } } @@ -1940,6 +2221,10 @@ impl App { Some(TimestampingMode::Arrival) => None, }; } + 5 => { + // Delete on empty: Never <-> After threshold + *delete_on_empty_enabled = !*delete_on_empty_enabled; + } _ => {} } } @@ -1952,6 +2237,8 @@ impl App { retention_age_secs: *retention_age_secs, timestamping_mode: timestamping_mode.clone(), timestamping_uncapped: *timestamping_uncapped, + delete_on_empty_enabled: *delete_on_empty_enabled, + delete_on_empty_min_age: delete_on_empty_min_age.clone(), }; self.reconfigure_stream(b, s, config, tx.clone()); } @@ -2683,7 +2970,16 @@ impl App { KeyCode::Char('c') => { self.input_mode = InputMode::CreateStream { basin: state.basin_name.clone(), - input: String::new(), + name: String::new(), + storage_class: None, + retention_policy: RetentionPolicyOption::Infinite, + retention_age_input: "7d".to_string(), + timestamping_mode: None, + timestamping_uncapped: false, + delete_on_empty_enabled: false, + delete_on_empty_min_age: "7d".to_string(), + selected: 0, + editing: false, }; } KeyCode::Char('d') => { @@ -2706,6 +3002,8 @@ impl App { retention_age_secs: 604800, timestamping_mode: None, timestamping_uncapped: None, + delete_on_empty_enabled: false, + delete_on_empty_min_age: "7d".to_string(), selected: 0, editing_age: false, age_input: String::new(), @@ -2793,6 +3091,8 @@ impl App { retention_age_secs: 604800, timestamping_mode: None, timestamping_uncapped: None, + delete_on_empty_enabled: false, + delete_on_empty_min_age: "7d".to_string(), selected: 0, editing_age: false, age_input: String::new(), @@ -3068,7 +3368,8 @@ impl App { }); } - fn create_stream(&mut self, basin: BasinName, name: String, tx: mpsc::UnboundedSender) { + fn create_stream_with_config(&mut self, basin: BasinName, name: String, config: StreamConfig, tx: mpsc::UnboundedSender) { + self.input_mode = InputMode::Normal; let s2 = self.s2.clone(); let tx_refresh = tx.clone(); let basin_clone = basin.clone(); @@ -3086,7 +3387,7 @@ impl App { basin: basin.clone(), stream: stream_name, }, - config: StreamConfig::default(), + config, }; match ops::create_stream(&s2, args).await { Ok(info) => { @@ -3492,12 +3793,15 @@ impl App { let timestamping_uncapped = config.timestamping.as_ref() .map(|t| t.uncapped) .unwrap_or(false); + let delete_on_empty_min_age_secs = config.delete_on_empty + .map(|d| d.min_age_secs); let info = StreamConfigInfo { storage_class, retention_age_secs, timestamping_mode, timestamping_uncapped, + delete_on_empty_min_age_secs, }; let _ = tx.send(Event::StreamConfigForReconfigLoaded(Ok(info))); } @@ -3596,13 +3900,21 @@ impl App { None }; + let delete_on_empty = if config.delete_on_empty_enabled { + humantime::parse_duration(&config.delete_on_empty_min_age) + .ok() + .map(|d| crate::types::DeleteOnEmptyConfig { delete_on_empty_min_age: d }) + } else { + None + }; + let args = ReconfigureStreamArgs { uri: S2BasinAndStreamUri { basin, stream }, config: StreamConfig { storage_class: config.storage_class, retention_policy, timestamping, - delete_on_empty: None, + delete_on_empty, }, }; match ops::reconfigure_stream(&s2, args).await { diff --git a/src/tui/event.rs b/src/tui/event.rs index 1d335a1..4ae30c4 100644 --- a/src/tui/event.rs +++ b/src/tui/event.rs @@ -22,6 +22,7 @@ pub struct StreamConfigInfo { pub retention_age_secs: Option, // None = infinite pub timestamping_mode: Option, pub timestamping_uncapped: bool, + pub delete_on_empty_min_age_secs: Option, // None = disabled } /// Events that can occur in the TUI diff --git a/src/tui/ui.rs b/src/tui/ui.rs index 6f5e933..fd6c77c 100644 --- a/src/tui/ui.rs +++ b/src/tui/ui.rs @@ -2689,23 +2689,253 @@ fn draw_input_dialog(f: &mut Frame, mode: &InputMode) { ) } - InputMode::CreateStream { basin, input } => ( - " Create Stream ", - vec![ - Line::from(""), - Line::from(vec![ - Span::styled("Basin: ", Style::default().fg(TEXT_MUTED)), - Span::styled(basin.to_string(), Style::default().fg(TEXT_SECONDARY)), - ]), - Line::from(""), - Line::from(vec![ - Span::styled("Stream: ", Style::default().fg(TEXT_MUTED)), - Span::styled(input, Style::default().fg(TEXT_PRIMARY)), - Span::styled("_", Style::default().fg(GREEN)), - ]), - ], - "enter confirm esc cancel", - ), + InputMode::CreateStream { + basin, + name, + storage_class, + retention_policy, + retention_age_input, + timestamping_mode, + timestamping_uncapped, + delete_on_empty_enabled, + delete_on_empty_min_age, + selected, + editing, + } => { + let cursor = "▎"; + + // Modern toggle switch rendering + let toggle = |on: bool, is_selected: bool| -> Vec> { + if on { + vec![ + Span::styled("", Style::default().fg(if is_selected { GREEN } else { Color::Rgb(60, 60, 60) })), + Span::styled(" ON ", Style::default().fg(BG_DARK).bg(GREEN).bold()), + Span::styled("▁▂▃", Style::default().fg(GREEN)), + ] + } else { + vec![ + Span::styled("▃▂▁", Style::default().fg(Color::Rgb(80, 80, 80))), + Span::styled(" OFF ", Style::default().fg(TEXT_MUTED).bg(Color::Rgb(50, 50, 50))), + Span::styled("", Style::default().fg(if is_selected { GREEN } else { Color::Rgb(60, 60, 60) })), + ] + } + }; + + // Pill-style option renderer + let pill = |label: &str, is_row_selected: bool, is_active: bool| -> Span<'static> { + if is_active { + Span::styled( + format!(" {} ", label), + Style::default() + .fg(BG_DARK) + .bg(if is_row_selected { GREEN } else { Color::Rgb(120, 120, 120) }) + .bold() + ) + } else { + Span::styled( + format!(" {} ", label), + Style::default() + .fg(if is_row_selected { TEXT_PRIMARY } else { Color::Rgb(80, 80, 80) }) + ) + } + }; + + // Field row helper with selection indicator + let field_row = |field_idx: usize, label: &str, current_selected: usize| -> (Span<'static>, Span<'static>) { + let is_selected = field_idx == current_selected; + let indicator = if is_selected { + Span::styled(" > ", Style::default().fg(GREEN).bold()) + } else { + Span::raw(" ") + }; + let label_span = Span::styled( + format!("{:<15}", label), + Style::default().fg(if is_selected { TEXT_PRIMARY } else { TEXT_MUTED }) + ); + (indicator, label_span) + }; + + // Storage options + let storage_opts = [ + ("Default", storage_class.is_none()), + ("Standard", matches!(storage_class, Some(StorageClass::Standard))), + ("Express", matches!(storage_class, Some(StorageClass::Express))), + ]; + + // Timestamping mode options + let ts_opts = [ + ("Default", timestamping_mode.is_none()), + ("ClientPrefer", matches!(timestamping_mode, Some(TimestampingMode::ClientPrefer))), + ("ClientRequire", matches!(timestamping_mode, Some(TimestampingMode::ClientRequire))), + ("Arrival", matches!(timestamping_mode, Some(TimestampingMode::Arrival))), + ]; + + // Retention options + let ret_opts = [ + ("Infinite", *retention_policy == RetentionPolicyOption::Infinite), + ("Age-based", *retention_policy == RetentionPolicyOption::Age), + ]; + + let mut lines = vec![]; + + // ═══════════════════════════════════════════════════════════════ + // STREAM NAME SECTION + // ═══════════════════════════════════════════════════════════════ + lines.push(Line::from("")); + lines.push(Line::from(vec![ + Span::styled(" Stream name ", Style::default().fg(CYAN).bold()), + Span::styled("─".repeat(34), Style::default().fg(Color::Rgb(50, 50, 50))), + ])); + lines.push(Line::from("")); + + // Show which basin this stream will be created in + lines.push(Line::from(vec![ + Span::raw(" "), + Span::styled("in basin: ", Style::default().fg(Color::Rgb(80, 80, 80))), + Span::styled(basin.to_string(), Style::default().fg(TEXT_SECONDARY)), + ])); + lines.push(Line::from("")); + + // Stream Name + let (ind, lbl) = field_row(0, "Name", *selected); + let name_display = if name.is_empty() { + Span::styled("enter name...", Style::default().fg(Color::Rgb(80, 80, 80)).italic()) + } else { + Span::styled(name.clone(), Style::default().fg(GREEN)) + }; + let cursor_span = if *selected == 0 && *editing { + Span::styled(cursor, Style::default().fg(GREEN)) + } else { + Span::raw("") + }; + + lines.push(Line::from(vec![ind, lbl, Span::raw(" "), name_display, cursor_span])); + + // ═══════════════════════════════════════════════════════════════ + // STREAM CONFIGURATION SECTION + // ═══════════════════════════════════════════════════════════════ + lines.push(Line::from("")); + lines.push(Line::from(vec![ + Span::styled(" Stream configuration ", Style::default().fg(CYAN).bold()), + Span::styled("─".repeat(25), Style::default().fg(Color::Rgb(50, 50, 50))), + ])); + lines.push(Line::from("")); + + // Storage Class + let (ind, lbl) = field_row(1, "Storage", *selected); + let mut storage_spans = vec![ind, lbl, Span::raw(" ")]; + for (label, active) in &storage_opts { + storage_spans.push(pill(label, *selected == 1, *active)); + storage_spans.push(Span::raw(" ")); + } + lines.push(Line::from(storage_spans)); + + // Retention + lines.push(Line::from("")); + let (ind, lbl) = field_row(2, "Retention", *selected); + let mut ret_spans = vec![ind, lbl, Span::raw(" ")]; + for (label, active) in &ret_opts { + ret_spans.push(pill(label, *selected == 2, *active)); + ret_spans.push(Span::raw(" ")); + } + lines.push(Line::from(ret_spans)); + + // Retention Age (conditional) + if *retention_policy == RetentionPolicyOption::Age { + let (ind, lbl) = field_row(3, " Duration", *selected); + let age_cursor = if *selected == 3 && *editing { cursor } else { "" }; + lines.push(Line::from(vec![ + ind, lbl, Span::raw(" "), + Span::styled(retention_age_input.clone(), Style::default().fg(YELLOW)), + Span::styled(age_cursor, Style::default().fg(GREEN)), + Span::styled(" e.g. 7d, 30d, 1y", Style::default().fg(Color::Rgb(80, 80, 80)).italic()), + ])); + } + + // Timestamping Mode + lines.push(Line::from("")); + let (ind, lbl) = field_row(4, "Timestamps", *selected); + let mut ts_spans = vec![ind, lbl, Span::raw(" ")]; + for (label, active) in &ts_opts { + ts_spans.push(pill(label, *selected == 4, *active)); + ts_spans.push(Span::raw(" ")); + } + lines.push(Line::from(ts_spans)); + + // Uncapped Timestamps + let (ind, lbl) = field_row(5, " Uncapped", *selected); + let mut uncapped_spans = vec![ind, lbl, Span::raw(" ")]; + uncapped_spans.extend(toggle(*timestamping_uncapped, *selected == 5)); + lines.push(Line::from(uncapped_spans)); + + // Delete on Empty + let delete_opts = [ + ("Never", !*delete_on_empty_enabled), + ("After threshold", *delete_on_empty_enabled), + ]; + lines.push(Line::from("")); + let (ind, lbl) = field_row(6, "Delete on empty", *selected); + let mut del_spans = vec![ind, lbl, Span::raw(" ")]; + for (label, active) in &delete_opts { + del_spans.push(pill(label, *selected == 6, *active)); + del_spans.push(Span::raw(" ")); + } + lines.push(Line::from(del_spans)); + + // Delete on Empty Threshold (conditional) + if *delete_on_empty_enabled { + let (ind, lbl) = field_row(7, " Threshold", *selected); + let age_cursor = if *selected == 7 && *editing { cursor } else { "" }; + lines.push(Line::from(vec![ + ind, lbl, Span::raw(" "), + Span::styled(delete_on_empty_min_age.clone(), Style::default().fg(YELLOW)), + Span::styled(age_cursor, Style::default().fg(GREEN)), + Span::styled(" e.g. 1h, 7d", Style::default().fg(Color::Rgb(80, 80, 80)).italic()), + ])); + } + + // ═══════════════════════════════════════════════════════════════ + // CREATE BUTTON + // ═══════════════════════════════════════════════════════════════ + lines.push(Line::from("")); + lines.push(Line::from(vec![ + Span::styled("─".repeat(52), Style::default().fg(Color::Rgb(50, 50, 50))), + ])); + lines.push(Line::from("")); + + let can_create = !name.is_empty(); + let btn_style = if *selected == 8 && can_create { + Style::default().fg(BG_DARK).bg(GREEN).bold() + } else if can_create { + Style::default().fg(GREEN).bold() + } else { + Style::default().fg(Color::Rgb(80, 80, 80)) + }; + + let btn_indicator = if *selected == 8 { + Span::styled(" > ", Style::default().fg(GREEN).bold()) + } else { + Span::raw(" ") + }; + + lines.push(Line::from(vec![ + btn_indicator, + Span::styled(" CREATE STREAM ", btn_style), + if !can_create { + Span::styled(" (enter stream name)", Style::default().fg(Color::Rgb(80, 80, 80)).italic()) + } else { + Span::raw("") + }, + ])); + + lines.push(Line::from("")); + + ( + " Create Stream ", + lines, + "j/k navigate | h/l cycle | Space toggle | Enter edit | Esc cancel", + ) + } InputMode::ConfirmDeleteBasin { basin } => ( " Delete Basin ", @@ -2929,6 +3159,8 @@ fn draw_input_dialog(f: &mut Frame, mode: &InputMode) { retention_age_secs, timestamping_mode, timestamping_uncapped, + delete_on_empty_enabled, + delete_on_empty_min_age, selected, editing_age, age_input, @@ -2941,26 +3173,33 @@ fn draw_input_dialog(f: &mut Frame, mode: &InputMode) { vec![ Span::styled("", Style::default().fg(if is_selected { GREEN } else { Color::Rgb(60, 60, 60) })), Span::styled(" ON ", Style::default().fg(BG_DARK).bg(GREEN).bold()), - Span::styled("", Style::default().fg(GREEN)), + Span::styled("▁▂▃", Style::default().fg(GREEN)), ] } else { vec![ - Span::styled("", Style::default().fg(if is_selected { TEXT_MUTED } else { Color::Rgb(60, 60, 60) })), - Span::styled(" OFF ", Style::default().fg(TEXT_MUTED).bg(Color::Rgb(60, 60, 60))), - Span::styled("", Style::default().fg(Color::Rgb(60, 60, 60))), + Span::styled("▃▂▁", Style::default().fg(Color::Rgb(80, 80, 80))), + Span::styled(" OFF ", Style::default().fg(TEXT_MUTED).bg(Color::Rgb(50, 50, 50))), + Span::styled("", Style::default().fg(if is_selected { GREEN } else { Color::Rgb(60, 60, 60) })), ] } }; // Pill-style selector - let pill = |label: &str, is_selected: bool, is_active: bool| -> Span<'static> { - let label = label.to_string(); + let pill = |label: &str, is_row_selected: bool, is_active: bool| -> Span<'static> { if is_active { - Span::styled(format!(" {} ", label), Style::default().fg(BG_DARK).bg(GREEN).bold()) - } else if is_selected { - Span::styled(format!(" {} ", label), Style::default().fg(TEXT_PRIMARY).bg(Color::Rgb(50, 50, 50))) + Span::styled( + format!(" {} ", label), + Style::default() + .fg(BG_DARK) + .bg(if is_row_selected { GREEN } else { Color::Rgb(120, 120, 120) }) + .bold() + ) } else { - Span::styled(format!(" {} ", label), Style::default().fg(TEXT_MUTED)) + Span::styled( + format!(" {} ", label), + Style::default() + .fg(if is_row_selected { TEXT_PRIMARY } else { Color::Rgb(80, 80, 80) }) + ) } }; @@ -2973,11 +3212,11 @@ fn draw_input_dialog(f: &mut Frame, mode: &InputMode) { Span::styled(" ", Style::default()) }; let label_style = if is_sel { - Style::default().fg(TEXT_PRIMARY).bold() + Style::default().fg(TEXT_PRIMARY) } else { Style::default().fg(TEXT_MUTED) }; - (indicator, Span::styled(label.to_string(), label_style)) + (indicator, Span::styled(format!("{:<15}", label), label_style)) }; // Options @@ -3037,7 +3276,7 @@ fn draw_input_dialog(f: &mut Frame, mode: &InputMode) { if *retention_policy == RetentionPolicyOption::Age { let (ind, lbl) = field_row(2, " Duration", *selected); let age_cursor = if *selected == 2 && *editing_age { cursor } else { "" }; - let age_display = if *editing_age { age_input.clone() } else { format!("{}s", retention_age_secs) }; + let age_display = if *selected == 2 && *editing_age { age_input.clone() } else { format!("{}s", retention_age_secs) }; lines.push(Line::from(vec![ ind, lbl, Span::raw(" "), Span::styled(age_display, Style::default().fg(YELLOW)), @@ -3062,6 +3301,32 @@ fn draw_input_dialog(f: &mut Frame, mode: &InputMode) { uncapped_spans.extend(toggle(timestamping_uncapped.unwrap_or(false), *selected == 4)); lines.push(Line::from(uncapped_spans)); + // Delete on Empty + let delete_opts = [ + ("Never", !*delete_on_empty_enabled), + ("After threshold", *delete_on_empty_enabled), + ]; + lines.push(Line::from("")); + let (ind, lbl) = field_row(5, "Delete on empty", *selected); + let mut del_spans = vec![ind, lbl, Span::raw(" ")]; + for (label, active) in &delete_opts { + del_spans.push(pill(label, *selected == 5, *active)); + del_spans.push(Span::raw(" ")); + } + lines.push(Line::from(del_spans)); + + // Delete on Empty Threshold (conditional) + if *delete_on_empty_enabled { + let (ind, lbl) = field_row(6, " Threshold", *selected); + let age_cursor = if *selected == 6 && *editing_age { cursor } else { "" }; + lines.push(Line::from(vec![ + ind, lbl, Span::raw(" "), + Span::styled(delete_on_empty_min_age.clone(), Style::default().fg(YELLOW)), + Span::styled(age_cursor, Style::default().fg(GREEN)), + Span::styled(" e.g. 1h, 7d", Style::default().fg(Color::Rgb(80, 80, 80)).italic()), + ])); + } + lines.push(Line::from("")); ( From 38db03db8427a89689376b87c0b3214b4282fd02 Mon Sep 17 00:00:00 2001 From: Mehul Arora Date: Sat, 24 Jan 2026 00:49:38 -0500 Subject: [PATCH 12/31] . --- src/tui/app.rs | 410 +++++++++++++++++++++-- src/tui/mod.rs | 9 +- src/tui/ui.rs | 869 +++++++++++++++++++++++++++++++++++++++++-------- 3 files changed, 1116 insertions(+), 172 deletions(-) diff --git a/src/tui/app.rs b/src/tui/app.rs index 9894d85..f594b47 100644 --- a/src/tui/app.rs +++ b/src/tui/app.rs @@ -9,6 +9,7 @@ use s2_sdk::types::{AccessTokenId, AccessTokenInfo, BasinInfo, BasinMetricSet, B use tokio::sync::mpsc; use crate::cli::{CreateStreamArgs, IssueAccessTokenArgs, ListAccessTokensArgs, ListBasinsArgs, ListStreamsArgs, ReadArgs, ReconfigureBasinArgs, ReconfigureStreamArgs}; +use crate::config::{self, ConfigKey, Compression}; use crate::error::CliError; use crate::ops; use crate::record_format::{RecordFormat, RecordsOut}; @@ -26,12 +27,14 @@ pub enum Tab { #[default] Basins, AccessTokens, + Settings, } /// Current screen being displayed #[derive(Debug, Clone)] pub enum Screen { Splash, + Setup(SetupState), Basins(BasinsState), Streams(StreamsState), StreamDetail(StreamDetailState), @@ -39,6 +42,15 @@ pub enum Screen { AppendView(AppendViewState), AccessTokens(AccessTokensState), MetricsView(MetricsViewState), + Settings(SettingsState), +} + +/// State for the setup screen (first-time token entry) +#[derive(Debug, Clone, Default)] +pub struct SetupState { + pub access_token: String, + pub error: Option, + pub validating: bool, } /// State for the basins list screen @@ -127,6 +139,71 @@ pub struct AccessTokensState { pub filter_active: bool, } +/// Compression option for settings +#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)] +pub enum CompressionOption { + #[default] + None, + Gzip, + Zstd, +} + +impl CompressionOption { + pub fn as_str(&self) -> &'static str { + match self { + CompressionOption::None => "None", + CompressionOption::Gzip => "Gzip", + CompressionOption::Zstd => "Zstd", + } + } + + pub fn next(&self) -> Self { + match self { + CompressionOption::None => CompressionOption::Gzip, + CompressionOption::Gzip => CompressionOption::Zstd, + CompressionOption::Zstd => CompressionOption::None, + } + } + + pub fn prev(&self) -> Self { + match self { + CompressionOption::None => CompressionOption::Zstd, + CompressionOption::Gzip => CompressionOption::None, + CompressionOption::Zstd => CompressionOption::Gzip, + } + } +} + +/// State for the settings screen +#[derive(Debug, Clone)] +pub struct SettingsState { + pub access_token: String, + pub access_token_masked: bool, // Whether to show masked or plaintext + pub account_endpoint: String, + pub basin_endpoint: String, + pub compression: CompressionOption, + pub selected: usize, // 0=token, 1=account_endpoint, 2=basin_endpoint, 3=compression + pub editing: bool, + pub has_changes: bool, + pub message: Option, +} + +impl Default for SettingsState { + fn default() -> Self { + Self { + access_token: String::new(), + access_token_masked: true, + account_endpoint: String::new(), + basin_endpoint: String::new(), + compression: CompressionOption::None, + selected: 0, + editing: false, + has_changes: false, + message: None, + } + } +} + /// Type of metrics being viewed #[derive(Debug, Clone)] pub enum MetricsType { @@ -610,7 +687,7 @@ impl Default for InputMode { pub struct App { pub screen: Screen, pub tab: Tab, - pub s2: s2_sdk::S2, + pub s2: Option, pub message: Option, pub show_help: bool, pub input_mode: InputMode, @@ -717,9 +794,14 @@ fn build_stream_config( } impl App { - pub fn new(s2: s2_sdk::S2) -> Self { + pub fn new(s2: Option) -> Self { + let screen = if s2.is_some() { + Screen::Splash + } else { + Screen::Setup(SetupState::default()) + }; Self { - screen: Screen::Splash, + screen, tab: Tab::Basins, s2, message: None, @@ -729,6 +811,97 @@ impl App { } } + /// Create an S2 client from the given access token + fn create_s2_client(access_token: &str) -> Result { + let sdk_config = s2_sdk::types::S2Config::new(access_token) + .with_user_agent("s2-cli") + .map_err(|e| CliError::EndpointsFromEnv(e.to_string()))? + .with_request_timeout(Duration::from_secs(30)); + s2_sdk::S2::new(sdk_config).map_err(CliError::SdkInit) + } + + /// Load settings from config file + fn load_settings_state() -> SettingsState { + // Load from file first + let file_config = config::load_config_file().unwrap_or_default(); + // Also load from environment (which load_cli_config does) + let env_config = config::load_cli_config().unwrap_or_default(); + + // Prefer file config for display, but show env token if file is empty + let access_token = file_config.access_token.clone() + .or_else(|| env_config.access_token.clone()) + .unwrap_or_default(); + + // Track if token is from env (read-only in that case) + let token_from_env = file_config.access_token.is_none() && env_config.access_token.is_some(); + + SettingsState { + access_token, + access_token_masked: true, + account_endpoint: file_config.account_endpoint.unwrap_or_default(), + basin_endpoint: file_config.basin_endpoint.unwrap_or_default(), + compression: match file_config.compression { + Some(Compression::Gzip) => CompressionOption::Gzip, + Some(Compression::Zstd) => CompressionOption::Zstd, + None => CompressionOption::None, + }, + selected: 0, + editing: false, + has_changes: false, + message: if token_from_env { + Some("Token loaded from S2_ACCESS_TOKEN env var".to_string()) + } else { + None + }, + } + } + + /// Save settings to config file + fn save_settings_static(state: &SettingsState) -> Result<(), CliError> { + let mut cli_config = config::load_config_file().unwrap_or_default(); + + // Update access token + if state.access_token.is_empty() { + cli_config.unset(ConfigKey::AccessToken); + } else { + cli_config.set(ConfigKey::AccessToken, state.access_token.clone()) + .map_err(|e| CliError::Config(e))?; + } + + // Update account endpoint + if state.account_endpoint.is_empty() { + cli_config.unset(ConfigKey::AccountEndpoint); + } else { + cli_config.set(ConfigKey::AccountEndpoint, state.account_endpoint.clone()) + .map_err(|e| CliError::Config(e))?; + } + + // Update basin endpoint + if state.basin_endpoint.is_empty() { + cli_config.unset(ConfigKey::BasinEndpoint); + } else { + cli_config.set(ConfigKey::BasinEndpoint, state.basin_endpoint.clone()) + .map_err(|e| CliError::Config(e))?; + } + + // Update compression + match state.compression { + CompressionOption::None => cli_config.unset(ConfigKey::Compression), + CompressionOption::Gzip => { + cli_config.set(ConfigKey::Compression, "gzip".to_string()) + .map_err(|e| CliError::Config(e))?; + } + CompressionOption::Zstd => { + cli_config.set(ConfigKey::Compression, "zstd".to_string()) + .map_err(|e| CliError::Config(e))?; + } + } + + config::save_cli_config(&cli_config) + .map_err(|e| CliError::Config(e))?; + Ok(()) + } + pub async fn run(mut self, terminal: &mut Terminal) -> Result<(), CliError> { let (tx, mut rx) = mpsc::unbounded_channel(); @@ -736,8 +909,10 @@ impl App { let splash_start = std::time::Instant::now(); let splash_duration = Duration::from_millis(1200); - // Start loading basins in background - self.load_basins(tx.clone()); + // Only start loading basins if we have an S2 client (access token configured) + if self.s2.is_some() { + self.load_basins(tx.clone()); + } // Track loaded basins for transition from splash let mut pending_basins: Option, CliError>> = None; @@ -1379,7 +1554,7 @@ impl App { // Tab key to switch between tabs (only on top-level screens) if key.code == KeyCode::Tab { match &self.screen { - Screen::Basins(_) | Screen::AccessTokens(_) => { + Screen::Basins(_) | Screen::AccessTokens(_) | Screen::Settings(_) => { self.switch_tab(tx.clone()); return; } @@ -1390,6 +1565,7 @@ impl App { // Screen-specific keys - handle in place to avoid borrow issues match &self.screen { Screen::Splash => {} // Keys handled in run loop + Screen::Setup(_) => self.handle_setup_key(key, tx), Screen::Basins(_) => self.handle_basins_key(key, tx), Screen::Streams(_) => self.handle_streams_key(key, tx), Screen::StreamDetail(_) => self.handle_stream_detail_key(key, tx), @@ -1397,6 +1573,7 @@ impl App { Screen::AppendView(_) => self.handle_append_view_key(key, tx), Screen::AccessTokens(_) => self.handle_access_tokens_key(key, tx), Screen::MetricsView(_) => self.handle_metrics_view_key(key, tx), + Screen::Settings(_) => self.handle_settings_key(key, tx), } } @@ -3195,7 +3372,10 @@ impl App { } fn load_basins(&self, tx: mpsc::UnboundedSender) { - let s2 = self.s2.clone(); + let Some(s2) = self.s2.clone() else { + let _ = tx.send(Event::BasinsLoaded(Err(CliError::Config(crate::error::CliConfigError::MissingAccessToken)))); + return; + }; tokio::spawn(async move { let args = ListBasinsArgs { prefix: None, @@ -3220,7 +3400,10 @@ impl App { } fn load_streams(&self, basin_name: BasinName, tx: mpsc::UnboundedSender) { - let s2 = self.s2.clone(); + let Some(s2) = self.s2.clone() else { + let _ = tx.send(Event::StreamsLoaded(Err(CliError::Config(crate::error::CliConfigError::MissingAccessToken)))); + return; + }; tokio::spawn(async move { let args = ListStreamsArgs { uri: S2BasinAndMaybeStreamUri { @@ -3254,7 +3437,7 @@ impl App { stream_name: StreamName, tx: mpsc::UnboundedSender, ) { - let s2 = self.s2.clone(); + let s2 = self.s2.clone().expect("S2 client not initialized"); let uri = S2BasinAndStreamUri { basin: basin_name.clone(), stream: stream_name.clone(), @@ -3291,7 +3474,7 @@ impl App { fn create_basin_with_config(&mut self, name: String, scope: BasinScopeOption, config: BasinConfig, tx: mpsc::UnboundedSender) { self.input_mode = InputMode::Normal; - let s2 = self.s2.clone(); + let s2 = self.s2.clone().expect("S2 client not initialized"); let tx_refresh = tx.clone(); tokio::spawn(async move { // Parse basin name @@ -3338,7 +3521,7 @@ impl App { } fn delete_basin(&mut self, basin: BasinName, tx: mpsc::UnboundedSender) { - let s2 = self.s2.clone(); + let s2 = self.s2.clone().expect("S2 client not initialized"); let tx_refresh = tx.clone(); let name = basin.to_string(); tokio::spawn(async move { @@ -3370,7 +3553,7 @@ impl App { fn create_stream_with_config(&mut self, basin: BasinName, name: String, config: StreamConfig, tx: mpsc::UnboundedSender) { self.input_mode = InputMode::Normal; - let s2 = self.s2.clone(); + let s2 = self.s2.clone().expect("S2 client not initialized"); let tx_refresh = tx.clone(); let basin_clone = basin.clone(); tokio::spawn(async move { @@ -3420,7 +3603,7 @@ impl App { } fn delete_stream(&mut self, basin: BasinName, stream: StreamName, tx: mpsc::UnboundedSender) { - let s2 = self.s2.clone(); + let s2 = self.s2.clone().expect("S2 client not initialized"); let tx_refresh = tx.clone(); let name = stream.to_string(); let basin_clone = basin.clone(); @@ -3479,7 +3662,7 @@ impl App { output_file: None, }); - let s2 = self.s2.clone(); + let s2 = self.s2.clone().expect("S2 client not initialized"); let uri = S2BasinAndStreamUri { basin: basin_name, stream: stream_name, @@ -3586,7 +3769,7 @@ impl App { output_file: if has_output { Some(output_file.clone()) } else { None }, }); - let s2 = self.s2.clone(); + let s2 = self.s2.clone().expect("S2 client not initialized"); let uri = S2BasinAndStreamUri { basin: basin_name, stream: stream_name, @@ -3733,7 +3916,7 @@ impl App { } fn load_basin_config(&self, basin: BasinName, tx: mpsc::UnboundedSender) { - let s2 = self.s2.clone(); + let s2 = self.s2.clone().expect("S2 client not initialized"); tokio::spawn(async move { match ops::get_basin_config(&s2, &basin).await { Ok(config) => { @@ -3778,7 +3961,7 @@ impl App { stream: StreamName, tx: mpsc::UnboundedSender, ) { - let s2 = self.s2.clone(); + let s2 = self.s2.clone().expect("S2 client not initialized"); let uri = S2BasinAndStreamUri { basin, stream }; tokio::spawn(async move { match ops::get_stream_config(&s2, uri).await { @@ -3818,7 +4001,7 @@ impl App { config: BasinReconfigureConfig, tx: mpsc::UnboundedSender, ) { - let s2 = self.s2.clone(); + let s2 = self.s2.clone().expect("S2 client not initialized"); let tx_refresh = tx.clone(); tokio::spawn(async move { // Build the default stream config @@ -3882,7 +4065,7 @@ impl App { config: StreamReconfigureConfig, tx: mpsc::UnboundedSender, ) { - let s2 = self.s2.clone(); + let s2 = self.s2.clone().expect("S2 client not initialized"); let basin_clone = basin.clone(); let tx_refresh = tx.clone(); tokio::spawn(async move { @@ -4120,7 +4303,7 @@ impl App { fencing_token: Option, tx: mpsc::UnboundedSender, ) { - let s2 = self.s2.clone(); + let s2 = self.s2.clone().expect("S2 client not initialized"); let body_preview = if body.len() > 50 { format!("{}...", &body[..50]) } else { @@ -4239,7 +4422,7 @@ impl App { current_token: Option, tx: mpsc::UnboundedSender, ) { - let s2 = self.s2.clone(); + let s2 = self.s2.clone().expect("S2 client not initialized"); let new_token_clone = new_token.clone(); tokio::spawn(async move { @@ -4313,7 +4496,7 @@ impl App { fencing_token: Option, tx: mpsc::UnboundedSender, ) { - let s2 = self.s2.clone(); + let s2 = self.s2.clone().expect("S2 client not initialized"); tokio::spawn(async move { use s2_sdk::types::{AppendInput, AppendRecordBatch, CommandRecord, FencingToken}; @@ -4378,6 +4561,10 @@ impl App { self.load_access_tokens(tx); } Tab::AccessTokens => { + self.tab = Tab::Settings; + self.screen = Screen::Settings(Self::load_settings_state()); + } + Tab::Settings => { self.tab = Tab::Basins; self.screen = Screen::Basins(BasinsState { loading: true, @@ -4501,9 +4688,176 @@ impl App { } } + /// Handle keys on setup screen (first-time token entry) + fn handle_setup_key(&mut self, key: KeyEvent, tx: mpsc::UnboundedSender) { + let Screen::Setup(state) = &mut self.screen else { + return; + }; + + match key.code { + KeyCode::Char('q') if key.modifiers.contains(KeyModifiers::CONTROL) => { + self.should_quit = true; + } + KeyCode::Esc => { + self.should_quit = true; + } + KeyCode::Enter => { + if state.access_token.is_empty() { + state.error = Some("Access token is required".to_string()); + return; + } + + // Try to create S2 client with the token + state.validating = true; + state.error = None; + + match Self::create_s2_client(&state.access_token) { + Ok(s2) => { + // Save the token to config + if let Err(e) = config::set_config_value(ConfigKey::AccessToken, state.access_token.clone()) { + state.error = Some(format!("Failed to save config: {}", e)); + state.validating = false; + return; + } + + // Set the S2 client and transition to basins + self.s2 = Some(s2); + self.screen = Screen::Basins(BasinsState { + loading: true, + ..Default::default() + }); + self.load_basins(tx); + self.message = Some(StatusMessage { + text: "Access token configured successfully".to_string(), + level: MessageLevel::Success, + }); + } + Err(e) => { + state.error = Some(format!("Invalid token: {}", e)); + state.validating = false; + } + } + } + KeyCode::Backspace => { + state.access_token.pop(); + state.error = None; + } + KeyCode::Char(c) => { + state.access_token.push(c); + state.error = None; + } + _ => {} + } + } + + /// Handle keys on settings screen + fn handle_settings_key(&mut self, key: KeyEvent, _tx: mpsc::UnboundedSender) { + let Screen::Settings(state) = &mut self.screen else { + return; + }; + + // Handle editing mode + if state.editing { + match key.code { + KeyCode::Esc => { + state.editing = false; + } + KeyCode::Enter => { + state.editing = false; + state.has_changes = true; + } + KeyCode::Backspace => { + match state.selected { + 0 => { state.access_token.pop(); } + 1 => { state.account_endpoint.pop(); } + 2 => { state.basin_endpoint.pop(); } + _ => {} + } + state.has_changes = true; + } + KeyCode::Char(c) => { + match state.selected { + 0 => state.access_token.push(c), + 1 => state.account_endpoint.push(c), + 2 => state.basin_endpoint.push(c), + _ => {} + } + state.has_changes = true; + } + _ => {} + } + return; + } + + match key.code { + KeyCode::Char('q') => { + self.should_quit = true; + } + KeyCode::Char('j') | KeyCode::Down => { + if state.selected < 4 { // 0=token, 1=account, 2=basin, 3=compression, 4=save + state.selected += 1; + } + } + KeyCode::Char('k') | KeyCode::Up => { + if state.selected > 0 { + state.selected -= 1; + } + } + KeyCode::Char('e') | KeyCode::Enter if state.selected < 3 => { + // Edit text fields + state.editing = true; + } + KeyCode::Char('h') | KeyCode::Left if state.selected == 3 => { + // Cycle compression option backwards + state.compression = state.compression.prev(); + state.has_changes = true; + } + KeyCode::Char('l') | KeyCode::Right if state.selected == 3 => { + // Cycle compression option forwards + state.compression = state.compression.next(); + state.has_changes = true; + } + KeyCode::Char(' ') if state.selected == 0 => { + // Toggle token visibility + state.access_token_masked = !state.access_token_masked; + } + KeyCode::Enter if state.selected == 4 => { + // Save settings - clone state to avoid borrow issues + let state_clone = state.clone(); + match Self::save_settings_static(&state_clone) { + Err(e) => { + state.message = Some(format!("Failed to save: {}", e)); + } + Ok(()) => { + state.has_changes = false; + state.message = Some("Settings saved successfully".to_string()); + + // If access token changed, recreate S2 client + if !state.access_token.is_empty() { + match Self::create_s2_client(&state.access_token) { + Ok(s2) => { + self.s2 = Some(s2); + } + Err(e) => { + state.message = Some(format!("Token saved but client error: {}", e)); + } + } + } + } + } + } + KeyCode::Char('r') => { + // Reload settings from file + *state = Self::load_settings_state(); + state.message = Some("Settings reloaded".to_string()); + } + _ => {} + } + } + /// Load access tokens fn load_access_tokens(&self, tx: mpsc::UnboundedSender) { - let s2 = self.s2.clone(); + let s2 = self.s2.clone().expect("S2 client not initialized"); tokio::spawn(async move { let args = ListAccessTokensArgs { prefix: None, @@ -4549,7 +4903,7 @@ impl App { auto_prefix_streams: bool, tx: mpsc::UnboundedSender, ) { - let s2 = self.s2.clone(); + let s2 = self.s2.clone().expect("S2 client not initialized"); let tx_refresh = tx.clone(); tokio::spawn(async move { @@ -4693,7 +5047,7 @@ impl App { /// Revoke an access token fn revoke_access_token(&self, id: String, tx: mpsc::UnboundedSender) { - let s2 = self.s2.clone(); + let s2 = self.s2.clone().expect("S2 client not initialized"); let tx_refresh = tx.clone(); tokio::spawn(async move { @@ -4775,7 +5129,7 @@ impl App { fn load_account_metrics(&self, category: MetricCategory, tx: mpsc::UnboundedSender) { use s2_sdk::types::AccountMetricSet; - let s2 = self.s2.clone(); + let s2 = self.s2.clone().expect("S2 client not initialized"); tokio::spawn(async move { // Get metrics for last 24 hours @@ -4809,7 +5163,7 @@ impl App { } fn load_basin_metrics(&self, basin_name: BasinName, category: MetricCategory, tx: mpsc::UnboundedSender) { - let s2 = self.s2.clone(); + let s2 = self.s2.clone().expect("S2 client not initialized"); tokio::spawn(async move { // Get metrics for last 24 hours @@ -4853,7 +5207,7 @@ impl App { /// Load stream metrics fn load_stream_metrics(&self, basin_name: BasinName, stream_name: StreamName, tx: mpsc::UnboundedSender) { - let s2 = self.s2.clone(); + let s2 = self.s2.clone().expect("S2 client not initialized"); tokio::spawn(async move { // Get metrics for last 24 hours diff --git a/src/tui/mod.rs b/src/tui/mod.rs index 09e1912..5f3b9c7 100644 --- a/src/tui/mod.rs +++ b/src/tui/mod.rs @@ -18,10 +18,13 @@ use crate::error::CliError; use app::App; pub async fn run() -> Result<(), CliError> { - // Load config and create SDK client + // Load config and try to create SDK client + // If access token is missing, we'll start with Setup screen instead of failing let cli_config = load_cli_config()?; - let sdk_config = sdk_config(&cli_config)?; - let s2 = s2_sdk::S2::new(sdk_config).map_err(CliError::SdkInit)?; + let s2 = match sdk_config(&cli_config) { + Ok(sdk_cfg) => Some(s2_sdk::S2::new(sdk_cfg).map_err(CliError::SdkInit)?), + Err(_) => None, // No access token - will show setup screen + }; // Setup terminal enable_raw_mode().map_err(|e| CliError::RecordWrite(format!("Failed to enable raw mode: {e}")))?; diff --git a/src/tui/ui.rs b/src/tui/ui.rs index fd6c77c..76b92e8 100644 --- a/src/tui/ui.rs +++ b/src/tui/ui.rs @@ -8,11 +8,10 @@ use ratatui::{ use crate::types::{StorageClass, TimestampingMode}; -use super::app::{AccessTokensState, App, AgoUnit, AppendViewState, BasinsState, ExpiryOption, InputMode, MessageLevel, MetricCategory, MetricsType, MetricsViewState, ReadStartFrom, ReadViewState, RetentionPolicyOption, ScopeOption, Screen, StreamDetailState, StreamsState, Tab}; +use super::app::{AccessTokensState, App, AgoUnit, AppendViewState, BasinsState, CompressionOption, ExpiryOption, InputMode, MessageLevel, MetricCategory, MetricsType, MetricsViewState, ReadStartFrom, ReadViewState, RetentionPolicyOption, ScopeOption, Screen, SettingsState, SetupState, StreamDetailState, StreamsState, Tab}; // S2 Console dark theme const GREEN: Color = Color::Rgb(34, 197, 94); // Active green -const GREEN_DIM: Color = Color::Rgb(22, 163, 74); // Dimmer green const YELLOW: Color = Color::Rgb(250, 204, 21); // Warning yellow const RED: Color = Color::Rgb(239, 68, 68); // Error red const CYAN: Color = Color::Rgb(34, 211, 238); // Cyan accent @@ -43,8 +42,16 @@ pub fn draw(f: &mut Frame, app: &App) { return; } + // Setup screen uses full area (no tabs or status bar) + if matches!(app.screen, Screen::Setup(_)) { + if let Screen::Setup(state) = &app.screen { + draw_setup(f, area, state); + } + return; + } + // Check if we should show tabs (only on top-level screens) - let show_tabs = matches!(app.screen, Screen::Basins(_) | Screen::AccessTokens(_)); + let show_tabs = matches!(app.screen, Screen::Basins(_) | Screen::AccessTokens(_) | Screen::Settings(_)); let chunks = if show_tabs { Layout::default() @@ -76,6 +83,7 @@ pub fn draw(f: &mut Frame, app: &App) { // Draw main content based on screen match &app.screen { Screen::Splash => unreachable!(), + Screen::Setup(_) => unreachable!(), // Handled above Screen::Basins(state) => draw_basins(f, chunks[1], state), Screen::Streams(state) => draw_streams(f, chunks[1], state), Screen::StreamDetail(state) => draw_stream_detail(f, chunks[1], state), @@ -83,6 +91,7 @@ pub fn draw(f: &mut Frame, app: &App) { Screen::AppendView(state) => draw_append_view(f, chunks[1], state), Screen::AccessTokens(state) => draw_access_tokens(f, chunks[1], state), Screen::MetricsView(state) => draw_metrics_view(f, chunks[1], state), + Screen::Settings(state) => draw_settings(f, chunks[1], state), } // Draw status bar @@ -158,6 +167,341 @@ fn draw_splash(f: &mut Frame, area: Rect) { f.render_widget(logo_widget, centered_area); } +/// Draw the setup screen (first-time token entry) +fn draw_setup(f: &mut Frame, area: Rect, state: &SetupState) { + // Draw aurora background + draw_aurora_background(f, area); + + // S2 logo (same as splash) + let logo = vec![ + " █████████████████████████ ", + " ██████████████████████████████ ", + " ███████████████████████████████ ", + "█████████████████████████████████", + "█████████████████████████████████ ", + "███████████████ ", + "███████████████ ", + "██████████████ ████████████████", + "██████████████ ████████████████", + "██████████████ ████████████████", + "███████████████ ███████", + "██████████████████ █████", + "█████████████████████████ ████", + "█████████████████████████ █████", + "██████ ██████", + "█████ ████████", + " ███ ██████████████████████ ", + " ██ ██████████████████████ ", + " ████████████████████ ", + ]; + + // Build content + let mut lines: Vec = logo + .iter() + .map(|&line| Line::from(Span::styled(line, Style::default().fg(Color::White)))) + .collect(); + + // Tagline + lines.push(Line::from("")); + lines.push(Line::from(Span::styled( + "Streams as a cloud storage primitive", + Style::default().fg(WHITE).bold(), + ))); + lines.push(Line::from(Span::styled( + "The serverless API for unlimited, durable, real-time streams.", + Style::default().fg(TEXT_MUTED), + ))); + + // Spacer + lines.push(Line::from("")); + lines.push(Line::from("")); + + // Token input - minimal style + let token_display = if state.access_token.is_empty() { + vec![ + Span::styled("Token ", Style::default().fg(TEXT_MUTED)), + Span::styled("› ", Style::default().fg(BORDER)), + Span::styled("_", Style::default().fg(CYAN)), + ] + } else { + let display = if state.access_token.len() > 40 { + format!("{}...", &state.access_token[..40]) + } else { + state.access_token.clone() + }; + vec![ + Span::styled("Token ", Style::default().fg(TEXT_MUTED)), + Span::styled("› ", Style::default().fg(GREEN)), + Span::styled(display, Style::default().fg(WHITE)), + Span::styled("_", Style::default().fg(CYAN)), + ] + }; + lines.push(Line::from(token_display)); + + // Status/error line + lines.push(Line::from("")); + if let Some(error) = &state.error { + lines.push(Line::from(Span::styled( + error.as_str(), + Style::default().fg(ERROR), + ))); + } else if state.validating { + lines.push(Line::from(Span::styled( + "Validating...", + Style::default().fg(YELLOW), + ))); + } else { + lines.push(Line::from(vec![ + Span::styled("Get token: ", Style::default().fg(TEXT_MUTED)), + Span::styled("s2.dev/dashboard/access-tokens", Style::default().fg(CYAN)), + ])); + } + + // Footer hint + lines.push(Line::from("")); + lines.push(Line::from(Span::styled( + "Enter to continue · Esc to quit", + Style::default().fg(BORDER), + ))); + + let content_height = lines.len() as u16; + let y = area.y + area.height.saturating_sub(content_height) / 2; + let centered_area = Rect::new(area.x, y, area.width, content_height); + + let content = Paragraph::new(lines).alignment(Alignment::Center); + f.render_widget(content, centered_area); +} + +/// Draw the settings screen +fn draw_settings(f: &mut Frame, area: Rect, state: &SettingsState) { + use ratatui::widgets::BorderType; + + // Layout: Title bar + content + let chunks = Layout::default() + .direction(Direction::Vertical) + .constraints([ + Constraint::Length(3), // Title bar (consistent height) + Constraint::Min(1), // Content + ]) + .split(area); + + // === TITLE BAR === + let title_block = Block::default() + .borders(Borders::BOTTOM) + .border_style(Style::default().fg(BORDER)) + .style(Style::default().bg(BG_PANEL)); + let title_content = Paragraph::new(Line::from(vec![ + Span::styled(" ⚙ ", Style::default().fg(GREEN)), + Span::styled("Settings", Style::default().fg(TEXT_PRIMARY).bold()), + ])) + .block(title_block); + f.render_widget(title_content, chunks[0]); + + // === CONTENT === + let content_area = chunks[1]; + + // Create centered settings panel + let panel_width = 70.min(content_area.width.saturating_sub(4)); + let panel_x = content_area.x + (content_area.width.saturating_sub(panel_width)) / 2; + let panel_area = Rect::new(panel_x, content_area.y + 1, panel_width, content_area.height.saturating_sub(2)); + + let settings_block = Block::default() + .borders(Borders::ALL) + .border_type(BorderType::Rounded) + .border_style(Style::default().fg(BORDER)) + .style(Style::default().bg(BG_PANEL)) + .padding(Padding::new(2, 2, 1, 1)); + let inner = settings_block.inner(panel_area); + f.render_widget(settings_block, panel_area); + + // Settings fields + let field_chunks = Layout::default() + .direction(Direction::Vertical) + .constraints([ + Constraint::Length(4), // Access Token (label + 3-line bordered input) + Constraint::Length(4), // Account Endpoint + Constraint::Length(4), // Basin Endpoint + Constraint::Length(3), // Compression (label + pills, no border) + Constraint::Length(1), // Spacer + Constraint::Length(3), // Save button + Constraint::Min(1), // Message/footer + ]) + .split(inner); + + // Access Token field + // Always show actual value when editing, otherwise respect mask flag + let is_editing_token = state.editing && state.selected == 0; + let token_display = if is_editing_token { + // When editing, always show actual value + state.access_token.clone() + } else if state.access_token_masked && !state.access_token.is_empty() { + format!("{}...", "*".repeat(20.min(state.access_token.len()))) + } else if state.access_token.is_empty() { + "(not set)".to_string() + } else { + state.access_token.clone() + }; + + draw_settings_field( + f, + field_chunks[0], + "Access Token", + token_display, + state.selected == 0, + is_editing_token, + Some("Space to toggle visibility"), + ); + + // Account Endpoint field + draw_settings_field( + f, + field_chunks[1], + "Account Endpoint", + if state.account_endpoint.is_empty() { + "(default)".to_string() + } else { + state.account_endpoint.clone() + }, + state.selected == 1, + state.editing && state.selected == 1, + None, + ); + + // Basin Endpoint field + draw_settings_field( + f, + field_chunks[2], + "Basin Endpoint", + if state.basin_endpoint.is_empty() { + "(default)".to_string() + } else { + state.basin_endpoint.clone() + }, + state.selected == 2, + state.editing && state.selected == 2, + None, + ); + + // Compression field (pill selector) + let compression_label = Line::from(vec![ + Span::styled("Compression", Style::default().fg(TEXT_SECONDARY)), + ]); + f.render_widget(Paragraph::new(compression_label), Rect::new(field_chunks[3].x, field_chunks[3].y, field_chunks[3].width, 1)); + + let options = [CompressionOption::None, CompressionOption::Gzip, CompressionOption::Zstd]; + let pills: Vec = options.iter().map(|opt| { + let is_selected = *opt == state.compression; + let style = if is_selected { + Style::default().fg(BG_DARK).bg(GREEN).bold() + } else { + Style::default().fg(TEXT_MUTED).bg(BG_DARK) + }; + Span::styled(format!(" {} ", opt.as_str()), style) + }).collect(); + + let mut pill_line = vec![Span::styled(" ", Style::default())]; + for (i, pill) in pills.into_iter().enumerate() { + if i > 0 { + pill_line.push(Span::styled(" ", Style::default())); + } + pill_line.push(pill); + } + if state.selected == 3 { + pill_line.push(Span::styled(" ← h/l →", Style::default().fg(TEXT_MUTED))); + } + + let compression_row = Rect::new(field_chunks[3].x, field_chunks[3].y + 1, field_chunks[3].width, 1); + f.render_widget( + Paragraph::new(Line::from(pill_line)) + .style(Style::default().bg(BG_DARK)), + compression_row, + ); + + // Save button + let save_style = if state.selected == 4 { + Style::default().fg(BG_DARK).bg(GREEN).bold() + } else if state.has_changes { + Style::default().fg(GREEN) + } else { + Style::default().fg(TEXT_MUTED) + }; + let save_text = if state.has_changes { + " ● Save Changes " + } else { + " Save Changes " + }; + let save_button = Paragraph::new(Line::from(Span::styled(save_text, save_style))) + .alignment(Alignment::Center); + f.render_widget(save_button, field_chunks[5]); + + // Message/footer + if let Some(msg) = &state.message { + let msg_style = if msg.contains("success") || msg.contains("saved") { + Style::default().fg(SUCCESS) + } else { + Style::default().fg(ERROR) + }; + let msg_para = Paragraph::new(Line::from(Span::styled(msg.as_str(), msg_style))) + .alignment(Alignment::Center); + f.render_widget(msg_para, field_chunks[6]); + } else { + let footer = Paragraph::new(Line::from(Span::styled( + "j/k navigate • e/Enter edit • r reload", + Style::default().fg(TEXT_MUTED), + ))) + .alignment(Alignment::Center); + f.render_widget(footer, field_chunks[6]); + } +} + +/// Helper to draw a settings field +fn draw_settings_field( + f: &mut Frame, + area: Rect, + label: &str, + value: String, + selected: bool, + editing: bool, + hint: Option<&str>, +) { + let label_line = Line::from(vec![ + Span::styled(label, Style::default().fg(TEXT_SECONDARY)), + if let Some(h) = hint { + Span::styled(format!(" ({})", h), Style::default().fg(TEXT_MUTED)) + } else { + Span::raw("") + }, + ]); + f.render_widget(Paragraph::new(label_line), Rect::new(area.x, area.y, area.width, 1)); + + let border_style = if selected { + Style::default().fg(GREEN) + } else { + Style::default().fg(BORDER) + }; + + let value_display = if editing { + format!("{}█", value) + } else { + value + }; + + let value_style = if value_display.starts_with('(') { + Style::default().fg(TEXT_MUTED) + } else { + Style::default().fg(TEXT_PRIMARY) + }; + + let value_block = Block::default() + .borders(Borders::ALL) + .border_style(border_style) + .style(Style::default().bg(BG_DARK)); + let value_para = Paragraph::new(Span::styled(value_display, value_style)) + .block(value_block); + // Height must be 3: top border (1) + content (1) + bottom border (1) + f.render_widget(value_para, Rect::new(area.x, area.y + 1, area.width, 3)); +} + /// Draw a subtle aurora/gradient background effect fn draw_aurora_background(f: &mut Frame, area: Rect) { let width = area.width as f64; @@ -216,10 +560,18 @@ fn draw_tab_bar(f: &mut Frame, area: Rect, current_tab: Tab) { Style::default().fg(TEXT_MUTED) }; + let settings_style = if current_tab == Tab::Settings { + Style::default().fg(GREEN).bold() + } else { + Style::default().fg(TEXT_MUTED) + }; + let line = Line::from(vec![ Span::styled("Basins", basins_style), Span::styled(" │ ", Style::default().fg(BORDER)), Span::styled("Access Tokens", tokens_style), + Span::styled(" │ ", Style::default().fg(BORDER)), + Span::styled("Settings", settings_style), Span::styled(" (Tab to switch)", Style::default().fg(TEXT_MUTED)), ]); @@ -228,16 +580,43 @@ fn draw_tab_bar(f: &mut Frame, area: Rect, current_tab: Tab) { } fn draw_access_tokens(f: &mut Frame, area: Rect, state: &AccessTokensState) { - // Layout: Search bar, Header, Table rows + // Layout: Title bar, Search bar, Header, Table rows let chunks = Layout::default() .direction(Direction::Vertical) .constraints([ + Constraint::Length(3), // Title bar (consistent height) Constraint::Length(3), // Search bar Constraint::Length(2), // Header Constraint::Min(1), // Table rows ]) .split(area); + // === Title Bar === + let count_text = if state.loading { + " loading...".to_string() + } else { + let filtered_count = state.tokens.iter() + .filter(|t| state.filter.is_empty() || t.id.to_lowercase().contains(&state.filter.to_lowercase())) + .count(); + if filtered_count != state.tokens.len() { + format!(" {}/{} tokens", filtered_count, state.tokens.len()) + } else { + format!(" {} tokens", state.tokens.len()) + } + }; + let title_lines = vec![ + Line::from(""), + Line::from(vec![ + Span::styled(" Access Tokens", Style::default().fg(GREEN).bold()), + Span::styled(&count_text, Style::default().fg(Color::Rgb(100, 100, 100))), + ]), + ]; + let title_block = Paragraph::new(title_lines) + .block(Block::default() + .borders(Borders::BOTTOM) + .border_style(Style::default().fg(Color::Rgb(40, 40, 40)))); + f.render_widget(title_block, chunks[0]); + // === Search Bar === let search_block = Block::default() .borders(Borders::ALL) @@ -246,26 +625,25 @@ fn draw_access_tokens(f: &mut Frame, area: Rect, state: &AccessTokensState) { let search_text = if state.filter_active { Line::from(vec![ - Span::styled("/ ", Style::default().fg(GREEN)), + Span::styled(" [/] ", Style::default().fg(GREEN)), Span::styled(&state.filter, Style::default().fg(TEXT_PRIMARY)), - Span::styled("█", Style::default().fg(GREEN)), // Cursor + Span::styled("_", Style::default().fg(GREEN)), ]) } else if !state.filter.is_empty() { Line::from(vec![ - Span::styled("Filter: ", Style::default().fg(TEXT_MUTED)), + Span::styled(" [/] ", Style::default().fg(TEXT_MUTED)), Span::styled(&state.filter, Style::default().fg(TEXT_PRIMARY)), ]) } else { - Line::from(Span::styled( - "Press / to search access tokens...", - Style::default().fg(TEXT_MUTED), - )) + Line::from(vec![ + Span::styled(" [/] Filter by token ID...", Style::default().fg(TEXT_MUTED)), + ]) }; let search_para = Paragraph::new(search_text) .block(search_block) .style(Style::default().bg(BG_PANEL)); - f.render_widget(search_para, chunks[0]); + f.render_widget(search_para, chunks[1]); // === Header === // Column widths: prefix(2) + token_id(30) + expires_at(28) + scope(rest) @@ -282,7 +660,7 @@ fn draw_access_tokens(f: &mut Frame, area: Rect, state: &AccessTokensState) { Span::styled("SCOPE", Style::default().fg(TEXT_MUTED).bold()), ]); let header_para = Paragraph::new(header); - f.render_widget(header_para, chunks[1]); + f.render_widget(header_para, chunks[2]); // === Token List === // Filter tokens @@ -300,7 +678,7 @@ fn draw_access_tokens(f: &mut Frame, area: Rect, state: &AccessTokensState) { "Loading access tokens...", Style::default().fg(TEXT_MUTED), ))); - f.render_widget(loading, chunks[2]); + f.render_widget(loading, chunks[3]); } else if filtered_tokens.is_empty() { let empty_msg = if state.tokens.is_empty() { "No access tokens. Press 'c' to issue a new token." @@ -311,9 +689,9 @@ fn draw_access_tokens(f: &mut Frame, area: Rect, state: &AccessTokensState) { empty_msg, Style::default().fg(TEXT_MUTED), ))); - f.render_widget(empty, chunks[2]); + f.render_widget(empty, chunks[3]); } else { - let list_height = chunks[2].height as usize; + let list_height = chunks[3].height as usize; let start = state.selected.saturating_sub(list_height / 2); let visible_tokens = filtered_tokens.iter().skip(start).take(list_height); @@ -360,7 +738,7 @@ fn draw_access_tokens(f: &mut Frame, area: Rect, state: &AccessTokensState) { .collect(); let list = Paragraph::new(lines); - f.render_widget(list, chunks[2]); + f.render_widget(list, chunks[3]); } } @@ -1013,16 +1391,43 @@ fn format_count(count: u64) -> String { } } fn draw_basins(f: &mut Frame, area: Rect, state: &BasinsState) { - // Layout: Search bar, Header, Table rows + // Layout: Title bar, Search bar, Header, Table rows let chunks = Layout::default() .direction(Direction::Vertical) .constraints([ + Constraint::Length(3), // Title bar (consistent height) Constraint::Length(3), // Search bar Constraint::Length(2), // Header Constraint::Min(1), // Table rows ]) .split(area); + // === Title Bar === + let count_text = if state.loading { + " loading...".to_string() + } else { + let filtered_count = state.basins.iter() + .filter(|b| state.filter.is_empty() || b.name.to_string().to_lowercase().contains(&state.filter.to_lowercase())) + .count(); + if filtered_count != state.basins.len() { + format!(" {}/{} basins", filtered_count, state.basins.len()) + } else { + format!(" {} basins", state.basins.len()) + } + }; + let title_lines = vec![ + Line::from(""), + Line::from(vec![ + Span::styled(" Basins", Style::default().fg(GREEN).bold()), + Span::styled(&count_text, Style::default().fg(Color::Rgb(100, 100, 100))), + ]), + ]; + let title_block = Paragraph::new(title_lines) + .block(Block::default() + .borders(Borders::BOTTOM) + .border_style(Style::default().fg(Color::Rgb(40, 40, 40)))); + f.render_widget(title_block, chunks[0]); + // === Search Bar === let search_block = Block::default() .borders(Borders::ALL) @@ -1047,10 +1452,10 @@ fn draw_basins(f: &mut Frame, area: Rect, state: &BasinsState) { }; let search = Paragraph::new(search_text).block(search_block); - f.render_widget(search, chunks[0]); + f.render_widget(search, chunks[1]); // === Table Header === - let header_area = chunks[1]; + let header_area = chunks[2]; // Calculate column widths: Name takes most space, State and Scope are fixed let total_width = header_area.width as usize; let state_col = 12; @@ -1077,7 +1482,7 @@ fn draw_basins(f: &mut Frame, area: Rect, state: &BasinsState) { .collect(); // === Table Rows === - let table_area = chunks[2]; + let table_area = chunks[3]; if filtered.is_empty() && !state.loading { let msg = if state.filter.is_empty() { @@ -1182,7 +1587,7 @@ fn draw_streams(f: &mut Frame, area: Rect, state: &StreamsState) { let chunks = Layout::default() .direction(Direction::Vertical) .constraints([ - Constraint::Length(2), // Title bar with basin name + Constraint::Length(3), // Title bar with basin name (consistent height) Constraint::Length(3), // Search bar Constraint::Length(2), // Header Constraint::Min(1), // Table rows @@ -1197,19 +1602,26 @@ fn draw_streams(f: &mut Frame, area: Rect, state: &StreamsState) { .filter(|s| state.filter.is_empty() || s.name.to_string().to_lowercase().contains(&state.filter.to_lowercase())) .count(); if filtered_count != state.streams.len() { - format!(" ({}/{} streams)", filtered_count, state.streams.len()) + format!(" {}/{} streams", filtered_count, state.streams.len()) } else { - format!(" ({} streams)", state.streams.len()) + format!(" {} streams", state.streams.len()) } }; let basin_name_str = state.basin_name.to_string(); - let title = Line::from(vec![ - Span::styled(" ← ", Style::default().fg(TEXT_MUTED)), - Span::styled(&basin_name_str, Style::default().fg(GREEN).bold()), - Span::styled(count_text, Style::default().fg(TEXT_MUTED)), - ]); - f.render_widget(Paragraph::new(title), chunks[0]); + let title_lines = vec![ + Line::from(""), + Line::from(vec![ + Span::styled(" ← ", Style::default().fg(Color::Rgb(100, 100, 100))), + Span::styled(&basin_name_str, Style::default().fg(GREEN).bold()), + Span::styled(&count_text, Style::default().fg(Color::Rgb(100, 100, 100))), + ]), + ]; + let title_block = Paragraph::new(title_lines) + .block(Block::default() + .borders(Borders::BOTTOM) + .border_style(Style::default().fg(Color::Rgb(40, 40, 40)))); + f.render_widget(title_block, chunks[0]); // === Search Bar === let search_block = Block::default() @@ -1348,24 +1760,40 @@ fn draw_stream_detail(f: &mut Frame, area: Rect, state: &StreamDetailState) { let chunks = Layout::default() .direction(Direction::Vertical) .constraints([ - Constraint::Length(3), // Header with URI - Constraint::Length(7), // Stats cards - Constraint::Min(8), // Actions + Constraint::Length(3), // Header with URI (consistent height) + Constraint::Length(5), // Stats cards + Constraint::Min(12), // Actions ]) .split(area); - // === Header === - let uri = format!("s2://{}/{}", state.basin_name, state.stream_name); - let header = Paragraph::new(Line::from(vec![ - Span::styled(" ", Style::default()), - Span::styled(&uri, Style::default().fg(GREEN).bold()), - ])) - .block(Block::default() - .borders(Borders::BOTTOM) - .border_style(Style::default().fg(BORDER))); + // === HEADER === + let basin_str = state.basin_name.to_string(); + let stream_str = state.stream_name.to_string(); + let header_lines = vec![ + Line::from(""), + Line::from(vec![ + Span::styled(" ← ", Style::default().fg(Color::Rgb(100, 100, 100))), + Span::styled(&basin_str, Style::default().fg(Color::Rgb(150, 150, 150))), + Span::styled(" / ", Style::default().fg(Color::Rgb(80, 80, 80))), + Span::styled(&stream_str, Style::default().fg(GREEN).bold()), + ]), + ]; + let header = Paragraph::new(header_lines) + .block(Block::default() + .borders(Borders::BOTTOM) + .border_style(Style::default().fg(Color::Rgb(40, 40, 40)))); f.render_widget(header, chunks[0]); - // === Stats Row === + // === STATS ROW === + let stats_area = Layout::default() + .direction(Direction::Horizontal) + .constraints([ + Constraint::Length(2), // Left padding + Constraint::Min(20), // Stats content + Constraint::Length(2), // Right padding + ]) + .split(chunks[1])[1]; + let stats_chunks = Layout::default() .direction(Direction::Horizontal) .constraints([ @@ -1374,46 +1802,51 @@ fn draw_stream_detail(f: &mut Frame, area: Rect, state: &StreamDetailState) { Constraint::Ratio(1, 4), Constraint::Ratio(1, 4), ]) - .split(chunks[1]); + .split(stats_area); - // Stat card helper function - fn render_stat_card(f: &mut Frame, area: Rect, label: &str, value: &str, sub: Option<&str>) { - let mut lines = vec![ - Line::from(Span::styled(label, Style::default().fg(TEXT_MUTED))), - Line::from(Span::styled(value, Style::default().fg(TEXT_PRIMARY).bold())), + // Stat card helper with icon and color + fn render_stat_card_v2(f: &mut Frame, area: Rect, icon: &str, label: &str, value: &str, value_color: Color) { + let lines = vec![ + Line::from(vec![ + Span::styled(icon, Style::default().fg(value_color)), + Span::styled(format!(" {}", label), Style::default().fg(Color::Rgb(120, 120, 120))), + ]), + Line::from(vec![ + Span::styled(" ", Style::default()), + Span::styled(value, Style::default().fg(value_color).bold()), + ]), ]; - if let Some(s) = sub { - lines.push(Line::from(Span::styled(s, Style::default().fg(TEXT_MUTED)))); - } let widget = Paragraph::new(lines) .block(Block::default() .borders(Borders::ALL) - .border_style(Style::default().fg(BORDER)) - .padding(Padding::horizontal(1))) - .alignment(Alignment::Center); + .border_style(Style::default().fg(Color::Rgb(50, 50, 50))) + .border_type(ratatui::widgets::BorderType::Rounded)); f.render_widget(widget, area); } // Tail Position - let (tail_val, tail_sub): (String, Option<&str>) = if let Some(pos) = &state.tail_position { - (format!("{}", pos.seq_num), Some("records")) + let (tail_val, tail_color) = if let Some(pos) = &state.tail_position { + if pos.seq_num > 0 { + (format!("{}", pos.seq_num), Color::Rgb(34, 211, 238)) // Cyan + } else { + ("0".to_string(), Color::Rgb(100, 100, 100)) + } } else if state.loading { - ("...".to_string(), None) + ("...".to_string(), Color::Rgb(100, 100, 100)) } else { - ("--".to_string(), None) + ("--".to_string(), Color::Rgb(100, 100, 100)) }; - render_stat_card(f, stats_chunks[0], "Tail Position", &tail_val, tail_sub); + render_stat_card_v2(f, stats_chunks[0], "▌", "Records", &tail_val, tail_color); - // Last Timestamp - let ts_val = if let Some(pos) = &state.tail_position { + // Last Write + let (ts_val, ts_color) = if let Some(pos) = &state.tail_position { if pos.timestamp > 0 { - // Format as relative time if recent let now_ms = std::time::SystemTime::now() .duration_since(std::time::UNIX_EPOCH) .map(|d| d.as_millis() as u64) .unwrap_or(0); let age_secs = now_ms.saturating_sub(pos.timestamp) / 1000; - if age_secs < 60 { + let val = if age_secs < 60 { format!("{}s ago", age_secs) } else if age_secs < 3600 { format!("{}m ago", age_secs / 60) @@ -1421,34 +1854,51 @@ fn draw_stream_detail(f: &mut Frame, area: Rect, state: &StreamDetailState) { format!("{}h ago", age_secs / 3600) } else { format!("{}d ago", age_secs / 86400) - } + }; + // Color based on recency + let color = if age_secs < 60 { + Color::Rgb(74, 222, 128) // Green - very recent + } else if age_secs < 3600 { + Color::Rgb(250, 204, 21) // Yellow - recent + } else { + Color::Rgb(180, 180, 180) // Gray - old + }; + (val, color) } else { - "never".to_string() + ("never".to_string(), Color::Rgb(100, 100, 100)) } } else { - "--".to_string() + ("--".to_string(), Color::Rgb(100, 100, 100)) }; - render_stat_card(f, stats_chunks[1], "Last Write", &ts_val, None); + render_stat_card_v2(f, stats_chunks[1], "◷", "Last Write", &ts_val, ts_color); // Storage Class - let storage_val = if let Some(config) = &state.config { - config.storage_class + let (storage_val, storage_color) = if let Some(config) = &state.config { + let val = config.storage_class .as_ref() .map(|s| format!("{:?}", s).to_lowercase()) - .unwrap_or_else(|| "default".to_string()) + .unwrap_or_else(|| "default".to_string()); + let color = match val.as_str() { + "express" => Color::Rgb(251, 146, 60), // Orange for express + "standard" => Color::Rgb(147, 197, 253), // Light blue for standard + _ => Color::Rgb(180, 180, 180), + }; + (val, color) } else { - "--".to_string() + ("--".to_string(), Color::Rgb(100, 100, 100)) }; - render_stat_card(f, stats_chunks[2], "Storage", &storage_val, None); + render_stat_card_v2(f, stats_chunks[2], "◈", "Storage", &storage_val, storage_color); // Retention - let retention_val = if let Some(config) = &state.config { - config.retention_policy + let (retention_val, retention_color) = if let Some(config) = &state.config { + let val = config.retention_policy .as_ref() .map(|p| match p { crate::types::RetentionPolicy::Age(dur) => { let secs = dur.as_secs(); - if secs >= 86400 { + if secs >= 86400 * 365 { + format!("{}y", secs / (86400 * 365)) + } else if secs >= 86400 { format!("{}d", secs / 86400) } else if secs >= 3600 { format!("{}h", secs / 3600) @@ -1456,55 +1906,102 @@ fn draw_stream_detail(f: &mut Frame, area: Rect, state: &StreamDetailState) { format!("{}s", secs) } } - crate::types::RetentionPolicy::Infinite => "infinite".to_string(), + crate::types::RetentionPolicy::Infinite => "∞".to_string(), }) - .unwrap_or_else(|| "infinite".to_string()) + .unwrap_or_else(|| "∞".to_string()); + let color = if val == "∞" { + Color::Rgb(167, 139, 250) // Purple for infinite + } else { + Color::Rgb(180, 180, 180) + }; + (val, color) } else { - "--".to_string() + ("--".to_string(), Color::Rgb(100, 100, 100)) }; - render_stat_card(f, stats_chunks[3], "Retention", &retention_val, None); + render_stat_card_v2(f, stats_chunks[3], "◔", "Retention", &retention_val, retention_color); - // === Actions === - let actions_block = Block::default() - .borders(Borders::ALL) - .border_style(Style::default().fg(BORDER)) - .padding(Padding::new(2, 2, 1, 1)); + // === ACTIONS === + let actions_outer = Layout::default() + .direction(Direction::Horizontal) + .constraints([ + Constraint::Length(2), + Constraint::Min(20), + Constraint::Length(2), + ]) + .split(chunks[2])[1]; + + // Split into two columns: Data Operations | Stream Management + let action_cols = Layout::default() + .direction(Direction::Horizontal) + .constraints([ + Constraint::Ratio(1, 2), + Constraint::Ratio(1, 2), + ]) + .split(actions_outer); - let actions = vec![ - ("t", "Tail", "Live follow from current position - see new records as they arrive"), - ("r", "Read", "Configure start position, limits, and time range"), - ("a", "Append", "Write records to this stream"), - ("f", "Fence", "Set a fencing token to block other writers"), - ("m", "Trim", "Delete records before a sequence number"), + // Data operations (left column) + let data_ops: Vec<(&str, &str, &str, &str)> = vec![ + ("t", "Tail", "Live stream, see records as they arrive", "◉"), + ("r", "Read", "Read with custom start position & limits", "◎"), + ("a", "Append", "Write new records to stream", "◆"), ]; - let mut action_lines = vec![]; + // Stream management (right column) + let mgmt_ops: Vec<(&str, &str, &str, &str)> = vec![ + ("f", "Fence", "Set token for exclusive writes", "⊘"), + ("m", "Trim", "Delete records before seq number", "✂"), + ]; - for (i, (key, title, desc)) in actions.iter().enumerate() { - let is_selected = i == state.selected_action; + fn render_action_column(f: &mut Frame, area: Rect, title: &str, actions: &[(&str, &str, &str, &str)], selected: usize, offset: usize) { + let title_width = title.len() + 4; + let line_width = area.width.saturating_sub(title_width as u16 + 2) as usize; - if is_selected { - action_lines.push(Line::from(vec![ - Span::styled("> ", Style::default().fg(GREEN)), - Span::styled(format!("[{}] ", key), Style::default().fg(GREEN).bold()), - Span::styled(*title, Style::default().fg(TEXT_PRIMARY).bold()), - ])); - action_lines.push(Line::from(vec![ - Span::styled(" ", Style::default()), - Span::styled(*desc, Style::default().fg(TEXT_SECONDARY)), - ])); - } else { - action_lines.push(Line::from(vec![ - Span::styled(" ", Style::default()), - Span::styled(format!("[{}] ", key), Style::default().fg(GREEN_DIM)), - Span::styled(*title, Style::default().fg(TEXT_MUTED)), - ])); + let mut lines = vec![ + Line::from(vec![ + Span::styled(format!(" {} ", title), Style::default().fg(Color::Rgb(34, 211, 238)).bold()), + Span::styled("─".repeat(line_width), Style::default().fg(Color::Rgb(40, 40, 40))), + ]), + Line::from(""), + ]; + + for (i, (key, name, desc, icon)) in actions.iter().enumerate() { + let actual_idx = i + offset; + let is_selected = actual_idx == selected; + + if is_selected { + // Selected action - highlighted card style + lines.push(Line::from(vec![ + Span::styled(" ▶ ", Style::default().fg(GREEN)), + Span::styled(*icon, Style::default().fg(GREEN)), + Span::styled(format!(" {} ", name), Style::default().fg(Color::White).bold()), + Span::styled(format!("[{}]", key), Style::default().fg(GREEN).bold()), + ])); + lines.push(Line::from(vec![ + Span::styled(" ", Style::default()), + Span::styled(*desc, Style::default().fg(Color::Rgb(180, 180, 180)).italic()), + ])); + } else { + // Unselected action - dimmed + lines.push(Line::from(vec![ + Span::styled(" ", Style::default()), + Span::styled(*icon, Style::default().fg(Color::Rgb(80, 80, 80))), + Span::styled(format!(" {} ", name), Style::default().fg(Color::Rgb(140, 140, 140))), + Span::styled(format!("[{}]", key), Style::default().fg(Color::Rgb(80, 80, 80))), + ])); + } + lines.push(Line::from("")); } - action_lines.push(Line::from("")); + + let widget = Paragraph::new(lines) + .block(Block::default() + .borders(Borders::ALL) + .border_style(Style::default().fg(Color::Rgb(50, 50, 50))) + .border_type(ratatui::widgets::BorderType::Rounded)); + f.render_widget(widget, area); } - let actions_paragraph = Paragraph::new(action_lines).block(actions_block); - f.render_widget(actions_paragraph, chunks[2]); + render_action_column(f, action_cols[0], "Data Operations", &data_ops, state.selected_action, 0); + render_action_column(f, action_cols[1], "Stream Management", &mgmt_ops, state.selected_action, 3); } fn draw_read_view(f: &mut Frame, area: Rect, state: &ReadViewState) { @@ -1518,34 +2015,55 @@ fn draw_read_view(f: &mut Frame, area: Rect, state: &ReadViewState) { ("READING", ACCENT) }; - let uri = format!("s2://{}/{}", state.basin_name, state.stream_name); + // Split into header and content + let main_chunks = Layout::default() + .direction(Direction::Vertical) + .constraints([ + Constraint::Length(3), // Header (consistent height) + Constraint::Min(1), // Content + ]) + .split(area); + + // === HEADER === + let basin_str = state.basin_name.to_string(); + let stream_str = state.stream_name.to_string(); + let record_count = format!(" {} records", state.records.len()); - // Build title spans - let mut title_spans = vec![ - Span::styled(" ", Style::default()), - Span::styled(mode_text, Style::default().fg(mode_color).bold()), + let mut header_spans = vec![ + Span::styled(" ← ", Style::default().fg(Color::Rgb(100, 100, 100))), + Span::styled(&basin_str, Style::default().fg(Color::Rgb(150, 150, 150))), + Span::styled(" / ", Style::default().fg(Color::Rgb(80, 80, 80))), + Span::styled(&stream_str, Style::default().fg(Color::Rgb(180, 180, 180))), Span::styled(" ", Style::default()), - Span::styled(&uri, Style::default().fg(TEXT_SECONDARY)), - Span::styled( - format!(" {} records ", state.records.len()), - Style::default().fg(TEXT_MUTED), - ), + Span::styled(format!(" {} ", mode_text), Style::default().fg(BG_DARK).bg(mode_color).bold()), + Span::styled(&record_count, Style::default().fg(Color::Rgb(100, 100, 100))), ]; // Add output file indicator if writing to file if let Some(ref output) = state.output_file { - title_spans.push(Span::styled(" → ", Style::default().fg(TEXT_MUTED))); - title_spans.push(Span::styled(output, Style::default().fg(YELLOW))); + header_spans.push(Span::styled(" → ", Style::default().fg(Color::Rgb(100, 100, 100)))); + header_spans.push(Span::styled(output, Style::default().fg(YELLOW))); } - // Main container with title + let header_lines = vec![ + Line::from(""), + Line::from(header_spans), + ]; + let header = Paragraph::new(header_lines) + .block(Block::default() + .borders(Borders::BOTTOM) + .border_style(Style::default().fg(Color::Rgb(40, 40, 40)))); + f.render_widget(header, main_chunks[0]); + + // === CONTENT === + let content_area = main_chunks[1]; + + // Main container let outer_block = Block::default() - .title(Line::from(title_spans)) - .borders(Borders::ALL) - .border_style(Style::default().fg(if state.is_tailing && !state.paused { GREEN } else { BORDER })); + .borders(Borders::NONE); - let inner_area = outer_block.inner(area); - f.render_widget(outer_block, area); + let inner_area = outer_block.inner(content_area); + f.render_widget(outer_block, content_area); if state.records.is_empty() { let text = if state.loading { @@ -1773,8 +2291,36 @@ fn draw_headers_popup(f: &mut Frame, record: &s2_sdk::types::SequencedRecord) { } fn draw_append_view(f: &mut Frame, area: Rect, state: &AppendViewState) { - let uri = format!("s2://{}/{}", state.basin_name, state.stream_name); + // Split into header and content + let outer_chunks = Layout::default() + .direction(Direction::Vertical) + .constraints([ + Constraint::Length(3), // Header (consistent height) + Constraint::Min(1), // Content + ]) + .split(area); + + // === HEADER === + let basin_str = state.basin_name.to_string(); + let stream_str = state.stream_name.to_string(); + let header_lines = vec![ + Line::from(""), + Line::from(vec![ + Span::styled(" ← ", Style::default().fg(Color::Rgb(100, 100, 100))), + Span::styled(&basin_str, Style::default().fg(Color::Rgb(150, 150, 150))), + Span::styled(" / ", Style::default().fg(Color::Rgb(80, 80, 80))), + Span::styled(&stream_str, Style::default().fg(Color::Rgb(180, 180, 180))), + Span::styled(" ", Style::default()), + Span::styled(" APPEND ", Style::default().fg(BG_DARK).bg(GREEN).bold()), + ]), + ]; + let header = Paragraph::new(header_lines) + .block(Block::default() + .borders(Borders::BOTTOM) + .border_style(Style::default().fg(Color::Rgb(40, 40, 40)))); + f.render_widget(header, outer_chunks[0]); + // === CONTENT === // Split into form (left) and history (right) let main_chunks = Layout::default() .direction(Direction::Horizontal) @@ -1782,17 +2328,13 @@ fn draw_append_view(f: &mut Frame, area: Rect, state: &AppendViewState) { Constraint::Percentage(50), // Form Constraint::Percentage(50), // History ]) - .split(area); + .split(outer_chunks[1]); // === Form pane === let form_block = Block::default() - .title(Line::from(vec![ - Span::styled(" APPEND ", Style::default().fg(GREEN).bold()), - Span::styled(" ", Style::default()), - Span::styled(&uri, Style::default().fg(TEXT_SECONDARY)), - ])) .borders(Borders::ALL) - .border_style(Style::default().fg(GREEN)) + .border_style(Style::default().fg(Color::Rgb(50, 50, 50))) + .border_type(ratatui::widgets::BorderType::Rounded) .padding(Padding::new(2, 2, 1, 1)); let form_inner = form_block.inner(main_chunks[0]); @@ -2019,7 +2561,16 @@ fn get_responsive_hints(screen: &Screen, width: usize) -> String { let medium = width >= 60; match screen { - Screen::Splash => String::new(), + Screen::Splash | Screen::Setup(_) => String::new(), + Screen::Settings(_) => { + if wide { + "jk nav | e edit | hl compression | space toggle | ⏎ save | r reload | ⇥ switch | q".to_string() + } else if medium { + "jk e hl space ⏎ r ⇥ q".to_string() + } else { + "jk e ⏎ r ⇥ q".to_string() + } + } Screen::Basins(_) => { if wide { "/ filter | jk nav | ⏎ open | M basin metrics | A acct metrics | c new | e cfg | d del | r ref | ?".to_string() @@ -2114,7 +2665,43 @@ fn draw_help_overlay(f: &mut Frame, screen: &Screen) { let area = centered_rect(50, 50, f.area()); let help_text = match screen { - Screen::Splash => vec![], // Never shown + Screen::Splash | Screen::Setup(_) => vec![], // Never shown + Screen::Settings(_) => vec![ + Line::from(""), + Line::from(vec![ + Span::styled(" j/k ", Style::default().fg(GREEN).bold()), + Span::styled("Navigate", Style::default().fg(TEXT_SECONDARY)), + ]), + Line::from(vec![ + Span::styled(" e ", Style::default().fg(GREEN).bold()), + Span::styled("Edit field", Style::default().fg(TEXT_SECONDARY)), + ]), + Line::from(vec![ + Span::styled(" h/l ", Style::default().fg(GREEN).bold()), + Span::styled("Change compression", Style::default().fg(TEXT_SECONDARY)), + ]), + Line::from(vec![ + Span::styled("space ", Style::default().fg(GREEN).bold()), + Span::styled("Toggle token visibility", Style::default().fg(TEXT_SECONDARY)), + ]), + Line::from(vec![ + Span::styled("enter ", Style::default().fg(GREEN).bold()), + Span::styled("Save changes", Style::default().fg(TEXT_SECONDARY)), + ]), + Line::from(vec![ + Span::styled(" r ", Style::default().fg(GREEN).bold()), + Span::styled("Reload from file", Style::default().fg(TEXT_SECONDARY)), + ]), + Line::from(vec![ + Span::styled(" tab ", Style::default().fg(GREEN).bold()), + Span::styled("Switch tabs", Style::default().fg(TEXT_SECONDARY)), + ]), + Line::from(vec![ + Span::styled(" q ", Style::default().fg(GREEN).bold()), + Span::styled("Quit", Style::default().fg(TEXT_SECONDARY)), + ]), + Line::from(""), + ], Screen::Basins(_) => vec![ Line::from(""), Line::from(vec![ From fb147ccb158585622ce9d7700063f23642ab2943 Mon Sep 17 00:00:00 2001 From: Mehul Arora Date: Sat, 24 Jan 2026 12:31:34 -0500 Subject: [PATCH 13/31] . --- Cargo.lock | 100 +++++++++ Cargo.toml | 1 + src/tui/app.rs | 589 +++++++++++++++++++++++++++++++++++++++++++++---- src/tui/ui.rs | 360 +++++++++++++++++++++++++++++- 4 files changed, 998 insertions(+), 52 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 2f23258..88c3385 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -32,6 +32,15 @@ version = "0.2.21" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "683d7910e743518b0e34f1186f92494becacb047c7b6bf616c96772180fef923" +[[package]] +name = "android_system_properties" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "819e7219dbd41043ac279b19830f2efc897156490d7fd6ea916720117ee66311" +dependencies = [ + "libc", +] + [[package]] name = "anstream" version = "0.6.21" @@ -342,6 +351,19 @@ version = "0.2.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "613afe47fcd5fac7ccf1db93babcb082c5994d996f20b8b159f2ad1658eb5724" +[[package]] +name = "chrono" +version = "0.4.43" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fac4744fb15ae8337dc853fee7fb3f4e48c0fbaa23d0afe49c447b4fab126118" +dependencies = [ + "iana-time-zone", + "js-sys", + "num-traits", + "wasm-bindgen", + "windows-link", +] + [[package]] name = "clap" version = "4.5.54" @@ -1204,6 +1226,30 @@ dependencies = [ "tracing", ] +[[package]] +name = "iana-time-zone" +version = "0.1.64" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "33e57f83510bb73707521ebaffa789ec8caf86f9657cad665b092b581d40e9fb" +dependencies = [ + "android_system_properties", + "core-foundation-sys", + "iana-time-zone-haiku", + "js-sys", + "log", + "wasm-bindgen", + "windows-core", +] + +[[package]] +name = "iana-time-zone-haiku" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f31827a206f56af32e590ba56d5d2d085f558508192593743f16b2306495269f" +dependencies = [ + "cc", +] + [[package]] name = "icu_collections" version = "2.1.1" @@ -2418,6 +2464,7 @@ dependencies = [ "async-stream", "base64ct", "bytes", + "chrono", "clap", "color-print", "colored", @@ -3612,12 +3659,65 @@ version = "0.4.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "712e227841d057c1ee1cd2fb22fa7e5a5461ae8e48fa2ca79ec42cfc1931183f" +[[package]] +name = "windows-core" +version = "0.62.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b8e83a14d34d0623b51dce9581199302a221863196a1dde71a7663a4c2be9deb" +dependencies = [ + "windows-implement", + "windows-interface", + "windows-link", + "windows-result", + "windows-strings", +] + +[[package]] +name = "windows-implement" +version = "0.60.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "053e2e040ab57b9dc951b72c264860db7eb3b0200ba345b4e4c3b14f67855ddf" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "windows-interface" +version = "0.59.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3f316c4a2570ba26bbec722032c4099d8c8bc095efccdc15688708623367e358" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + [[package]] name = "windows-link" version = "0.2.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f0805222e57f7521d6a62e36fa9163bc891acd422f971defe97d64e70d0a4fe5" +[[package]] +name = "windows-result" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7781fa89eaf60850ac3d2da7af8e5242a5ea78d1a11c49bf2910bb5a73853eb5" +dependencies = [ + "windows-link", +] + +[[package]] +name = "windows-strings" +version = "0.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7837d08f69c77cf6b07689544538e017c1bfcf57e34b4c0ff58e6c2cd3b37091" +dependencies = [ + "windows-link", +] + [[package]] name = "windows-sys" version = "0.45.0" diff --git a/Cargo.toml b/Cargo.toml index aaadc1c..9c17b70 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -45,6 +45,7 @@ xxhash-rust = { version = "0.8.15", features = ["xxh3"] } # TUI ratatui = "0.29" crossterm = "0.28" +chrono = "0.4" [dev-dependencies] assert_cmd = "2.1" diff --git a/src/tui/app.rs b/src/tui/app.rs index f594b47..39f4c40 100644 --- a/src/tui/app.rs +++ b/src/tui/app.rs @@ -1,6 +1,8 @@ use std::collections::VecDeque; use std::time::Duration; +use chrono::{Datelike, NaiveDate}; + use base64ct::Encoding; use crossterm::event::{self, Event as CrosstermEvent, KeyCode, KeyEvent, KeyModifiers}; use futures::StreamExt; @@ -266,14 +268,131 @@ impl MetricCategory { } } +/// Time range options for metrics +#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)] +pub enum TimeRangeOption { + OneHour, + SixHours, + TwelveHours, + #[default] + TwentyFourHours, + ThreeDays, + SevenDays, + ThirtyDays, + Custom { start: u32, end: u32 }, // Unix timestamps +} + +impl TimeRangeOption { + pub const PRESETS: &'static [TimeRangeOption] = &[ + TimeRangeOption::OneHour, + TimeRangeOption::SixHours, + TimeRangeOption::TwelveHours, + TimeRangeOption::TwentyFourHours, + TimeRangeOption::ThreeDays, + TimeRangeOption::SevenDays, + TimeRangeOption::ThirtyDays, + ]; + + pub fn as_str(&self) -> &'static str { + match self { + TimeRangeOption::OneHour => "1h", + TimeRangeOption::SixHours => "6h", + TimeRangeOption::TwelveHours => "12h", + TimeRangeOption::TwentyFourHours => "24h", + TimeRangeOption::ThreeDays => "3d", + TimeRangeOption::SevenDays => "7d", + TimeRangeOption::ThirtyDays => "30d", + TimeRangeOption::Custom { .. } => "Custom", + } + } + + pub fn as_label(&self) -> &'static str { + match self { + TimeRangeOption::OneHour => "Last hour", + TimeRangeOption::SixHours => "Last 6 hours", + TimeRangeOption::TwelveHours => "Last 12 hours", + TimeRangeOption::TwentyFourHours => "Last 24 hours", + TimeRangeOption::ThreeDays => "Last 3 days", + TimeRangeOption::SevenDays => "Last 7 days", + TimeRangeOption::ThirtyDays => "Last 30 days", + TimeRangeOption::Custom { .. } => "Custom range", + } + } + + pub fn as_duration(&self) -> Duration { + match self { + TimeRangeOption::OneHour => Duration::from_secs(60 * 60), + TimeRangeOption::SixHours => Duration::from_secs(6 * 60 * 60), + TimeRangeOption::TwelveHours => Duration::from_secs(12 * 60 * 60), + TimeRangeOption::TwentyFourHours => Duration::from_secs(24 * 60 * 60), + TimeRangeOption::ThreeDays => Duration::from_secs(3 * 24 * 60 * 60), + TimeRangeOption::SevenDays => Duration::from_secs(7 * 24 * 60 * 60), + TimeRangeOption::ThirtyDays => Duration::from_secs(30 * 24 * 60 * 60), + TimeRangeOption::Custom { start, end } => Duration::from_secs((end - start) as u64), + } + } + + pub fn get_range(&self) -> (u32, u32) { + match self { + TimeRangeOption::Custom { start, end } => (*start, *end), + _ => { + let now = std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .unwrap_or_default() + .as_secs() as u32; + let range_secs = self.as_duration().as_secs() as u32; + (now.saturating_sub(range_secs), now) + } + } + } + + pub fn next(&self) -> Self { + match self { + TimeRangeOption::OneHour => TimeRangeOption::SixHours, + TimeRangeOption::SixHours => TimeRangeOption::TwelveHours, + TimeRangeOption::TwelveHours => TimeRangeOption::TwentyFourHours, + TimeRangeOption::TwentyFourHours => TimeRangeOption::ThreeDays, + TimeRangeOption::ThreeDays => TimeRangeOption::SevenDays, + TimeRangeOption::SevenDays => TimeRangeOption::ThirtyDays, + TimeRangeOption::ThirtyDays => TimeRangeOption::OneHour, + TimeRangeOption::Custom { .. } => TimeRangeOption::OneHour, + } + } + + pub fn prev(&self) -> Self { + match self { + TimeRangeOption::OneHour => TimeRangeOption::ThirtyDays, + TimeRangeOption::SixHours => TimeRangeOption::OneHour, + TimeRangeOption::TwelveHours => TimeRangeOption::SixHours, + TimeRangeOption::TwentyFourHours => TimeRangeOption::TwelveHours, + TimeRangeOption::ThreeDays => TimeRangeOption::TwentyFourHours, + TimeRangeOption::SevenDays => TimeRangeOption::ThreeDays, + TimeRangeOption::ThirtyDays => TimeRangeOption::SevenDays, + TimeRangeOption::Custom { .. } => TimeRangeOption::ThirtyDays, + } + } +} + /// State for the metrics view #[derive(Debug, Clone)] pub struct MetricsViewState { pub metrics_type: MetricsType, pub metrics: Vec, pub selected_category: MetricCategory, + pub time_range: TimeRangeOption, pub loading: bool, pub scroll: usize, + // Time picker popup state + pub time_picker_open: bool, + pub time_picker_selected: usize, + // Calendar picker state + pub calendar_open: bool, + pub calendar_year: i32, + pub calendar_month: u32, + pub calendar_day: u32, // Currently highlighted day + pub calendar_start: Option<(i32, u32, u32)>, // Selected start date (year, month, day) + pub calendar_end: Option<(i32, u32, u32)>, // Selected end date + pub calendar_selecting_end: bool, // true if selecting end date } /// Status message level @@ -5091,58 +5210,92 @@ impl App { /// Open basin metrics view /// Open account metrics view fn open_account_metrics(&mut self, tx: mpsc::UnboundedSender) { + let (year, month, day) = Self::today(); self.screen = Screen::MetricsView(MetricsViewState { metrics_type: MetricsType::Account, metrics: Vec::new(), selected_category: MetricCategory::ActiveBasins, + time_range: TimeRangeOption::default(), loading: true, scroll: 0, + time_picker_open: false, + time_picker_selected: 3, // Default to 24h (index 3) + calendar_open: false, + calendar_year: year, + calendar_month: month, + calendar_day: day, + calendar_start: None, + calendar_end: None, + calendar_selecting_end: false, }); - self.load_account_metrics(MetricCategory::ActiveBasins, tx); + self.load_account_metrics(MetricCategory::ActiveBasins, TimeRangeOption::default(), tx); } fn open_basin_metrics(&mut self, basin_name: BasinName, tx: mpsc::UnboundedSender) { + let (year, month, day) = Self::today(); self.screen = Screen::MetricsView(MetricsViewState { metrics_type: MetricsType::Basin { basin_name: basin_name.clone() }, metrics: Vec::new(), selected_category: MetricCategory::Storage, + time_range: TimeRangeOption::default(), loading: true, scroll: 0, + time_picker_open: false, + time_picker_selected: 3, + calendar_open: false, + calendar_year: year, + calendar_month: month, + calendar_day: day, + calendar_start: None, + calendar_end: None, + calendar_selecting_end: false, }); - self.load_basin_metrics(basin_name, MetricCategory::Storage, tx); + self.load_basin_metrics(basin_name, MetricCategory::Storage, TimeRangeOption::default(), tx); } /// Open stream metrics view fn open_stream_metrics(&mut self, basin_name: BasinName, stream_name: StreamName, tx: mpsc::UnboundedSender) { + let (year, month, day) = Self::today(); self.screen = Screen::MetricsView(MetricsViewState { metrics_type: MetricsType::Stream { basin_name: basin_name.clone(), stream_name: stream_name.clone() }, metrics: Vec::new(), selected_category: MetricCategory::Storage, + time_range: TimeRangeOption::default(), loading: true, scroll: 0, + time_picker_open: false, + time_picker_selected: 3, + calendar_open: false, + calendar_year: year, + calendar_month: month, + calendar_day: day, + calendar_start: None, + calendar_end: None, + calendar_selecting_end: false, }); - self.load_stream_metrics(basin_name, stream_name, tx); + self.load_stream_metrics(basin_name, stream_name, TimeRangeOption::default(), tx); + } + + /// Get today's date as (year, month, day) + fn today() -> (i32, u32, u32) { + use chrono::{Datelike, Local}; + let today = Local::now(); + (today.year(), today.month(), today.day()) } /// Load basin metrics /// Load account metrics - fn load_account_metrics(&self, category: MetricCategory, tx: mpsc::UnboundedSender) { + fn load_account_metrics(&self, category: MetricCategory, time_range: TimeRangeOption, tx: mpsc::UnboundedSender) { use s2_sdk::types::AccountMetricSet; let s2 = self.s2.clone().expect("S2 client not initialized"); + let (start, end) = time_range.get_range(); tokio::spawn(async move { - // Get metrics for last 24 hours - let now = std::time::SystemTime::now() - .duration_since(std::time::UNIX_EPOCH) - .unwrap_or_default() - .as_secs() as u32; - let day_ago = now.saturating_sub(24 * 60 * 60); - let set = match category { - MetricCategory::ActiveBasins => AccountMetricSet::ActiveBasins(TimeRange::new(day_ago, now)), + MetricCategory::ActiveBasins => AccountMetricSet::ActiveBasins(TimeRange::new(start, end)), MetricCategory::AccountOps => AccountMetricSet::AccountOps( - s2_sdk::types::TimeRangeAndInterval::new(day_ago, now) + s2_sdk::types::TimeRangeAndInterval::new(start, end) ), _ => return, // Other categories not valid for account }; @@ -5162,30 +5315,24 @@ impl App { }); } - fn load_basin_metrics(&self, basin_name: BasinName, category: MetricCategory, tx: mpsc::UnboundedSender) { + fn load_basin_metrics(&self, basin_name: BasinName, category: MetricCategory, time_range: TimeRangeOption, tx: mpsc::UnboundedSender) { let s2 = self.s2.clone().expect("S2 client not initialized"); + let (start, end) = time_range.get_range(); tokio::spawn(async move { - // Get metrics for last 24 hours - let now = std::time::SystemTime::now() - .duration_since(std::time::UNIX_EPOCH) - .unwrap_or_default() - .as_secs() as u32; - let day_ago = now.saturating_sub(24 * 60 * 60); - let set = match category { - MetricCategory::Storage => BasinMetricSet::Storage(TimeRange::new(day_ago, now)), + MetricCategory::Storage => BasinMetricSet::Storage(TimeRange::new(start, end)), MetricCategory::AppendOps => BasinMetricSet::AppendOps( - s2_sdk::types::TimeRangeAndInterval::new(day_ago, now) + s2_sdk::types::TimeRangeAndInterval::new(start, end) ), MetricCategory::ReadOps => BasinMetricSet::ReadOps( - s2_sdk::types::TimeRangeAndInterval::new(day_ago, now) + s2_sdk::types::TimeRangeAndInterval::new(start, end) ), MetricCategory::AppendThroughput => BasinMetricSet::AppendThroughput( - s2_sdk::types::TimeRangeAndInterval::new(day_ago, now) + s2_sdk::types::TimeRangeAndInterval::new(start, end) ), MetricCategory::ReadThroughput => BasinMetricSet::ReadThroughput( - s2_sdk::types::TimeRangeAndInterval::new(day_ago, now) + s2_sdk::types::TimeRangeAndInterval::new(start, end) ), _ => return, // Account metrics not valid for basin }; @@ -5206,18 +5353,12 @@ impl App { } /// Load stream metrics - fn load_stream_metrics(&self, basin_name: BasinName, stream_name: StreamName, tx: mpsc::UnboundedSender) { + fn load_stream_metrics(&self, basin_name: BasinName, stream_name: StreamName, time_range: TimeRangeOption, tx: mpsc::UnboundedSender) { let s2 = self.s2.clone().expect("S2 client not initialized"); + let (start, end) = time_range.get_range(); tokio::spawn(async move { - // Get metrics for last 24 hours - let now = std::time::SystemTime::now() - .duration_since(std::time::UNIX_EPOCH) - .unwrap_or_default() - .as_secs() as u32; - let day_ago = now.saturating_sub(24 * 60 * 60); - - let set = StreamMetricSet::Storage(TimeRange::new(day_ago, now)); + let set = StreamMetricSet::Storage(TimeRange::new(start, end)); let input = s2_sdk::types::GetStreamMetricsInput::new(basin_name, stream_name, set); match s2.get_stream_metrics(input).await { @@ -5236,12 +5377,30 @@ impl App { /// Handle keys in metrics view fn handle_metrics_view_key(&mut self, key: KeyEvent, tx: mpsc::UnboundedSender) { + // Check if time picker or calendar is open first + let (time_picker_open, calendar_open) = { + let Screen::MetricsView(state) = &self.screen else { + return; + }; + (state.time_picker_open, state.calendar_open) + }; + + if time_picker_open { + self.handle_time_picker_key(key, tx); + return; + } + + if calendar_open { + self.handle_calendar_key(key, tx); + return; + } + // Extract data from state first to avoid borrow issues - let (metrics_type, selected_category) = { + let (metrics_type, selected_category, time_range) = { let Screen::MetricsView(state) = &self.screen else { return; }; - (state.metrics_type.clone(), state.selected_category) + (state.metrics_type.clone(), state.selected_category, state.time_range.clone()) }; match key.code { @@ -5286,6 +5445,17 @@ impl App { } } } + KeyCode::Char('t') => { + // Open time picker + if let Screen::MetricsView(state) = &mut self.screen { + state.time_picker_open = true; + // Set picker selection to current time range + state.time_picker_selected = TimeRangeOption::PRESETS + .iter() + .position(|p| std::mem::discriminant(p) == std::mem::discriminant(&state.time_range)) + .unwrap_or(3); + } + } KeyCode::Left | KeyCode::Char('h') => { // Previous metric category (for basin or account metrics) match &metrics_type { @@ -5296,7 +5466,7 @@ impl App { state.loading = true; state.metrics.clear(); } - self.load_account_metrics(new_category, tx); + self.load_account_metrics(new_category, time_range, tx); } MetricsType::Basin { basin_name } => { let basin_name = basin_name.clone(); @@ -5306,7 +5476,7 @@ impl App { state.loading = true; state.metrics.clear(); } - self.load_basin_metrics(basin_name, new_category, tx); + self.load_basin_metrics(basin_name, new_category, time_range, tx); } MetricsType::Stream { .. } => {} // No category switching for stream } @@ -5321,7 +5491,7 @@ impl App { state.loading = true; state.metrics.clear(); } - self.load_account_metrics(new_category, tx); + self.load_account_metrics(new_category, time_range, tx); } MetricsType::Basin { basin_name } => { let basin_name = basin_name.clone(); @@ -5331,7 +5501,7 @@ impl App { state.loading = true; state.metrics.clear(); } - self.load_basin_metrics(basin_name, new_category, tx); + self.load_basin_metrics(basin_name, new_category, time_range, tx); } MetricsType::Stream { .. } => {} // No category switching for stream } @@ -5356,17 +5526,348 @@ impl App { } match &metrics_type { MetricsType::Account => { - self.load_account_metrics(selected_category, tx); + self.load_account_metrics(selected_category, time_range, tx); + } + MetricsType::Basin { basin_name } => { + self.load_basin_metrics(basin_name.clone(), selected_category, time_range, tx); + } + MetricsType::Stream { basin_name, stream_name } => { + self.load_stream_metrics(basin_name.clone(), stream_name.clone(), time_range, tx); + } + } + } + KeyCode::Char('[') => { + // Previous time range + let new_time_range = time_range.prev(); + if let Screen::MetricsView(state) = &mut self.screen { + state.time_range = new_time_range.clone(); + state.loading = true; + state.metrics.clear(); + } + match &metrics_type { + MetricsType::Account => { + self.load_account_metrics(selected_category, new_time_range, tx); + } + MetricsType::Basin { basin_name } => { + self.load_basin_metrics(basin_name.clone(), selected_category, new_time_range, tx); + } + MetricsType::Stream { basin_name, stream_name } => { + self.load_stream_metrics(basin_name.clone(), stream_name.clone(), new_time_range, tx); + } + } + } + KeyCode::Char(']') => { + // Next time range + let new_time_range = time_range.next(); + if let Screen::MetricsView(state) = &mut self.screen { + state.time_range = new_time_range.clone(); + state.loading = true; + state.metrics.clear(); + } + match &metrics_type { + MetricsType::Account => { + self.load_account_metrics(selected_category, new_time_range, tx); } MetricsType::Basin { basin_name } => { - self.load_basin_metrics(basin_name.clone(), selected_category, tx); + self.load_basin_metrics(basin_name.clone(), selected_category, new_time_range, tx); } MetricsType::Stream { basin_name, stream_name } => { - self.load_stream_metrics(basin_name.clone(), stream_name.clone(), tx); + self.load_stream_metrics(basin_name.clone(), stream_name.clone(), new_time_range, tx); + } + } + } + _ => {} + } + } + + /// Handle keys when time picker popup is open + fn handle_time_picker_key(&mut self, key: KeyEvent, tx: mpsc::UnboundedSender) { + // PRESETS.len() is 7 (indices 0-6), index 7 is "Custom" + const CUSTOM_INDEX: usize = 7; + + let (metrics_type, selected_category) = { + let Screen::MetricsView(state) = &self.screen else { + return; + }; + (state.metrics_type.clone(), state.selected_category) + }; + + match key.code { + KeyCode::Esc => { + // Close picker without changing + if let Screen::MetricsView(state) = &mut self.screen { + state.time_picker_open = false; + } + } + KeyCode::Up | KeyCode::Char('k') => { + if let Screen::MetricsView(state) = &mut self.screen { + if state.time_picker_selected > 0 { + state.time_picker_selected -= 1; + } + } + } + KeyCode::Down | KeyCode::Char('j') => { + if let Screen::MetricsView(state) = &mut self.screen { + if state.time_picker_selected < CUSTOM_INDEX { + state.time_picker_selected += 1; + } + } + } + KeyCode::Enter => { + let time_picker_selected = { + let Screen::MetricsView(state) = &self.screen else { + return; + }; + state.time_picker_selected + }; + + if time_picker_selected == CUSTOM_INDEX { + // Open calendar picker + if let Screen::MetricsView(state) = &mut self.screen { + state.time_picker_open = false; + state.calendar_open = true; + state.calendar_start = None; + state.calendar_end = None; + state.calendar_selecting_end = false; + } + } else { + // Select preset time range and close picker + let new_time_range = { + let Screen::MetricsView(state) = &mut self.screen else { + return; + }; + let selected = TimeRangeOption::PRESETS + .get(state.time_picker_selected) + .cloned() + .unwrap_or_default(); + state.time_range = selected.clone(); + state.time_picker_open = false; + state.loading = true; + state.metrics.clear(); + selected + }; + + // Reload metrics with new time range + match &metrics_type { + MetricsType::Account => { + self.load_account_metrics(selected_category, new_time_range, tx); + } + MetricsType::Basin { basin_name } => { + self.load_basin_metrics(basin_name.clone(), selected_category, new_time_range, tx); + } + MetricsType::Stream { basin_name, stream_name } => { + self.load_stream_metrics(basin_name.clone(), stream_name.clone(), new_time_range, tx); + } + } + } + } + _ => {} + } + } + + /// Handle keys when calendar picker is open + fn handle_calendar_key(&mut self, key: KeyEvent, tx: mpsc::UnboundedSender) { + let (metrics_type, selected_category) = { + let Screen::MetricsView(state) = &self.screen else { + return; + }; + (state.metrics_type.clone(), state.selected_category) + }; + + match key.code { + KeyCode::Esc => { + // Close calendar without changing + if let Screen::MetricsView(state) = &mut self.screen { + state.calendar_open = false; + state.calendar_start = None; + state.calendar_end = None; + } + } + KeyCode::Left | KeyCode::Char('h') => { + if let Screen::MetricsView(state) = &mut self.screen { + // Move to previous day + if state.calendar_day > 1 { + state.calendar_day -= 1; + } else { + // Go to previous month + if state.calendar_month > 1 { + state.calendar_month -= 1; + } else { + state.calendar_month = 12; + state.calendar_year -= 1; + } + state.calendar_day = Self::days_in_month(state.calendar_year, state.calendar_month); + } + } + } + KeyCode::Right | KeyCode::Char('l') => { + if let Screen::MetricsView(state) = &mut self.screen { + let max_day = Self::days_in_month(state.calendar_year, state.calendar_month); + if state.calendar_day < max_day { + state.calendar_day += 1; + } else { + // Go to next month + if state.calendar_month < 12 { + state.calendar_month += 1; + } else { + state.calendar_month = 1; + state.calendar_year += 1; + } + state.calendar_day = 1; + } + } + } + KeyCode::Up | KeyCode::Char('k') => { + if let Screen::MetricsView(state) = &mut self.screen { + // Move up one week (7 days) + if state.calendar_day > 7 { + state.calendar_day -= 7; + } else { + // Go to previous month + if state.calendar_month > 1 { + state.calendar_month -= 1; + } else { + state.calendar_month = 12; + state.calendar_year -= 1; + } + let prev_month_days = Self::days_in_month(state.calendar_year, state.calendar_month); + state.calendar_day = prev_month_days.saturating_sub(7 - state.calendar_day); + } + } + } + KeyCode::Down | KeyCode::Char('j') => { + if let Screen::MetricsView(state) = &mut self.screen { + let max_day = Self::days_in_month(state.calendar_year, state.calendar_month); + if state.calendar_day + 7 <= max_day { + state.calendar_day += 7; + } else { + let overflow = state.calendar_day + 7 - max_day; + // Go to next month + if state.calendar_month < 12 { + state.calendar_month += 1; + } else { + state.calendar_month = 1; + state.calendar_year += 1; + } + state.calendar_day = overflow.min(Self::days_in_month(state.calendar_year, state.calendar_month)); + } + } + } + KeyCode::Char('[') => { + // Previous month + if let Screen::MetricsView(state) = &mut self.screen { + if state.calendar_month > 1 { + state.calendar_month -= 1; + } else { + state.calendar_month = 12; + state.calendar_year -= 1; + } + let max_day = Self::days_in_month(state.calendar_year, state.calendar_month); + state.calendar_day = state.calendar_day.min(max_day); + } + } + KeyCode::Char(']') => { + // Next month + if let Screen::MetricsView(state) = &mut self.screen { + if state.calendar_month < 12 { + state.calendar_month += 1; + } else { + state.calendar_month = 1; + state.calendar_year += 1; + } + let max_day = Self::days_in_month(state.calendar_year, state.calendar_month); + state.calendar_day = state.calendar_day.min(max_day); + } + } + KeyCode::Enter => { + // Select date + let should_apply = { + let Screen::MetricsView(state) = &mut self.screen else { + return; + }; + let selected_date = (state.calendar_year, state.calendar_month, state.calendar_day); + + if state.calendar_start.is_none() { + // First selection: set start date + state.calendar_start = Some(selected_date); + state.calendar_selecting_end = true; + false + } else if !state.calendar_selecting_end { + // Start date already set, selecting again resets + state.calendar_start = Some(selected_date); + state.calendar_selecting_end = true; + false + } else { + // Second selection: set end date and apply + state.calendar_end = Some(selected_date); + true + } + }; + + if should_apply { + // Apply custom date range + let new_time_range = { + let Screen::MetricsView(state) = &mut self.screen else { + return; + }; + + let start_date = state.calendar_start.unwrap(); + let end_date = state.calendar_end.unwrap(); + + // Ensure start <= end + let (start, end) = if start_date <= end_date { + (start_date, end_date) + } else { + (end_date, start_date) + }; + + // Convert to unix timestamps (start of day for start, end of day for end) + let start_ts = Self::date_to_timestamp(start.0, start.1, start.2, true); + let end_ts = Self::date_to_timestamp(end.0, end.1, end.2, false); + + let time_range = TimeRangeOption::Custom { start: start_ts, end: end_ts }; + state.time_range = time_range.clone(); + state.calendar_open = false; + state.loading = true; + state.metrics.clear(); + time_range + }; + + // Reload metrics + match &metrics_type { + MetricsType::Account => { + self.load_account_metrics(selected_category, new_time_range, tx); + } + MetricsType::Basin { basin_name } => { + self.load_basin_metrics(basin_name.clone(), selected_category, new_time_range, tx); + } + MetricsType::Stream { basin_name, stream_name } => { + self.load_stream_metrics(basin_name.clone(), stream_name.clone(), new_time_range, tx); + } } } } _ => {} } } + + /// Get the number of days in a month + fn days_in_month(year: i32, month: u32) -> u32 { + NaiveDate::from_ymd_opt(year, month + 1, 1) + .unwrap_or_else(|| NaiveDate::from_ymd_opt(year + 1, 1, 1).unwrap()) + .pred_opt() + .map(|d| d.day()) + .unwrap_or(28) + } + + /// Convert a date to unix timestamp + fn date_to_timestamp(year: i32, month: u32, day: u32, start_of_day: bool) -> u32 { + use chrono::{TimeZone, Utc}; + let dt = if start_of_day { + Utc.with_ymd_and_hms(year, month, day, 0, 0, 0).unwrap() + } else { + Utc.with_ymd_and_hms(year, month, day, 23, 59, 59).unwrap() + }; + dt.timestamp() as u32 + } } diff --git a/src/tui/ui.rs b/src/tui/ui.rs index 76b92e8..8e02167 100644 --- a/src/tui/ui.rs +++ b/src/tui/ui.rs @@ -3,7 +3,7 @@ use ratatui::{ layout::{Alignment, Constraint, Direction, Layout, Rect}, style::{Color, Style, Stylize}, text::{Line, Span}, - widgets::{Block, Borders, Clear, Padding, Paragraph}, + widgets::{Block, Borders, Clear, List, ListItem, Padding, Paragraph}, }; use crate::types::{StorageClass, TimestampingMode}; @@ -94,6 +94,16 @@ pub fn draw(f: &mut Frame, app: &App) { Screen::Settings(state) => draw_settings(f, chunks[1], state), } + // Draw time picker popup if open + if let Screen::MetricsView(state) = &app.screen { + if state.time_picker_open { + draw_time_picker(f, state); + } + if state.calendar_open { + draw_calendar_picker(f, state); + } + } + // Draw status bar draw_status_bar(f, chunks[2], app); @@ -896,10 +906,14 @@ fn draw_metrics_view(f: &mut Frame, area: Rect, state: &MetricsViewState) { title_spans.push(Span::styled(format!(" {} ", cat.as_str()), style)); } + // Add time range to title + title_spans.push(Span::styled(" ", Style::default())); + title_spans.push(Span::styled(format!("[{}]", state.time_range.as_str()), Style::default().fg(CYAN))); + let title_block = Block::default() .borders(Borders::ALL) .border_style(Style::default().fg(GREEN)) - .title_bottom(Line::from(Span::styled(" ←/→ switch category ", Style::default().fg(TEXT_MUTED)))) + .title_bottom(Line::from(Span::styled(" ←/→ category t time picker ", Style::default().fg(TEXT_MUTED)))) .style(Style::default().bg(BG_PANEL)); let title_para = Paragraph::new(Line::from(title_spans)) @@ -933,10 +947,14 @@ fn draw_metrics_view(f: &mut Frame, area: Rect, state: &MetricsViewState) { title_spans.push(Span::styled(format!(" {} ", cat.as_str()), style)); } + // Add time range to title + title_spans.push(Span::styled(" ", Style::default())); + title_spans.push(Span::styled(format!("[{}]", state.time_range.as_str()), Style::default().fg(CYAN))); + let title_block = Block::default() .borders(Borders::ALL) .border_style(Style::default().fg(GREEN)) - .title_bottom(Line::from(Span::styled(" ←/→ switch category ", Style::default().fg(TEXT_MUTED)))) + .title_bottom(Line::from(Span::styled(" ←/→ category t time picker ", Style::default().fg(TEXT_MUTED)))) .style(Style::default().bg(BG_PANEL)); let title_para = Paragraph::new(Line::from(title_spans)) @@ -947,13 +965,14 @@ fn draw_metrics_view(f: &mut Frame, area: Rect, state: &MetricsViewState) { let title_block = Block::default() .borders(Borders::ALL) .border_style(Style::default().fg(GREEN)) + .title_bottom(Line::from(Span::styled(" t time picker ", Style::default().fg(TEXT_MUTED)))) .style(Style::default().bg(BG_PANEL)); let title_para = Paragraph::new(Line::from(vec![ Span::styled(" [ ", Style::default().fg(BORDER)), Span::styled(&title, Style::default().fg(GREEN).bold()), Span::styled(" ] ", Style::default().fg(BORDER)), - Span::styled("Storage (24h)", Style::default().fg(TEXT_PRIMARY)), + Span::styled(format!("Storage [{}]", state.time_range.as_str()), Style::default().fg(TEXT_PRIMARY)), ])) .block(title_block) .alignment(Alignment::Center); @@ -988,9 +1007,7 @@ fn draw_metrics_view(f: &mut Frame, area: Rect, state: &MetricsViewState) { .style(Style::default().bg(BG_DARK)); let empty = Paragraph::new(vec![ Line::from(""), - Line::from(Span::styled("No metrics data available", Style::default().fg(TEXT_MUTED))), - Line::from(""), - Line::from(Span::styled("Try writing some data first", Style::default().fg(TEXT_MUTED))), + Line::from(Span::styled("No data in the last 24 hours", Style::default().fg(TEXT_MUTED))), ]) .block(empty_block) .alignment(Alignment::Center); @@ -1003,6 +1020,23 @@ fn draw_metrics_view(f: &mut Frame, area: Rect, state: &MetricsViewState) { return; } + // Check for Label metrics first (like Active Basins) + let mut label_values: Vec = Vec::new(); + let mut label_name = String::new(); + + for metric in &state.metrics { + if let Metric::Label(m) = metric { + label_name = m.name.clone(); + label_values.extend(m.values.iter().cloned()); + } + } + + // If we have label metrics, render them differently + if !label_values.is_empty() { + render_label_metric(f, chunks, &label_name, &label_values, state); + return; + } + // Collect all time-series values for rendering let mut all_values: Vec<(u32, f64)> = Vec::new(); let mut metric_name = String::new(); @@ -1025,7 +1059,7 @@ fn draw_metrics_view(f: &mut Frame, area: Rect, state: &MetricsViewState) { metric_unit = m.unit; all_values.push((0, m.value)); } - Metric::Label(_) => {} + Metric::Label(_) => {} // Handled above } } @@ -1185,6 +1219,91 @@ fn intensity_to_color(intensity: f64) -> Color { } } +/// Render a label metric (list of string values, like Active Basins) +fn render_label_metric( + f: &mut Frame, + chunks: std::rc::Rc<[Rect]>, + metric_name: &str, + values: &[String], + state: &MetricsViewState, +) { + // Stats header showing count + let stats_block = Block::default() + .borders(Borders::ALL) + .border_style(Style::default().fg(BORDER)) + .style(Style::default().bg(BG_PANEL)); + + let stats_inner = stats_block.inner(chunks[1]); + f.render_widget(stats_block, chunks[1]); + + let stats_line = Line::from(vec![ + Span::styled(" TOTAL ", Style::default().fg(BG_DARK).bg(GREEN).bold()), + Span::styled(format!(" {} ", values.len()), Style::default().fg(GREEN).bold()), + Span::styled(format!(" {} in selected time range", metric_name.to_lowercase()), Style::default().fg(TEXT_MUTED)), + ]); + let stats_para = Paragraph::new(stats_line).alignment(Alignment::Center); + f.render_widget(stats_para, stats_inner); + + // Main list area + let list_block = Block::default() + .borders(Borders::ALL) + .border_style(Style::default().fg(GREEN)) + .title(Line::from(vec![ + Span::styled(" ", Style::default()), + Span::styled(metric_name, Style::default().fg(GREEN).bold()), + Span::styled(" ", Style::default()), + ])) + .style(Style::default().bg(BG_DARK)); + + let list_inner = list_block.inner(chunks[2]); + f.render_widget(list_block, chunks[2]); + + if values.is_empty() { + let empty = Paragraph::new(Span::styled("No data", Style::default().fg(TEXT_MUTED))) + .alignment(Alignment::Center); + f.render_widget(empty, list_inner); + } else { + // Render the list of values + let visible_rows = list_inner.height as usize; + let total_items = values.len(); + + let items: Vec = values + .iter() + .enumerate() + .skip(state.scroll) + .take(visible_rows) + .map(|(i, value)| { + Line::from(vec![ + Span::styled(format!(" {:>3}. ", i + 1), Style::default().fg(TEXT_MUTED)), + Span::styled(value, Style::default().fg(GREEN)), + ]) + }) + .collect(); + + let list_para = Paragraph::new(items); + f.render_widget(list_para, list_inner); + + // Scroll indicator in timeline area + let scroll_block = Block::default() + .borders(Borders::ALL) + .border_style(Style::default().fg(BORDER)) + .title(Line::from(vec![ + Span::styled( + format!(" Showing {}-{} of {} ", + state.scroll + 1, + (state.scroll + visible_rows).min(total_items), + total_items + ), + Style::default().fg(TEXT_MUTED) + ), + ])) + .title_bottom(Line::from(Span::styled(" j/k scroll ", Style::default().fg(TEXT_MUTED)))) + .style(Style::default().bg(BG_DARK)); + + f.render_widget(scroll_block, chunks[3]); + } +} + /// Render a beautiful area chart with Y-axis, filled area, and X-axis fn render_area_chart( f: &mut Frame, @@ -1390,6 +1509,231 @@ fn format_count(count: u64) -> String { count.to_string() } } + +/// Draw time range picker popup +fn draw_time_picker(f: &mut Frame, state: &MetricsViewState) { + use super::app::TimeRangeOption; + + let area = f.area(); + + // PRESETS.len() + 1 for "Custom" option + let item_count = TimeRangeOption::PRESETS.len() + 1; + + // Calculate popup dimensions + let popup_width = 30u16; + let popup_height = (item_count as u16) + 4; // Items + borders + title + + // Center the popup + let popup_x = (area.width.saturating_sub(popup_width)) / 2; + let popup_y = (area.height.saturating_sub(popup_height)) / 2; + + let popup_area = Rect::new(popup_x, popup_y, popup_width, popup_height); + + // Clear the area behind the popup + f.render_widget(Clear, popup_area); + + // Build the list items + let mut items: Vec = TimeRangeOption::PRESETS + .iter() + .enumerate() + .map(|(i, option)| { + let is_selected = i == state.time_picker_selected; + let is_current = std::mem::discriminant(option) == std::mem::discriminant(&state.time_range); + + let style = if is_selected { + Style::default().fg(BG_DARK).bg(GREEN).bold() + } else if is_current { + Style::default().fg(GREEN) + } else { + Style::default().fg(TEXT_PRIMARY) + }; + + let marker = if is_current { " ✓" } else { "" }; + ListItem::new(format!(" {}{} ", option.as_label(), marker)).style(style) + }) + .collect(); + + // Add "Custom" option at index 7 + let custom_index = TimeRangeOption::PRESETS.len(); + let is_custom_selected = state.time_picker_selected == custom_index; + let is_custom_current = matches!(state.time_range, TimeRangeOption::Custom { .. }); + let custom_style = if is_custom_selected { + Style::default().fg(BG_DARK).bg(GREEN).bold() + } else if is_custom_current { + Style::default().fg(GREEN) + } else { + Style::default().fg(CYAN) + }; + let custom_marker = if is_custom_current { " ✓" } else { "" }; + items.push(ListItem::new(format!(" Custom range...{} ", custom_marker)).style(custom_style)); + + let list = List::new(items) + .block( + Block::default() + .borders(Borders::ALL) + .border_style(Style::default().fg(GREEN)) + .title(Line::from(vec![ + Span::styled(" ", Style::default()), + Span::styled("Time Range", Style::default().fg(GREEN).bold()), + Span::styled(" ", Style::default()), + ])) + .title_bottom(Line::from(Span::styled( + " Enter select Esc close ", + Style::default().fg(TEXT_MUTED), + ))) + .style(Style::default().bg(BG_PANEL)), + ); + + f.render_widget(list, popup_area); +} + +/// Draw calendar date picker +fn draw_calendar_picker(f: &mut Frame, state: &MetricsViewState) { + use chrono::{Datelike, NaiveDate}; + + let area = f.area(); + + // Calendar dimensions: 7 columns * 4 chars + padding = 32, height for header + 6 weeks + status + let popup_width = 36u16; + let popup_height = 14u16; + + // Center the popup + let popup_x = (area.width.saturating_sub(popup_width)) / 2; + let popup_y = (area.height.saturating_sub(popup_height)) / 2; + + let popup_area = Rect::new(popup_x, popup_y, popup_width, popup_height); + + // Clear the area behind the popup + f.render_widget(Clear, popup_area); + + // Month names + let month_names = [ + "January", "February", "March", "April", "May", "June", + "July", "August", "September", "October", "November", "December" + ]; + let month_name = month_names.get(state.calendar_month as usize - 1).unwrap_or(&"???"); + + // Calculate first day of month and days in month + let first_of_month = NaiveDate::from_ymd_opt(state.calendar_year, state.calendar_month, 1) + .unwrap_or(NaiveDate::from_ymd_opt(2026, 1, 1).unwrap()); + let first_weekday = first_of_month.weekday().num_days_from_sunday() as usize; + let days_in_month = { + let next_month = if state.calendar_month == 12 { + NaiveDate::from_ymd_opt(state.calendar_year + 1, 1, 1) + } else { + NaiveDate::from_ymd_opt(state.calendar_year, state.calendar_month + 1, 1) + }; + next_month.unwrap().pred_opt().map(|d| d.day()).unwrap_or(28) + }; + + // Build calendar lines + let mut lines: Vec = Vec::new(); + + // Month/Year header with navigation hints + lines.push(Line::from(vec![ + Span::styled(" [", Style::default().fg(TEXT_MUTED)), + Span::styled(format!("{} {}", month_name, state.calendar_year), Style::default().fg(GREEN).bold()), + Span::styled("] ", Style::default().fg(TEXT_MUTED)), + ])); + + // Day headers + lines.push(Line::from(vec![ + Span::styled(" Su ", Style::default().fg(TEXT_MUTED)), + Span::styled(" Mo ", Style::default().fg(TEXT_MUTED)), + Span::styled(" Tu ", Style::default().fg(TEXT_MUTED)), + Span::styled(" We ", Style::default().fg(TEXT_MUTED)), + Span::styled(" Th ", Style::default().fg(TEXT_MUTED)), + Span::styled(" Fr ", Style::default().fg(TEXT_MUTED)), + Span::styled(" Sa ", Style::default().fg(TEXT_MUTED)), + ])); + + // Calendar grid + let mut day = 1u32; + for week in 0..6 { + let mut spans: Vec = Vec::new(); + for weekday in 0..7 { + let cell_idx = week * 7 + weekday; + if cell_idx < first_weekday || day > days_in_month { + spans.push(Span::styled(" ", Style::default())); + } else { + let is_selected = day == state.calendar_day; + let current_date = (state.calendar_year, state.calendar_month, day); + + // Check if this day is the start or end of selection + let is_start = state.calendar_start == Some(current_date); + let is_end = state.calendar_end == Some(current_date); + + // Check if this day is in the selected range + let in_range = match (state.calendar_start, state.calendar_end) { + (Some(start), Some(end)) => { + let (s, e) = if start <= end { (start, end) } else { (end, start) }; + current_date >= s && current_date <= e + } + (Some(start), None) if state.calendar_selecting_end => { + // Show range preview while selecting end + let (s, e) = if start <= current_date { (start, current_date) } else { (current_date, start) }; + current_date >= s && current_date <= e && is_selected + } + _ => false, + }; + + let style = if is_selected { + Style::default().fg(BG_DARK).bg(GREEN).bold() + } else if is_start || is_end { + Style::default().fg(BG_DARK).bg(CYAN).bold() + } else if in_range { + Style::default().fg(GREEN).bg(BG_PANEL) + } else { + Style::default().fg(TEXT_PRIMARY) + }; + + spans.push(Span::styled(format!("{:>3} ", day), style)); + day += 1; + } + } + lines.push(Line::from(spans)); + if day > days_in_month { + break; + } + } + + // Selection status + let status = match (state.calendar_start, state.calendar_end) { + (Some((sy, sm, sd)), Some((ey, em, ed))) => { + format!("{:02}/{:02}/{} - {:02}/{:02}/{}", sm, sd, sy, em, ed, ey) + } + (Some((sy, sm, sd)), None) => { + if state.calendar_selecting_end { + format!("{:02}/{:02}/{} - select end", sm, sd, sy) + } else { + format!("Select start date") + } + } + _ => "Select start date".to_string(), + }; + lines.push(Line::from(Span::styled(format!(" {} ", status), Style::default().fg(CYAN)))); + + let calendar_block = Block::default() + .borders(Borders::ALL) + .border_style(Style::default().fg(GREEN)) + .title(Line::from(vec![ + Span::styled(" ", Style::default()), + Span::styled("Select Date Range", Style::default().fg(GREEN).bold()), + Span::styled(" ", Style::default()), + ])) + .title_bottom(Line::from(Span::styled( + " ←→↑↓ nav [/] month Enter select Esc cancel ", + Style::default().fg(TEXT_MUTED), + ))) + .style(Style::default().bg(BG_PANEL)); + + let calendar_para = Paragraph::new(lines) + .block(calendar_block) + .alignment(Alignment::Center); + + f.render_widget(calendar_para, popup_area); +} + fn draw_basins(f: &mut Frame, area: Rect, state: &BasinsState) { // Layout: Title bar, Search bar, Header, Table rows let chunks = Layout::default() From d3c61f9ce2f3784509d4b9a1b1a73fd8d987f6ba Mon Sep 17 00:00:00 2001 From: Mehul Arora Date: Sat, 24 Jan 2026 12:48:39 -0500 Subject: [PATCH 14/31] . --- src/tui/ui.rs | 198 +++++++++++++++++++++++++++++++++++++++++++++++++- 1 file changed, 197 insertions(+), 1 deletion(-) diff --git a/src/tui/ui.rs b/src/tui/ui.rs index 8e02167..92084c2 100644 --- a/src/tui/ui.rs +++ b/src/tui/ui.rs @@ -1037,7 +1037,18 @@ fn draw_metrics_view(f: &mut Frame, area: Rect, state: &MetricsViewState) { return; } - // Collect all time-series values for rendering + // Check if we have multiple Accumulation metrics (like Account Ops) + let accumulation_metrics: Vec<_> = state.metrics.iter() + .filter_map(|m| if let Metric::Accumulation(a) = m { Some(a) } else { None }) + .collect(); + + if accumulation_metrics.len() > 1 { + // Multiple metrics - render as operations breakdown + render_multi_metric(f, chunks, &accumulation_metrics, state); + return; + } + + // Collect all time-series values for rendering (single metric) let mut all_values: Vec<(u32, f64)> = Vec::new(); let mut metric_name = String::new(); let mut metric_unit = s2_sdk::types::MetricUnit::Bytes; @@ -1219,6 +1230,191 @@ fn intensity_to_color(intensity: f64) -> Color { } } +/// Render multiple accumulation metrics (like Account Ops breakdown) +fn render_multi_metric( + f: &mut Frame, + chunks: std::rc::Rc<[Rect]>, + metrics: &[&s2_sdk::types::AccumulationMetric], + state: &MetricsViewState, +) { + use std::collections::BTreeMap; + + // Color palette for different operation types (purple/blue gradient like web console) + let colors = [ + Color::Rgb(139, 92, 246), // Purple (primary) + Color::Rgb(124, 58, 237), // Violet + Color::Rgb(99, 102, 241), // Indigo + Color::Rgb(79, 70, 229), // Deep indigo + Color::Rgb(59, 130, 246), // Blue + Color::Rgb(37, 99, 235), // Royal blue + Color::Rgb(96, 165, 250), // Light blue + Color::Rgb(147, 197, 253), // Pale blue + Color::Rgb(250, 204, 21), // Yellow (for highlights) + Color::Rgb(251, 146, 60), // Orange + ]; + + // Calculate totals for each metric and sort by total + let mut metric_totals: Vec<(String, f64, usize)> = metrics + .iter() + .enumerate() + .map(|(i, m)| { + let total: f64 = m.values.iter().map(|(_, v)| v).sum(); + (m.name.clone(), total, i) + }) + .collect(); + metric_totals.sort_by(|a, b| b.1.partial_cmp(&a.1).unwrap_or(std::cmp::Ordering::Equal)); + + // Aggregate all values by timestamp for the area chart (sum of all operation types) + let mut time_buckets: BTreeMap = BTreeMap::new(); + for metric in metrics.iter() { + for (ts, val) in &metric.values { + *time_buckets.entry(*ts).or_default() += val; + } + } + + let all_values: Vec<(u32, f64)> = time_buckets.into_iter().collect(); + let values_only: Vec = all_values.iter().map(|(_, v)| *v).collect(); + + let grand_total: f64 = values_only.iter().sum(); + let min_val = values_only.iter().cloned().fold(f64::MAX, f64::min); + let max_val = values_only.iter().cloned().fold(f64::MIN, f64::max); + let avg_val = if !values_only.is_empty() { + grand_total / values_only.len() as f64 + } else { + 0.0 + }; + let latest_val = values_only.last().cloned().unwrap_or(0.0); + let first_val = values_only.first().cloned().unwrap_or(0.0); + let first_ts = all_values.first().map(|(ts, _)| *ts).unwrap_or(0); + let last_ts = all_values.last().map(|(ts, _)| *ts).unwrap_or(0); + + // Calculate change for trend indicator + let change = if first_val > 0.0 { + ((latest_val - first_val) / first_val) * 100.0 + } else if latest_val > 0.0 { + 100.0 + } else { + 0.0 + }; + + // === Stats header row (nerdy style) === + let stats_block = Block::default() + .borders(Borders::ALL) + .border_style(Style::default().fg(BORDER)) + .style(Style::default().bg(BG_PANEL)); + + let stats_inner = stats_block.inner(chunks[1]); + f.render_widget(stats_block, chunks[1]); + + let (trend_arrow, trend_color) = if change > 1.0 { + ("↑", Color::Rgb(34, 197, 94)) + } else if change < -1.0 { + ("↓", Color::Rgb(239, 68, 68)) + } else { + ("→", TEXT_MUTED) + }; + let trend_text = if change.abs() > 0.1 { + format!("{:+.1}%", change) + } else { + "stable".to_string() + }; + + let stats_line = Line::from(vec![ + Span::styled(" NOW ", Style::default().fg(BG_DARK).bg(GREEN).bold()), + Span::styled(format!(" {} ", format_count(latest_val as u64)), Style::default().fg(GREEN).bold()), + Span::styled(trend_arrow, Style::default().fg(trend_color).bold()), + Span::styled(format!("{} ", trend_text), Style::default().fg(trend_color)), + Span::styled(" | ", Style::default().fg(BORDER)), + Span::styled("min ", Style::default().fg(TEXT_MUTED)), + Span::styled(format_count(min_val as u64), Style::default().fg(Color::Rgb(96, 165, 250))), + Span::styled(" max ", Style::default().fg(TEXT_MUTED)), + Span::styled(format_count(max_val as u64), Style::default().fg(Color::Rgb(251, 191, 36))), + Span::styled(" avg ", Style::default().fg(TEXT_MUTED)), + Span::styled(format_count(avg_val as u64), Style::default().fg(Color::Rgb(167, 139, 250))), + Span::styled(format!(" | {} pts", all_values.len()), Style::default().fg(TEXT_MUTED)), + ]); + let stats_para = Paragraph::new(stats_line).alignment(Alignment::Center); + f.render_widget(stats_para, stats_inner); + + // === Main Area Chart (total operations over time) === + let chart_block = Block::default() + .borders(Borders::ALL) + .border_style(Style::default().fg(GREEN)) + .title(Line::from(vec![ + Span::styled(" ", Style::default()), + Span::styled("Total Operations", Style::default().fg(GREEN).bold()), + Span::styled(" ", Style::default()), + ])) + .style(Style::default().bg(BG_DARK)); + + let chart_inner = chart_block.inner(chunks[2]); + f.render_widget(chart_block, chunks[2]); + + if !all_values.is_empty() { + render_area_chart(f, chart_inner, &all_values, min_val, max_val, s2_sdk::types::MetricUnit::Operations, first_ts, last_ts); + } + + // === Timeline/Legend area - show breakdown by operation type === + let timeline_block = Block::default() + .borders(Borders::ALL) + .border_style(Style::default().fg(BORDER)) + .title(Line::from(vec![ + Span::styled(" Breakdown ", Style::default().fg(TEXT_PRIMARY)), + Span::styled(format!("({} operation types)", metrics.len()), Style::default().fg(TEXT_MUTED)), + ])) + .title_bottom(Line::from(Span::styled(" j/k scroll ", Style::default().fg(TEXT_MUTED)))) + .style(Style::default().bg(BG_DARK)); + + let timeline_inner = timeline_block.inner(chunks[3]); + f.render_widget(timeline_block, chunks[3]); + + // Render breakdown as horizontal bars + let visible_rows = timeline_inner.height as usize; + let bar_width = timeline_inner.width.saturating_sub(28) as usize; + let max_total = metric_totals.iter().map(|(_, t, _)| *t).fold(0.0, f64::max); + + let lines: Vec = metric_totals + .iter() + .enumerate() + .skip(state.scroll) + .take(visible_rows) + .map(|(_i, (name, total, orig_idx))| { + let color = colors[*orig_idx % colors.len()]; + let bar_len = if max_total > 0.0 { + ((total / max_total) * bar_width as f64) as usize + } else { + 0 + }; + + let bar: String = "█".repeat(bar_len); + let percentage = if grand_total > 0.0 { + (total / grand_total) * 100.0 + } else { + 0.0 + }; + + let display_name = name.replace('_', " "); + let display_name = if display_name.len() > 14 { + format!("{}…", &display_name[..13]) + } else { + display_name + }; + + Line::from(vec![ + Span::styled(format!(" {:>14} ", display_name), Style::default().fg(TEXT_PRIMARY)), + Span::styled(bar, Style::default().fg(color)), + Span::styled( + format!(" {:>6} ({:>4.1}%)", format_count(*total as u64), percentage), + Style::default().fg(TEXT_SECONDARY) + ), + ]) + }) + .collect(); + + let bars_para = Paragraph::new(lines); + f.render_widget(bars_para, timeline_inner); +} + /// Render a label metric (list of string values, like Active Basins) fn render_label_metric( f: &mut Frame, From 01d25fad0ce74eb3a847e4883c5a6257c69ed1bd Mon Sep 17 00:00:00 2001 From: Mehul Arora Date: Sat, 24 Jan 2026 13:18:53 -0500 Subject: [PATCH 15/31] . --- src/tui/app.rs | 82 +++++++++++++++++++++++++++----------------------- 1 file changed, 44 insertions(+), 38 deletions(-) diff --git a/src/tui/app.rs b/src/tui/app.rs index 39f4c40..85f0e83 100644 --- a/src/tui/app.rs +++ b/src/tui/app.rs @@ -1067,9 +1067,49 @@ impl App { self.screen = Screen::Basins(basins_state); } - // Handle events + // Always check keyboard input first (non-blocking) to ensure responsiveness + // even when async events are flooding in + if event::poll(Duration::from_millis(0)) + .map_err(|e| CliError::RecordWrite(format!("Failed to poll events: {e}")))? + { + if let CrosstermEvent::Key(key) = event::read() + .map_err(|e| CliError::RecordWrite(format!("Failed to read event: {e}")))? + { + // Skip to basins on any key during splash + if matches!(self.screen, Screen::Splash) { + let mut basins_state = BasinsState { + loading: pending_basins.is_none(), + ..Default::default() + }; + if let Some(result) = pending_basins.take() { + match result { + Ok(basins) => { + basins_state.basins = basins; + basins_state.loading = false; + } + Err(e) => { + basins_state.loading = false; + self.message = Some(StatusMessage { + text: format!("Failed to load basins: {e}"), + level: MessageLevel::Error, + }); + } + } + } + self.screen = Screen::Basins(basins_state); + continue; + } + self.handle_key(key, tx.clone()); + } + } + + // Check quit early to avoid unnecessary async processing + if self.should_quit { + break; + } + + // Handle async events from background tasks with a short timeout tokio::select! { - // Handle async events from background tasks Some(event) = rx.recv() => { // If on splash screen, cache the basins result if matches!(self.screen, Screen::Splash) { @@ -1081,42 +1121,8 @@ impl App { self.handle_event(event); } - // Handle keyboard input - _ = tokio::time::sleep(Duration::from_millis(50)) => { - if event::poll(Duration::from_millis(0)) - .map_err(|e| CliError::RecordWrite(format!("Failed to poll events: {e}")))? - { - if let CrosstermEvent::Key(key) = event::read() - .map_err(|e| CliError::RecordWrite(format!("Failed to read event: {e}")))? - { - // Skip to basins on any key during splash - if matches!(self.screen, Screen::Splash) { - let mut basins_state = BasinsState { - loading: pending_basins.is_none(), - ..Default::default() - }; - if let Some(result) = pending_basins.take() { - match result { - Ok(basins) => { - basins_state.basins = basins; - basins_state.loading = false; - } - Err(e) => { - basins_state.loading = false; - self.message = Some(StatusMessage { - text: format!("Failed to load basins: {e}"), - level: MessageLevel::Error, - }); - } - } - } - self.screen = Screen::Basins(basins_state); - continue; - } - self.handle_key(key, tx.clone()); - } - } - } + // Small sleep to prevent busy-looping when no events + _ = tokio::time::sleep(Duration::from_millis(16)) => {} } if self.should_quit { From 2674ae444deb7c21de88d61102f9427695766ff5 Mon Sep 17 00:00:00 2001 From: Mehul Arora Date: Sat, 24 Jan 2026 14:03:07 -0500 Subject: [PATCH 16/31] . --- src/bench.rs | 30 +-- src/tui/app.rs | 673 ++++++++++++++++++++++++++++++++++++++++++++++- src/tui/event.rs | 63 ++++- src/tui/ui.rs | 537 ++++++++++++++++++++++++++++++++++++- src/types.rs | 1 + 5 files changed, 1283 insertions(+), 21 deletions(-) diff --git a/src/bench.rs b/src/bench.rs index 5618398..6cbf50b 100644 --- a/src/bench.rs +++ b/src/bench.rs @@ -34,20 +34,20 @@ const WRITE_DONE_SENTINEL: u64 = u64::MAX; type PendingAck = Pin)> + Send>>; -struct BenchWriteSample { - bytes: u64, - records: u64, - elapsed: Duration, - ack_latencies: Vec, - chain_hash: Option, +pub struct BenchWriteSample { + pub bytes: u64, + pub records: u64, + pub elapsed: Duration, + pub ack_latencies: Vec, + pub chain_hash: Option, } -struct BenchReadSample { - bytes: u64, - records: u64, - elapsed: Duration, - e2e_latencies: Vec, - chain_hash: Option, +pub struct BenchReadSample { + pub bytes: u64, + pub records: u64, + pub elapsed: Duration, + pub e2e_latencies: Vec, + pub chain_hash: Option, } trait BenchSample { @@ -136,7 +136,7 @@ fn chain_hash(prev_hash: u64, body: &[u8]) -> u64 { hasher.digest() } -fn bench_write( +pub fn bench_write( stream: S2Stream, record_size: usize, target_mibps: NonZeroU64, @@ -258,7 +258,7 @@ fn bench_write( } } -fn bench_read( +pub fn bench_read( stream: S2Stream, record_size: usize, write_done_records: Arc, @@ -273,7 +273,7 @@ fn bench_read( ) } -fn bench_read_catchup( +pub fn bench_read_catchup( stream: S2Stream, record_size: usize, bench_start: Instant, diff --git a/src/tui/app.rs b/src/tui/app.rs index 85f0e83..c8ab2fe 100644 --- a/src/tui/app.rs +++ b/src/tui/app.rs @@ -17,7 +17,7 @@ use crate::ops; use crate::record_format::{RecordFormat, RecordsOut}; use crate::types::{BasinConfig, DeleteOnEmptyConfig, Operation, RetentionPolicy, S2BasinAndMaybeStreamUri, S2BasinAndStreamUri, S2BasinUri, StorageClass, StreamConfig, TimestampingConfig, TimestampingMode}; -use super::event::{BasinConfigInfo, Event, StreamConfigInfo}; +use super::event::{BasinConfigInfo, BenchFinalStats, BenchPhase, BenchSample, Event, StreamConfigInfo}; use super::ui; /// Maximum records to keep in read view buffer @@ -45,6 +45,7 @@ pub enum Screen { AccessTokens(AccessTokensState), MetricsView(MetricsViewState), Settings(SettingsState), + BenchView(BenchViewState), } /// State for the setup screen (first-time token entry) @@ -395,6 +396,125 @@ pub struct MetricsViewState { pub calendar_selecting_end: bool, // true if selecting end date } +/// Benchmark configuration phase +#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)] +pub enum BenchConfigField { + #[default] + RecordSize, + TargetMibps, + Duration, + CatchupDelay, + Start, +} + +impl BenchConfigField { + pub fn next(&self) -> Self { + match self { + Self::RecordSize => Self::TargetMibps, + Self::TargetMibps => Self::Duration, + Self::Duration => Self::CatchupDelay, + Self::CatchupDelay => Self::Start, + Self::Start => Self::RecordSize, + } + } + + pub fn prev(&self) -> Self { + match self { + Self::RecordSize => Self::Start, + Self::TargetMibps => Self::RecordSize, + Self::Duration => Self::TargetMibps, + Self::CatchupDelay => Self::Duration, + Self::Start => Self::CatchupDelay, + } + } +} + +/// State for the benchmark view +#[derive(Debug, Clone)] +pub struct BenchViewState { + pub basin_name: BasinName, + // Configuration (shown before running) + pub config_phase: bool, + pub config_field: BenchConfigField, + pub record_size: u32, // bytes (default 8KB) + pub target_mibps: u64, // MiB/s (default 1) + pub duration_secs: u64, // seconds (default 60) + pub catchup_delay_secs: u64, // seconds (default 20) + pub editing: bool, + pub edit_buffer: String, + // Runtime state + pub stream_name: Option, + pub phase: BenchPhase, + pub running: bool, + pub paused: bool, + pub stopping: bool, + // Progress + pub elapsed_secs: f64, + pub progress_pct: f64, + // Write stats + pub write_mibps: f64, + pub write_recps: f64, + pub write_bytes: u64, + pub write_records: u64, + pub write_history: Vec, // throughput history for sparkline + // Read stats + pub read_mibps: f64, + pub read_recps: f64, + pub read_bytes: u64, + pub read_records: u64, + pub read_history: Vec, + // Catchup stats + pub catchup_mibps: f64, + pub catchup_recps: f64, + pub catchup_bytes: u64, + pub catchup_records: u64, + // Latency stats (final) + pub ack_latency: Option, + pub e2e_latency: Option, + // Error + pub error: Option, +} + +impl BenchViewState { + pub fn new(basin_name: BasinName) -> Self { + Self { + basin_name, + config_phase: true, + config_field: BenchConfigField::default(), + record_size: 8 * 1024, // 8 KB + target_mibps: 1, + duration_secs: 60, + catchup_delay_secs: 20, + editing: false, + edit_buffer: String::new(), + stream_name: None, + phase: BenchPhase::Write, + running: false, + paused: false, + stopping: false, + elapsed_secs: 0.0, + progress_pct: 0.0, + write_mibps: 0.0, + write_recps: 0.0, + write_bytes: 0, + write_records: 0, + write_history: Vec::new(), + read_mibps: 0.0, + read_recps: 0.0, + read_bytes: 0, + read_records: 0, + read_history: Vec::new(), + catchup_mibps: 0.0, + catchup_recps: 0.0, + catchup_bytes: 0, + catchup_records: 0, + ack_latency: None, + e2e_latency: None, + error: None, + } + } +} + /// Status message level #[derive(Debug, Clone, Copy, PartialEq, Eq)] pub enum MessageLevel { @@ -1635,6 +1755,94 @@ impl App { level: MessageLevel::Error, }); } + + Event::BenchStreamCreated(result) => { + if let Screen::BenchView(state) = &mut self.screen { + match result { + Ok(stream_name) => { + state.stream_name = Some(stream_name); + state.running = true; + self.message = Some(StatusMessage { + text: "Benchmark started".to_string(), + level: MessageLevel::Info, + }); + } + Err(e) => { + state.error = Some(e.to_string()); + state.running = false; + } + } + } + } + + Event::BenchWriteSample(sample) => { + if let Screen::BenchView(state) = &mut self.screen { + state.write_mibps = sample.mib_per_sec; + state.write_recps = sample.records_per_sec; + state.write_bytes = sample.bytes; + state.write_records = sample.records; + state.elapsed_secs = sample.elapsed.as_secs_f64(); + state.progress_pct = (state.elapsed_secs / state.duration_secs as f64) * 100.0; + // Keep last 60 samples for sparkline + state.write_history.push(sample.mib_per_sec); + if state.write_history.len() > 60 { + state.write_history.remove(0); + } + } + } + + Event::BenchReadSample(sample) => { + if let Screen::BenchView(state) = &mut self.screen { + state.read_mibps = sample.mib_per_sec; + state.read_recps = sample.records_per_sec; + state.read_bytes = sample.bytes; + state.read_records = sample.records; + // Keep last 60 samples for sparkline + state.read_history.push(sample.mib_per_sec); + if state.read_history.len() > 60 { + state.read_history.remove(0); + } + } + } + + Event::BenchCatchupSample(sample) => { + if let Screen::BenchView(state) = &mut self.screen { + state.catchup_mibps = sample.mib_per_sec; + state.catchup_recps = sample.records_per_sec; + state.catchup_bytes = sample.bytes; + state.catchup_records = sample.records; + } + } + + Event::BenchPhaseComplete(phase) => { + if let Screen::BenchView(state) = &mut self.screen { + state.phase = match phase { + BenchPhase::Write => BenchPhase::Read, + BenchPhase::Read => BenchPhase::CatchupWait, + BenchPhase::CatchupWait => BenchPhase::Catchup, + BenchPhase::Catchup => BenchPhase::Catchup, // Final + }; + } + } + + Event::BenchComplete(result) => { + if let Screen::BenchView(state) = &mut self.screen { + state.running = false; + match result { + Ok(stats) => { + state.ack_latency = stats.ack_latency; + state.e2e_latency = stats.e2e_latency; + self.message = Some(StatusMessage { + text: "Benchmark complete!".to_string(), + level: MessageLevel::Success, + }); + } + Err(e) => { + state.error = Some(e.to_string()); + } + } + } + } } } @@ -1699,6 +1907,7 @@ impl App { Screen::AccessTokens(_) => self.handle_access_tokens_key(key, tx), Screen::MetricsView(_) => self.handle_metrics_view_key(key, tx), Screen::Settings(_) => self.handle_settings_key(key, tx), + Screen::BenchView(_) => self.handle_bench_view_key(key, tx), } } @@ -3161,6 +3370,13 @@ impl App { // Account Metrics self.open_account_metrics(tx); } + KeyCode::Char('B') => { + // Benchmark on selected basin + if let Some(basin) = filtered.get(state.selected) { + let basin_name = basin.name.clone(); + self.screen = Screen::BenchView(BenchViewState::new(basin_name)); + } + } KeyCode::Esc => { if !state.filter.is_empty() { state.filter.clear(); @@ -5876,4 +6092,459 @@ impl App { }; dt.timestamp() as u32 } + + fn handle_bench_view_key(&mut self, key: KeyEvent, tx: mpsc::UnboundedSender) { + let Screen::BenchView(state) = &mut self.screen else { + return; + }; + + // If running, only allow stop/pause + if state.running { + match key.code { + KeyCode::Esc | KeyCode::Char('q') => { + state.stopping = true; + self.message = Some(StatusMessage { + text: "Stopping benchmark...".to_string(), + level: MessageLevel::Info, + }); + } + KeyCode::Char(' ') => { + state.paused = !state.paused; + self.message = Some(StatusMessage { + text: if state.paused { "Paused".to_string() } else { "Resumed".to_string() }, + level: MessageLevel::Info, + }); + } + _ => {} + } + return; + } + + // If showing results, allow going back + if !state.config_phase && !state.running { + match key.code { + KeyCode::Esc | KeyCode::Char('q') => { + // Go back to basins + self.screen = Screen::Basins(BasinsState::default()); + self.load_basins(tx); + } + KeyCode::Char('r') => { + // Reset to config phase + let basin_name = state.basin_name.clone(); + self.screen = Screen::BenchView(BenchViewState::new(basin_name)); + } + _ => {} + } + return; + } + + // Config phase + if state.editing { + match key.code { + KeyCode::Esc => { + state.editing = false; + state.edit_buffer.clear(); + } + KeyCode::Enter => { + // Apply the edit + if let Ok(val) = state.edit_buffer.parse::() { + match state.config_field { + BenchConfigField::RecordSize => { + let val = val.clamp(128, 1024 * 1024) as u32; + state.record_size = val; + } + BenchConfigField::TargetMibps => { + state.target_mibps = val.max(1); + } + BenchConfigField::Duration => { + state.duration_secs = val.max(1); + } + BenchConfigField::CatchupDelay => { + state.catchup_delay_secs = val; + } + BenchConfigField::Start => {} + } + } + state.editing = false; + state.edit_buffer.clear(); + } + KeyCode::Char(c) if c.is_ascii_digit() => { + state.edit_buffer.push(c); + } + KeyCode::Backspace => { + state.edit_buffer.pop(); + } + _ => {} + } + return; + } + + match key.code { + KeyCode::Esc | KeyCode::Char('q') => { + // Go back to basins + self.screen = Screen::Basins(BasinsState::default()); + self.load_basins(tx); + } + KeyCode::Up | KeyCode::Char('k') => { + state.config_field = state.config_field.prev(); + } + KeyCode::Down | KeyCode::Char('j') => { + state.config_field = state.config_field.next(); + } + KeyCode::Enter => { + if state.config_field == BenchConfigField::Start { + // Start the benchmark + let basin_name = state.basin_name.clone(); + let record_size = state.record_size; + let target_mibps = state.target_mibps; + let duration_secs = state.duration_secs; + let catchup_delay_secs = state.catchup_delay_secs; + state.config_phase = false; + self.start_benchmark(basin_name, record_size, target_mibps, duration_secs, catchup_delay_secs, tx); + } else { + // Edit the field + state.editing = true; + state.edit_buffer = match state.config_field { + BenchConfigField::RecordSize => state.record_size.to_string(), + BenchConfigField::TargetMibps => state.target_mibps.to_string(), + BenchConfigField::Duration => state.duration_secs.to_string(), + BenchConfigField::CatchupDelay => state.catchup_delay_secs.to_string(), + BenchConfigField::Start => String::new(), + }; + } + } + KeyCode::Left | KeyCode::Char('h') => { + // Decrease value + match state.config_field { + BenchConfigField::RecordSize => { + state.record_size = (state.record_size / 2).max(128); + } + BenchConfigField::TargetMibps => { + state.target_mibps = (state.target_mibps.saturating_sub(1)).max(1); + } + BenchConfigField::Duration => { + state.duration_secs = (state.duration_secs.saturating_sub(10)).max(10); + } + BenchConfigField::CatchupDelay => { + state.catchup_delay_secs = state.catchup_delay_secs.saturating_sub(5); + } + BenchConfigField::Start => {} + } + } + KeyCode::Right | KeyCode::Char('l') => { + // Increase value + match state.config_field { + BenchConfigField::RecordSize => { + state.record_size = (state.record_size * 2).min(1024 * 1024); + } + BenchConfigField::TargetMibps => { + state.target_mibps = state.target_mibps.saturating_add(1).min(100); + } + BenchConfigField::Duration => { + state.duration_secs = state.duration_secs.saturating_add(10).min(600); + } + BenchConfigField::CatchupDelay => { + state.catchup_delay_secs = state.catchup_delay_secs.saturating_add(5).min(120); + } + BenchConfigField::Start => {} + } + } + _ => {} + } + } + + fn start_benchmark( + &self, + basin_name: BasinName, + record_size: u32, + target_mibps: u64, + duration_secs: u64, + catchup_delay_secs: u64, + tx: mpsc::UnboundedSender, + ) { + let Some(s2) = self.s2.clone() else { + let _ = tx.send(Event::BenchStreamCreated(Err(CliError::Config( + crate::error::CliConfigError::MissingAccessToken, + )))); + return; + }; + + tokio::spawn(async move { + use s2_sdk::types::{CreateStreamInput, DeleteStreamInput, StreamName}; + use std::num::NonZeroU64; + use std::time::Duration; + + // Create a temporary stream for the benchmark + let stream_name: StreamName = format!("_bench_{}", uuid::Uuid::new_v4()) + .parse() + .expect("valid stream name"); + let stream_name_str = stream_name.to_string(); + + // Get basin client + let basin = s2.basin(basin_name.clone()); + + // Create the stream + if let Err(e) = basin + .create_stream(CreateStreamInput::new(stream_name.clone())) + .await + { + let _ = tx.send(Event::BenchStreamCreated(Err(CliError::op( + crate::error::OpKind::Bench, + e, + )))); + return; + } + + let _ = tx.send(Event::BenchStreamCreated(Ok(stream_name_str))); + + // Get stream handle + let stream = basin.stream(stream_name.clone()); + + // Run the benchmark with events + let result = run_bench_with_events( + stream, + record_size as usize, + NonZeroU64::new(target_mibps).unwrap_or(NonZeroU64::new(1).unwrap()), + Duration::from_secs(duration_secs), + Duration::from_secs(catchup_delay_secs), + tx.clone(), + ) + .await; + + // Clean up the stream + let _ = basin.delete_stream(DeleteStreamInput::new(stream_name)).await; + + let _ = tx.send(Event::BenchComplete(result)); + }); + } +} + +/// Run the benchmark and send events to the TUI +async fn run_bench_with_events( + stream: s2_sdk::S2Stream, + record_size: usize, + target_mibps: std::num::NonZeroU64, + duration: std::time::Duration, + catchup_delay: std::time::Duration, + tx: mpsc::UnboundedSender, +) -> Result { + use crate::bench::*; + use crate::types::LatencyStats; + use futures::StreamExt; + use std::sync::atomic::{AtomicBool, AtomicU64, Ordering}; + use std::sync::Arc; + use std::time::Duration; + use tokio::time::Instant; + + const WRITE_DONE_SENTINEL: u64 = u64::MAX; + + let bench_start = Instant::now(); + let stop = Arc::new(AtomicBool::new(false)); + let write_done_records = Arc::new(AtomicU64::new(WRITE_DONE_SENTINEL)); + + // We need to re-implement the bench logic here to send events + // For now, let's use a simplified version that calls into bench.rs + // and extracts the stats + + // Actually, let's just run the benchmark and send periodic updates + // The bench module already has the core logic, we just need to wrap it + + let mut write_bytes: u64 = 0; + let mut write_records: u64 = 0; + let mut write_elapsed = Duration::ZERO; + let mut read_bytes: u64 = 0; + let mut read_records: u64 = 0; + let mut read_elapsed = Duration::ZERO; + let mut catchup_bytes: u64 = 0; + let mut catchup_records: u64 = 0; + let mut catchup_elapsed = Duration::ZERO; + let mut all_ack_latencies: Vec = Vec::new(); + let mut all_e2e_latencies: Vec = Vec::new(); + + // Run write and read streams concurrently + let write_stream = bench_write( + stream.clone(), + record_size, + target_mibps, + stop.clone(), + write_done_records.clone(), + bench_start, + ); + + let read_stream = bench_read( + stream.clone(), + record_size, + write_done_records.clone(), + bench_start, + ); + + enum BenchEvent { + Write(Result), + Read(Result), + WriteDone, + ReadDone, + } + + let (btx, mut brx) = tokio::sync::mpsc::unbounded_channel(); + let write_tx = btx.clone(); + let write_handle = tokio::spawn(async move { + let mut write_stream = std::pin::pin!(write_stream); + while let Some(sample) = write_stream.next().await { + if write_tx.send(BenchEvent::Write(sample)).is_err() { + return; + } + } + let _ = write_tx.send(BenchEvent::WriteDone); + }); + let read_tx = btx.clone(); + let read_handle = tokio::spawn(async move { + let mut read_stream = std::pin::pin!(read_stream); + while let Some(sample) = read_stream.next().await { + if read_tx.send(BenchEvent::Read(sample)).is_err() { + return; + } + } + let _ = read_tx.send(BenchEvent::ReadDone); + }); + drop(btx); + + let deadline = bench_start + duration; + let mut write_done = false; + let mut read_done = false; + + loop { + if write_done && read_done { + break; + } + tokio::select! { + _ = tokio::time::sleep_until(deadline), if !stop.load(Ordering::Relaxed) => { + stop.store(true, Ordering::Relaxed); + let _ = tx.send(Event::BenchPhaseComplete(BenchPhase::Write)); + } + event = brx.recv() => { + match event { + Some(BenchEvent::Write(Ok(sample))) => { + all_ack_latencies.extend(sample.ack_latencies.iter().copied()); + write_bytes = sample.bytes; + write_records = sample.records; + write_elapsed = sample.elapsed; + let mibps = sample.bytes as f64 / (1024.0 * 1024.0) / sample.elapsed.as_secs_f64().max(0.001); + let recps = sample.records as f64 / sample.elapsed.as_secs_f64().max(0.001); + let _ = tx.send(Event::BenchWriteSample(BenchSample { + bytes: sample.bytes, + records: sample.records, + elapsed: sample.elapsed, + mib_per_sec: mibps, + records_per_sec: recps, + })); + } + Some(BenchEvent::Write(Err(e))) => { + stop.store(true, Ordering::Relaxed); + write_handle.abort(); + read_handle.abort(); + return Err(e); + } + Some(BenchEvent::WriteDone) => { + write_done = true; + } + Some(BenchEvent::Read(Ok(sample))) => { + all_e2e_latencies.extend(sample.e2e_latencies.iter().copied()); + read_bytes = sample.bytes; + read_records = sample.records; + read_elapsed = sample.elapsed; + let mibps = sample.bytes as f64 / (1024.0 * 1024.0) / sample.elapsed.as_secs_f64().max(0.001); + let recps = sample.records as f64 / sample.elapsed.as_secs_f64().max(0.001); + let _ = tx.send(Event::BenchReadSample(BenchSample { + bytes: sample.bytes, + records: sample.records, + elapsed: sample.elapsed, + mib_per_sec: mibps, + records_per_sec: recps, + })); + } + Some(BenchEvent::Read(Err(e))) => { + stop.store(true, Ordering::Relaxed); + write_handle.abort(); + read_handle.abort(); + return Err(e); + } + Some(BenchEvent::ReadDone) => { + read_done = true; + let _ = tx.send(Event::BenchPhaseComplete(BenchPhase::Read)); + } + None => { + write_done = true; + read_done = true; + } + } + } + } + } + + let _ = write_handle.await; + let _ = read_handle.await; + + // Catchup phase + let _ = tx.send(Event::BenchPhaseComplete(BenchPhase::CatchupWait)); + tokio::time::sleep(catchup_delay).await; + + let catchup_stream = bench_read_catchup(stream.clone(), record_size, bench_start); + let mut catchup_stream = std::pin::pin!(catchup_stream); + while let Some(result) = catchup_stream.next().await { + match result { + Ok(sample) => { + catchup_bytes = sample.bytes; + catchup_records = sample.records; + catchup_elapsed = sample.elapsed; + let mibps = sample.bytes as f64 / (1024.0 * 1024.0) / sample.elapsed.as_secs_f64().max(0.001); + let recps = sample.records as f64 / sample.elapsed.as_secs_f64().max(0.001); + let _ = tx.send(Event::BenchCatchupSample(BenchSample { + bytes: sample.bytes, + records: sample.records, + elapsed: sample.elapsed, + mib_per_sec: mibps, + records_per_sec: recps, + })); + } + Err(e) => { + return Err(e); + } + } + } + let _ = tx.send(Event::BenchPhaseComplete(BenchPhase::Catchup)); + + let write_mibps = write_bytes as f64 / (1024.0 * 1024.0) / write_elapsed.as_secs_f64().max(0.001); + let write_recps = write_records as f64 / write_elapsed.as_secs_f64().max(0.001); + let read_mibps = read_bytes as f64 / (1024.0 * 1024.0) / read_elapsed.as_secs_f64().max(0.001); + let read_recps = read_records as f64 / read_elapsed.as_secs_f64().max(0.001); + let catchup_mibps = catchup_bytes as f64 / (1024.0 * 1024.0) / catchup_elapsed.as_secs_f64().max(0.001); + let catchup_recps = catchup_records as f64 / catchup_elapsed.as_secs_f64().max(0.001); + + Ok(BenchFinalStats { + write_mibps, + write_recps, + write_bytes, + write_records, + write_elapsed, + read_mibps, + read_recps, + read_bytes, + read_records, + read_elapsed, + catchup_mibps, + catchup_recps, + catchup_bytes, + catchup_records, + catchup_elapsed, + ack_latency: if all_ack_latencies.is_empty() { + None + } else { + Some(LatencyStats::compute(all_ack_latencies)) + }, + e2e_latency: if all_e2e_latencies.is_empty() { + None + } else { + Some(LatencyStats::compute(all_e2e_latencies)) + }, + }) } diff --git a/src/tui/event.rs b/src/tui/event.rs index 4ae30c4..7c5781e 100644 --- a/src/tui/event.rs +++ b/src/tui/event.rs @@ -1,7 +1,9 @@ +use std::time::Duration; + use s2_sdk::types::{AccessTokenInfo, BasinInfo, Metric, SequencedRecord, StreamInfo, StreamPosition}; use crate::error::CliError; -use crate::types::{StorageClass, StreamConfig, TimestampingMode}; +use crate::types::{LatencyStats, StorageClass, StreamConfig, TimestampingMode}; /// Basin config info for reconfiguration #[derive(Debug, Clone)] @@ -99,4 +101,63 @@ pub enum Event { /// An error occurred in a background task Error(CliError), + + /// Benchmark stream created + BenchStreamCreated(Result), + + /// Benchmark write sample received + BenchWriteSample(BenchSample), + + /// Benchmark read sample received + BenchReadSample(BenchSample), + + /// Benchmark catchup sample received + BenchCatchupSample(BenchSample), + + /// Benchmark phase completed + BenchPhaseComplete(BenchPhase), + + /// Benchmark finished with final stats + BenchComplete(Result), +} + +/// A sample from the benchmark (write, read, or catchup) +#[derive(Debug, Clone)] +pub struct BenchSample { + pub bytes: u64, + pub records: u64, + pub elapsed: Duration, + pub mib_per_sec: f64, + pub records_per_sec: f64, +} + +/// Which phase of the benchmark +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum BenchPhase { + Write, + Read, + CatchupWait, + Catchup, +} + +/// Final benchmark statistics +#[derive(Debug, Clone)] +pub struct BenchFinalStats { + pub write_mibps: f64, + pub write_recps: f64, + pub write_bytes: u64, + pub write_records: u64, + pub write_elapsed: Duration, + pub read_mibps: f64, + pub read_recps: f64, + pub read_bytes: u64, + pub read_records: u64, + pub read_elapsed: Duration, + pub catchup_mibps: f64, + pub catchup_recps: f64, + pub catchup_bytes: u64, + pub catchup_records: u64, + pub catchup_elapsed: Duration, + pub ack_latency: Option, + pub e2e_latency: Option, } diff --git a/src/tui/ui.rs b/src/tui/ui.rs index 92084c2..0010bb3 100644 --- a/src/tui/ui.rs +++ b/src/tui/ui.rs @@ -8,13 +8,14 @@ use ratatui::{ use crate::types::{StorageClass, TimestampingMode}; -use super::app::{AccessTokensState, App, AgoUnit, AppendViewState, BasinsState, CompressionOption, ExpiryOption, InputMode, MessageLevel, MetricCategory, MetricsType, MetricsViewState, ReadStartFrom, ReadViewState, RetentionPolicyOption, ScopeOption, Screen, SettingsState, SetupState, StreamDetailState, StreamsState, Tab}; +use super::app::{AccessTokensState, App, AgoUnit, AppendViewState, BasinsState, BenchViewState, CompressionOption, ExpiryOption, InputMode, MessageLevel, MetricCategory, MetricsType, MetricsViewState, ReadStartFrom, ReadViewState, RetentionPolicyOption, ScopeOption, Screen, SettingsState, SetupState, StreamDetailState, StreamsState, Tab}; // S2 Console dark theme const GREEN: Color = Color::Rgb(34, 197, 94); // Active green const YELLOW: Color = Color::Rgb(250, 204, 21); // Warning yellow const RED: Color = Color::Rgb(239, 68, 68); // Error red const CYAN: Color = Color::Rgb(34, 211, 238); // Cyan accent +const BLUE: Color = Color::Rgb(59, 130, 246); // Blue accent const WHITE: Color = Color::Rgb(255, 255, 255); // Pure white const GRAY_100: Color = Color::Rgb(243, 244, 246); // Near white const GRAY_500: Color = Color::Rgb(107, 114, 128); // Muted gray @@ -92,6 +93,7 @@ pub fn draw(f: &mut Frame, app: &App) { Screen::AccessTokens(state) => draw_access_tokens(f, chunks[1], state), Screen::MetricsView(state) => draw_metrics_view(f, chunks[1], state), Screen::Settings(state) => draw_settings(f, chunks[1], state), + Screen::BenchView(state) => draw_bench_view(f, chunks[1], state), } // Draw time picker popup if open @@ -3113,11 +3115,11 @@ fn get_responsive_hints(screen: &Screen, width: usize) -> String { } Screen::Basins(_) => { if wide { - "/ filter | jk nav | ⏎ open | M basin metrics | A acct metrics | c new | e cfg | d del | r ref | ?".to_string() + "/ filter | jk nav | ⏎ open | B bench | M metrics | A acct | c new | e cfg | d del | r ref | ?".to_string() } else if medium { - "/ | jk ⏎ | M basin | A acct | c d e r ?".to_string() + "/ | jk ⏎ | B bench | M A | c d e r ?".to_string() } else { - "jk ⏎ M A c d ?".to_string() + "jk ⏎ B M A c d ?".to_string() } } Screen::Streams(_) => { @@ -3198,6 +3200,27 @@ fn get_responsive_hints(screen: &Screen, width: usize) -> String { } } } + Screen::BenchView(state) => { + if state.config_phase { + if wide { + "jk nav | ←→ adjust | ⏎ edit/start | esc back | q quit".to_string() + } else { + "jk ←→ ⏎ esc q".to_string() + } + } else if state.running { + if wide { + "space pause | q stop".to_string() + } else { + "space q".to_string() + } + } else { + if wide { + "r restart | esc back | q quit".to_string() + } else { + "r esc q".to_string() + } + } + } } } @@ -3276,6 +3299,10 @@ fn draw_help_overlay(f: &mut Frame, screen: &Screen) { Span::styled(" r ", Style::default().fg(GREEN).bold()), Span::styled("Refresh", Style::default().fg(TEXT_SECONDARY)), ]), + Line::from(vec![ + Span::styled(" B ", Style::default().fg(GREEN).bold()), + Span::styled("Benchmark", Style::default().fg(TEXT_SECONDARY)), + ]), Line::from(vec![ Span::styled(" M ", Style::default().fg(GREEN).bold()), Span::styled("Basin metrics", Style::default().fg(TEXT_SECONDARY)), @@ -3483,6 +3510,56 @@ fn draw_help_overlay(f: &mut Frame, screen: &Screen) { lines.push(Line::from("")); lines }, + Screen::BenchView(state) => { + if state.config_phase { + vec![ + Line::from(""), + Line::from(vec![ + Span::styled(" j/k ", Style::default().fg(GREEN).bold()), + Span::styled("Navigate", Style::default().fg(TEXT_SECONDARY)), + ]), + Line::from(vec![ + Span::styled(" h/l ", Style::default().fg(GREEN).bold()), + Span::styled("Adjust value", Style::default().fg(TEXT_SECONDARY)), + ]), + Line::from(vec![ + Span::styled("enter ", Style::default().fg(GREEN).bold()), + Span::styled("Edit / Start", Style::default().fg(TEXT_SECONDARY)), + ]), + Line::from(vec![ + Span::styled(" esc ", Style::default().fg(GREEN).bold()), + Span::styled("Back", Style::default().fg(TEXT_SECONDARY)), + ]), + Line::from(""), + ] + } else if state.running { + vec![ + Line::from(""), + Line::from(vec![ + Span::styled("space ", Style::default().fg(GREEN).bold()), + Span::styled("Pause / Resume", Style::default().fg(TEXT_SECONDARY)), + ]), + Line::from(vec![ + Span::styled(" q ", Style::default().fg(GREEN).bold()), + Span::styled("Stop benchmark", Style::default().fg(TEXT_SECONDARY)), + ]), + Line::from(""), + ] + } else { + vec![ + Line::from(""), + Line::from(vec![ + Span::styled(" r ", Style::default().fg(GREEN).bold()), + Span::styled("Restart benchmark", Style::default().fg(TEXT_SECONDARY)), + ]), + Line::from(vec![ + Span::styled(" esc ", Style::default().fg(GREEN).bold()), + Span::styled("Back", Style::default().fg(TEXT_SECONDARY)), + ]), + Line::from(""), + ] + } + }, }; let block = Block::default() @@ -5167,3 +5244,455 @@ fn draw_input_dialog(f: &mut Frame, mode: &InputMode) { let hint_para = Paragraph::new(hint_line).alignment(Alignment::Center); f.render_widget(hint_para, chunks[1]); } + +fn draw_bench_view(f: &mut Frame, area: Rect, state: &BenchViewState) { + let block = Block::default() + .title(Line::from(vec![ + Span::styled(" Benchmark ", Style::default().fg(TEXT_PRIMARY).bold()), + Span::styled( + format!("• {} ", state.basin_name), + Style::default().fg(ACCENT), + ), + ])) + .borders(Borders::ALL) + .border_style(Style::default().fg(BORDER)) + .style(Style::default().bg(BG_DARK)); + + let inner = block.inner(area); + f.render_widget(block, area); + + if state.config_phase { + draw_bench_config(f, inner, state); + } else { + draw_bench_running(f, inner, state); + } +} + +fn draw_bench_config(f: &mut Frame, area: Rect, state: &BenchViewState) { + use crate::tui::app::BenchConfigField; + + let chunks = Layout::default() + .direction(Direction::Vertical) + .constraints([ + Constraint::Length(3), // Title + Constraint::Length(3), // Record size + Constraint::Length(3), // Target MiB/s + Constraint::Length(3), // Duration + Constraint::Length(3), // Catchup delay + Constraint::Length(3), // Start button + Constraint::Min(1), // Spacer + ]) + .margin(1) + .split(area); + + // Title + let title = Paragraph::new(Line::from(vec![ + Span::styled("Configure Benchmark", Style::default().fg(TEXT_PRIMARY).bold()), + ])) + .alignment(Alignment::Center); + f.render_widget(title, chunks[0]); + + // Helper to draw a config field + let draw_field = |f: &mut Frame, area: Rect, label: &str, value: &str, selected: bool, editing: bool| { + let style = if selected { + Style::default().fg(if editing { YELLOW } else { GREEN }).bold() + } else { + Style::default().fg(TEXT_SECONDARY) + }; + + let prefix = if selected { "▸ " } else { " " }; + let suffix = if selected && !editing { " ◂" } else { "" }; + + let line = Line::from(vec![ + Span::styled(prefix, style), + Span::styled(format!("{}: ", label), Style::default().fg(TEXT_MUTED)), + Span::styled(value, style), + Span::styled(suffix, style), + ]); + f.render_widget(Paragraph::new(line), area); + }; + + // Record size + let record_size_str = if state.editing && state.config_field == BenchConfigField::RecordSize { + format!("{}_", state.edit_buffer) + } else { + format_bytes(state.record_size as u64) + }; + draw_field( + f, + chunks[1], + "Record Size", + &record_size_str, + state.config_field == BenchConfigField::RecordSize, + state.editing, + ); + + // Target MiB/s + let target_str = if state.editing && state.config_field == BenchConfigField::TargetMibps { + format!("{}_", state.edit_buffer) + } else { + format!("{} MiB/s", state.target_mibps) + }; + draw_field( + f, + chunks[2], + "Target Throughput", + &target_str, + state.config_field == BenchConfigField::TargetMibps, + state.editing, + ); + + // Duration + let duration_str = if state.editing && state.config_field == BenchConfigField::Duration { + format!("{}_", state.edit_buffer) + } else { + format!("{}s", state.duration_secs) + }; + draw_field( + f, + chunks[3], + "Duration", + &duration_str, + state.config_field == BenchConfigField::Duration, + state.editing, + ); + + // Catchup delay + let catchup_str = if state.editing && state.config_field == BenchConfigField::CatchupDelay { + format!("{}_", state.edit_buffer) + } else { + format!("{}s", state.catchup_delay_secs) + }; + draw_field( + f, + chunks[4], + "Catchup Delay", + &catchup_str, + state.config_field == BenchConfigField::CatchupDelay, + state.editing, + ); + + // Start button + let start_style = if state.config_field == BenchConfigField::Start { + Style::default().fg(BG_DARK).bg(GREEN).bold() + } else { + Style::default().fg(GREEN) + }; + let start = Paragraph::new(Line::from(Span::styled(" ▶ Start Benchmark ", start_style))) + .alignment(Alignment::Center); + f.render_widget(start, chunks[5]); +} + +fn draw_bench_running(f: &mut Frame, area: Rect, state: &BenchViewState) { + use crate::tui::event::BenchPhase; + + let chunks = Layout::default() + .direction(Direction::Vertical) + .constraints([ + Constraint::Length(3), // Progress bar + Constraint::Length(5), // Write stats + Constraint::Length(5), // Read stats + Constraint::Length(5), // Catchup stats (or waiting) + Constraint::Min(3), // Latency stats or chart + ]) + .margin(1) + .split(area); + + // Progress bar + let progress_pct = state.progress_pct.min(100.0); + let phase_text = match state.phase { + BenchPhase::Write => "Writing", + BenchPhase::Read => "Reading", + BenchPhase::CatchupWait => "Waiting for catchup", + BenchPhase::Catchup => "Catchup read", + }; + + let progress_block = Block::default() + .title(Line::from(vec![ + Span::styled(" Progress ", Style::default().fg(TEXT_PRIMARY).bold()), + Span::styled( + format!("• {} ", phase_text), + Style::default().fg(YELLOW), + ), + ])) + .borders(Borders::ALL) + .border_style(Style::default().fg(BORDER)); + + let progress_inner = progress_block.inner(chunks[0]); + f.render_widget(progress_block, chunks[0]); + + // Progress bar using block characters + let bar_width = progress_inner.width.saturating_sub(20) as usize; + let filled = ((progress_pct / 100.0) * bar_width as f64) as usize; + let empty = bar_width.saturating_sub(filled); + + let progress_line = Line::from(vec![ + Span::styled(format!("{:>5.1}% ", progress_pct), Style::default().fg(TEXT_PRIMARY)), + Span::styled("█".repeat(filled), Style::default().fg(GREEN)), + Span::styled("░".repeat(empty), Style::default().fg(TEXT_MUTED)), + Span::styled( + format!(" {:.1}s / {}s", state.elapsed_secs, state.duration_secs), + Style::default().fg(TEXT_SECONDARY), + ), + ]); + f.render_widget(Paragraph::new(progress_line), progress_inner); + + // Write stats + draw_bench_stat_box( + f, + chunks[1], + "Write", + BLUE, + state.write_mibps, + state.write_recps, + state.write_bytes, + state.write_records, + &state.write_history, + ); + + // Read stats + draw_bench_stat_box( + f, + chunks[2], + "Read", + GREEN, + state.read_mibps, + state.read_recps, + state.read_bytes, + state.read_records, + &state.read_history, + ); + + // Catchup stats or waiting message + if matches!(state.phase, BenchPhase::CatchupWait) { + let wait_block = Block::default() + .title(Line::from(Span::styled(" Catchup ", Style::default().fg(TEXT_PRIMARY).bold()))) + .borders(Borders::ALL) + .border_style(Style::default().fg(BORDER)); + let wait_inner = wait_block.inner(chunks[3]); + f.render_widget(wait_block, chunks[3]); + + let wait_text = Paragraph::new(Line::from(vec![ + Span::styled("Waiting ", Style::default().fg(TEXT_MUTED)), + Span::styled( + format!("{}s", state.catchup_delay_secs), + Style::default().fg(CYAN), + ), + Span::styled(" before catchup read...", Style::default().fg(TEXT_MUTED)), + ])) + .alignment(Alignment::Center); + f.render_widget(wait_text, wait_inner); + } else { + draw_bench_stat_box( + f, + chunks[3], + "Catchup", + CYAN, + state.catchup_mibps, + state.catchup_recps, + state.catchup_bytes, + state.catchup_records, + &[], + ); + } + + // Latency stats (only show after completion) + if !state.running && (state.ack_latency.is_some() || state.e2e_latency.is_some()) { + draw_latency_stats(f, chunks[4], &state.ack_latency, &state.e2e_latency); + } else if let Some(error) = &state.error { + let error_block = Block::default() + .title(Line::from(Span::styled(" Error ", Style::default().fg(RED).bold()))) + .borders(Borders::ALL) + .border_style(Style::default().fg(RED)); + let error_inner = error_block.inner(chunks[4]); + f.render_widget(error_block, chunks[4]); + + let error_text = Paragraph::new(error.as_str()) + .style(Style::default().fg(RED)) + .wrap(ratatui::widgets::Wrap { trim: true }); + f.render_widget(error_text, error_inner); + } else { + // Show sparkline area for throughput history + draw_throughput_sparklines(f, chunks[4], &state.write_history, &state.read_history); + } +} + +fn draw_bench_stat_box( + f: &mut Frame, + area: Rect, + label: &str, + color: Color, + mibps: f64, + recps: f64, + bytes: u64, + records: u64, + history: &[f64], +) { + let block = Block::default() + .title(Line::from(Span::styled( + format!(" {} ", label), + Style::default().fg(color).bold(), + ))) + .borders(Borders::ALL) + .border_style(Style::default().fg(BORDER)); + + let inner = block.inner(area); + f.render_widget(block, area); + + // Layout: stats on left, sparkline on right + let chunks = Layout::default() + .direction(Direction::Horizontal) + .constraints([Constraint::Min(30), Constraint::Length(25)]) + .split(inner); + + // Stats + let stats = vec![ + Line::from(vec![ + Span::styled(format!("{:>8.2}", mibps), Style::default().fg(color).bold()), + Span::styled(" MiB/s ", Style::default().fg(TEXT_MUTED)), + Span::styled(format!("{:>8.0}", recps), Style::default().fg(color).bold()), + Span::styled(" rec/s", Style::default().fg(TEXT_MUTED)), + ]), + Line::from(vec![ + Span::styled(format!("{:>8}", format_bytes(bytes)), Style::default().fg(TEXT_SECONDARY)), + Span::styled(" total ", Style::default().fg(TEXT_MUTED)), + Span::styled(format!("{:>8}", format_number(records)), Style::default().fg(TEXT_SECONDARY)), + Span::styled(" records", Style::default().fg(TEXT_MUTED)), + ]), + ]; + f.render_widget(Paragraph::new(stats), chunks[0]); + + // Sparkline + if !history.is_empty() { + let sparkline_data: Vec = history.iter().map(|v| (*v * 100.0) as u64).collect(); + let sparkline = ratatui::widgets::Sparkline::default() + .data(&sparkline_data) + .style(Style::default().fg(color)); + f.render_widget(sparkline, chunks[1]); + } +} + +fn draw_throughput_sparklines(f: &mut Frame, area: Rect, write_history: &[f64], read_history: &[f64]) { + let block = Block::default() + .title(Line::from(Span::styled(" Throughput ", Style::default().fg(TEXT_PRIMARY).bold()))) + .borders(Borders::ALL) + .border_style(Style::default().fg(BORDER)); + + let inner = block.inner(area); + f.render_widget(block, area); + + if write_history.is_empty() && read_history.is_empty() { + let waiting = Paragraph::new("Collecting data...") + .style(Style::default().fg(TEXT_MUTED)) + .alignment(Alignment::Center); + f.render_widget(waiting, inner); + return; + } + + let chunks = Layout::default() + .direction(Direction::Vertical) + .constraints([Constraint::Percentage(50), Constraint::Percentage(50)]) + .split(inner); + + // Write sparkline + if !write_history.is_empty() { + let write_data: Vec = write_history.iter().map(|v| (*v * 100.0) as u64).collect(); + let write_spark = ratatui::widgets::Sparkline::default() + .block(Block::default().title(Span::styled("Write", Style::default().fg(BLUE)))) + .data(&write_data) + .style(Style::default().fg(BLUE)); + f.render_widget(write_spark, chunks[0]); + } + + // Read sparkline + if !read_history.is_empty() { + let read_data: Vec = read_history.iter().map(|v| (*v * 100.0) as u64).collect(); + let read_spark = ratatui::widgets::Sparkline::default() + .block(Block::default().title(Span::styled("Read", Style::default().fg(GREEN)))) + .data(&read_data) + .style(Style::default().fg(GREEN)); + f.render_widget(read_spark, chunks[1]); + } +} + +fn draw_latency_stats( + f: &mut Frame, + area: Rect, + ack_latency: &Option, + e2e_latency: &Option, +) { + let block = Block::default() + .title(Line::from(Span::styled(" Latency Statistics ", Style::default().fg(TEXT_PRIMARY).bold()))) + .borders(Borders::ALL) + .border_style(Style::default().fg(BORDER)); + + let inner = block.inner(area); + f.render_widget(block, area); + + let chunks = Layout::default() + .direction(Direction::Horizontal) + .constraints([Constraint::Percentage(50), Constraint::Percentage(50)]) + .split(inner); + + // Ack latency + if let Some(stats) = ack_latency { + draw_latency_box(f, chunks[0], "Ack Latency", BLUE, stats); + } + + // E2E latency + if let Some(stats) = e2e_latency { + draw_latency_box(f, chunks[1], "E2E Latency", GREEN, stats); + } +} + +fn draw_latency_box( + f: &mut Frame, + area: Rect, + title: &str, + color: Color, + stats: &crate::types::LatencyStats, +) { + let block = Block::default() + .title(Line::from(Span::styled(title, Style::default().fg(color).bold()))) + .borders(Borders::ALL) + .border_style(Style::default().fg(BORDER)); + + let inner = block.inner(area); + f.render_widget(block, area); + + let stats_vec = stats.clone().into_vec(); + let max_val = stats_vec + .iter() + .map(|(_, d)| d.as_millis()) + .max() + .unwrap_or(1) as f64; + + let bar_width = inner.width.saturating_sub(20) as f64; + + let lines: Vec = stats_vec + .iter() + .map(|(name, duration)| { + let ms = duration.as_millis(); + let bar_len = ((ms as f64 / max_val) * bar_width).round() as usize; + Line::from(vec![ + Span::styled(format!("{:>5}: ", name), Style::default().fg(TEXT_MUTED)), + Span::styled(format!("{:>4}ms ", ms), Style::default().fg(color).bold()), + Span::styled("█".repeat(bar_len), Style::default().fg(color)), + ]) + }) + .collect(); + + f.render_widget(Paragraph::new(lines), inner); +} + +fn format_number(n: u64) -> String { + if n >= 1_000_000_000 { + format!("{:.2}B", n as f64 / 1_000_000_000.0) + } else if n >= 1_000_000 { + format!("{:.2}M", n as f64 / 1_000_000.0) + } else if n >= 1_000 { + format!("{:.2}K", n as f64 / 1_000.0) + } else { + n.to_string() + } +} diff --git a/src/types.rs b/src/types.rs index 1acb247..1a4b1df 100644 --- a/src/types.rs +++ b/src/types.rs @@ -799,6 +799,7 @@ impl From for TimeseriesInterval { } } +#[derive(Debug, Clone)] pub struct LatencyStats { pub min: std::time::Duration, pub median: std::time::Duration, From 4ed3c4e93c9081dd07b54dd44e5b4625b2a8e1f7 Mon Sep 17 00:00:00 2001 From: Mehul Arora Date: Mon, 26 Jan 2026 11:43:18 -0500 Subject: [PATCH 17/31] . --- src/tui/app.rs | 723 +++++++++++--------- src/tui/event.rs | 22 +- src/tui/mod.rs | 33 +- src/tui/ui.rs | 1635 +++++++++++++++++++++++----------------------- 4 files changed, 1243 insertions(+), 1170 deletions(-) diff --git a/src/tui/app.rs b/src/tui/app.rs index c8ab2fe..dc8875b 100644 --- a/src/tui/app.rs +++ b/src/tui/app.rs @@ -101,6 +101,35 @@ pub struct ReadViewState { pub show_detail: bool, pub hide_list: bool, pub output_file: Option, + // Throughput tracking for live sparklines + pub throughput_history: Vec, // MiB/s samples + pub records_per_sec_history: Vec, // records/s samples + pub current_mibps: f64, + pub current_recps: f64, + pub bytes_this_second: u64, + pub records_this_second: u64, + pub last_tick: Option, + // Timeline scrubber + pub show_timeline: bool, +} + +/// Maximum records to keep in PiP buffer (smaller than main view) +const MAX_PIP_RECORDS: usize = 50; + +/// Picture-in-Picture state for watching a stream while navigating elsewhere +#[derive(Debug, Clone)] +pub struct PipState { + pub basin_name: BasinName, + pub stream_name: StreamName, + pub records: VecDeque, + pub paused: bool, + pub minimized: bool, + // Throughput tracking + pub current_mibps: f64, + pub current_recps: f64, + pub bytes_this_second: u64, + pub records_this_second: u64, + pub last_tick: Option, } /// State for the append view @@ -108,18 +137,15 @@ pub struct ReadViewState { pub struct AppendViewState { pub basin_name: BasinName, pub stream_name: StreamName, - // Record fields pub body: String, pub headers: Vec<(String, String)>, // List of (key, value) pairs pub match_seq_num: String, // Empty = none - pub fencing_token: String, // Empty = none - // UI state - pub selected: usize, // 0=body, 1=headers, 2=match_seq, 3=fencing, 4=send + pub fencing_token: String, + pub selected: usize, pub editing: bool, pub header_key_input: String, // For adding new header pub header_value_input: String, - pub editing_header_key: bool, // true = editing key, false = editing value - // Results + pub editing_header_key: bool, pub history: Vec, pub appending: bool, } @@ -224,7 +250,7 @@ pub enum MetricCategory { ReadOps, AppendThroughput, ReadThroughput, - // Account-level metrics + BasinOps, ActiveBasins, AccountOps, } @@ -236,8 +262,8 @@ impl MetricCategory { Self::AppendOps => Self::ReadOps, Self::ReadOps => Self::AppendThroughput, Self::AppendThroughput => Self::ReadThroughput, - Self::ReadThroughput => Self::Storage, - // Account metrics cycle + Self::ReadThroughput => Self::BasinOps, + Self::BasinOps => Self::Storage, Self::ActiveBasins => Self::AccountOps, Self::AccountOps => Self::ActiveBasins, } @@ -245,12 +271,12 @@ impl MetricCategory { pub fn prev(&self) -> Self { match self { - Self::Storage => Self::ReadThroughput, + Self::Storage => Self::BasinOps, Self::AppendOps => Self::Storage, Self::ReadOps => Self::AppendOps, Self::AppendThroughput => Self::ReadOps, Self::ReadThroughput => Self::AppendThroughput, - // Account metrics cycle + Self::BasinOps => Self::ReadThroughput, Self::ActiveBasins => Self::AccountOps, Self::AccountOps => Self::ActiveBasins, } @@ -263,6 +289,7 @@ impl MetricCategory { Self::ReadOps => "Read Ops", Self::AppendThroughput => "Append Throughput", Self::ReadThroughput => "Read Throughput", + Self::BasinOps => "Basin Ops", Self::ActiveBasins => "Active Basins", Self::AccountOps => "Account Ops", } @@ -383,10 +410,8 @@ pub struct MetricsViewState { pub time_range: TimeRangeOption, pub loading: bool, pub scroll: usize, - // Time picker popup state pub time_picker_open: bool, pub time_picker_selected: usize, - // Calendar picker state pub calendar_open: bool, pub calendar_year: i32, pub calendar_month: u32, @@ -433,7 +458,6 @@ impl BenchConfigField { #[derive(Debug, Clone)] pub struct BenchViewState { pub basin_name: BasinName, - // Configuration (shown before running) pub config_phase: bool, pub config_field: BenchConfigField, pub record_size: u32, // bytes (default 8KB) @@ -442,36 +466,29 @@ pub struct BenchViewState { pub catchup_delay_secs: u64, // seconds (default 20) pub editing: bool, pub edit_buffer: String, - // Runtime state pub stream_name: Option, pub phase: BenchPhase, pub running: bool, pub paused: bool, pub stopping: bool, - // Progress pub elapsed_secs: f64, pub progress_pct: f64, - // Write stats pub write_mibps: f64, pub write_recps: f64, pub write_bytes: u64, pub write_records: u64, - pub write_history: Vec, // throughput history for sparkline - // Read stats + pub write_history: Vec, pub read_mibps: f64, pub read_recps: f64, pub read_bytes: u64, pub read_records: u64, pub read_history: Vec, - // Catchup stats pub catchup_mibps: f64, pub catchup_recps: f64, pub catchup_bytes: u64, pub catchup_records: u64, - // Latency stats (final) pub ack_latency: Option, pub e2e_latency: Option, - // Error pub error: Option, } @@ -538,21 +555,16 @@ pub enum InputMode { /// Creating a new basin CreateBasin { name: String, - // Basin scope (cloud provider/region) scope: BasinScopeOption, - // Basin-level settings create_stream_on_append: bool, create_stream_on_read: bool, - // Default stream config storage_class: Option, retention_policy: RetentionPolicyOption, retention_age_input: String, timestamping_mode: Option, timestamping_uncapped: bool, - // Delete-on-empty config delete_on_empty_enabled: bool, delete_on_empty_min_age: String, - // UI state selected: usize, editing: bool, }, @@ -560,16 +572,13 @@ pub enum InputMode { CreateStream { basin: BasinName, name: String, - // Stream config storage_class: Option, retention_policy: RetentionPolicyOption, retention_age_input: String, timestamping_mode: Option, timestamping_uncapped: bool, - // Delete-on-empty config delete_on_empty_enabled: bool, delete_on_empty_min_age: String, - // UI state selected: usize, editing: bool, }, @@ -580,16 +589,13 @@ pub enum InputMode { /// Reconfiguring a basin ReconfigureBasin { basin: BasinName, - // Basin-level settings create_stream_on_append: Option, create_stream_on_read: Option, - // Default stream config storage_class: Option, retention_policy: RetentionPolicyOption, retention_age_secs: u64, timestamping_mode: Option, timestamping_uncapped: Option, - // UI state selected: usize, editing_age: bool, age_input: String, @@ -603,10 +609,8 @@ pub enum InputMode { retention_age_secs: u64, timestamping_mode: Option, timestamping_uncapped: Option, - // Delete-on-empty config delete_on_empty_enabled: bool, delete_on_empty_min_age: String, - // UI state selected: usize, editing_age: bool, age_input: String, @@ -615,22 +619,18 @@ pub enum InputMode { CustomRead { basin: BasinName, stream: StreamName, - // Start position start_from: ReadStartFrom, seq_num_value: String, timestamp_value: String, ago_value: String, ago_unit: AgoUnit, tail_offset_value: String, - // Limits count_limit: String, byte_limit: String, until_timestamp: String, - // Options clamp: bool, format: ReadFormat, - output_file: String, // Empty = display only, path = write to file - // UI state + output_file: String, selected: usize, editing: bool, }, @@ -654,27 +654,22 @@ pub enum InputMode { }, /// Issue a new access token IssueAccessToken { - // Basic info id: String, expiry: ExpiryOption, - expiry_custom: String, // For custom duration input - // Resource scopes + expiry_custom: String, basins_scope: ScopeOption, basins_value: String, streams_scope: ScopeOption, streams_value: String, tokens_scope: ScopeOption, tokens_value: String, - // Operation permissions (Read/Write for each level) account_read: bool, account_write: bool, basin_read: bool, basin_write: bool, stream_read: bool, stream_write: bool, - // Options auto_prefix_streams: bool, - // UI state selected: usize, editing: bool, }, @@ -890,8 +885,6 @@ impl ReadFormat { } } } - - /// Config for basin reconfiguration #[derive(Debug, Clone)] pub struct BasinReconfigureConfig { @@ -930,6 +923,7 @@ pub struct App { pub message: Option, pub show_help: bool, pub input_mode: InputMode, + pub pip: Option, should_quit: bool, } @@ -945,7 +939,7 @@ fn build_basin_config( delete_on_empty_enabled: bool, delete_on_empty_min_age: String, ) -> BasinConfig { - // Parse retention policy + let retention = match retention_policy { RetentionPolicyOption::Infinite => None, RetentionPolicyOption::Age => { @@ -954,8 +948,6 @@ fn build_basin_config( .map(RetentionPolicy::Age) } }; - - // Build timestamping config if specified let timestamping = if timestamping_mode.is_some() || timestamping_uncapped { Some(TimestampingConfig { timestamping_mode, @@ -964,8 +956,6 @@ fn build_basin_config( } else { None }; - - // Build delete-on-empty config if enabled let delete_on_empty = if delete_on_empty_enabled { humantime::parse_duration(&delete_on_empty_min_age) .ok() @@ -995,7 +985,7 @@ fn build_stream_config( delete_on_empty_enabled: bool, delete_on_empty_min_age: String, ) -> StreamConfig { - // Parse retention policy + let retention = match retention_policy { RetentionPolicyOption::Infinite => None, RetentionPolicyOption::Age => { @@ -1004,8 +994,6 @@ fn build_stream_config( .map(RetentionPolicy::Age) } }; - - // Build timestamping config if specified let timestamping = if timestamping_mode.is_some() || timestamping_uncapped { Some(TimestampingConfig { timestamping_mode, @@ -1014,8 +1002,6 @@ fn build_stream_config( } else { None }; - - // Build delete-on-empty config if enabled let delete_on_empty = if delete_on_empty_enabled { humantime::parse_duration(&delete_on_empty_min_age) .ok() @@ -1046,6 +1032,7 @@ impl App { message: None, show_help: false, input_mode: InputMode::Normal, + pip: None, should_quit: false, } } @@ -1061,17 +1048,13 @@ impl App { /// Load settings from config file fn load_settings_state() -> SettingsState { - // Load from file first + let file_config = config::load_config_file().unwrap_or_default(); - // Also load from environment (which load_cli_config does) - let env_config = config::load_cli_config().unwrap_or_default(); - // Prefer file config for display, but show env token if file is empty + let env_config = config::load_cli_config().unwrap_or_default(); let access_token = file_config.access_token.clone() .or_else(|| env_config.access_token.clone()) .unwrap_or_default(); - - // Track if token is from env (read-only in that case) let token_from_env = file_config.access_token.is_none() && env_config.access_token.is_some(); SettingsState { @@ -1098,32 +1081,24 @@ impl App { /// Save settings to config file fn save_settings_static(state: &SettingsState) -> Result<(), CliError> { let mut cli_config = config::load_config_file().unwrap_or_default(); - - // Update access token if state.access_token.is_empty() { cli_config.unset(ConfigKey::AccessToken); } else { cli_config.set(ConfigKey::AccessToken, state.access_token.clone()) .map_err(|e| CliError::Config(e))?; } - - // Update account endpoint if state.account_endpoint.is_empty() { cli_config.unset(ConfigKey::AccountEndpoint); } else { cli_config.set(ConfigKey::AccountEndpoint, state.account_endpoint.clone()) .map_err(|e| CliError::Config(e))?; } - - // Update basin endpoint if state.basin_endpoint.is_empty() { cli_config.unset(ConfigKey::BasinEndpoint); } else { cli_config.set(ConfigKey::BasinEndpoint, state.basin_endpoint.clone()) .map_err(|e| CliError::Config(e))?; } - - // Update compression match state.compression { CompressionOption::None => cli_config.unset(ConfigKey::Compression), CompressionOption::Gzip => { @@ -1143,28 +1118,20 @@ impl App { pub async fn run(mut self, terminal: &mut Terminal) -> Result<(), CliError> { let (tx, mut rx) = mpsc::unbounded_channel(); - - // Show splash screen briefly let splash_start = std::time::Instant::now(); let splash_duration = Duration::from_millis(1200); - - // Only start loading basins if we have an S2 client (access token configured) if self.s2.is_some() { self.load_basins(tx.clone()); } - - // Track loaded basins for transition from splash let mut pending_basins: Option, CliError>> = None; loop { - // Render + terminal .draw(|f| ui::draw(f, &self)) .map_err(|e| CliError::RecordWrite(format!("Failed to draw: {e}")))?; - - // Check if splash screen should end if matches!(self.screen, Screen::Splash) && splash_start.elapsed() >= splash_duration { - // Transition to basins + let mut basins_state = BasinsState { loading: pending_basins.is_none(), ..Default::default() @@ -1195,7 +1162,7 @@ impl App { if let CrosstermEvent::Key(key) = event::read() .map_err(|e| CliError::RecordWrite(format!("Failed to read event: {e}")))? { - // Skip to basins on any key during splash + if matches!(self.screen, Screen::Splash) { let mut basins_state = BasinsState { loading: pending_basins.is_none(), @@ -1222,8 +1189,6 @@ impl App { self.handle_key(key, tx.clone()); } } - - // Check quit early to avoid unnecessary async processing if self.should_quit { break; } @@ -1231,7 +1196,7 @@ impl App { // Handle async events from background tasks with a short timeout tokio::select! { Some(event) = rx.recv() => { - // If on splash screen, cache the basins result + if matches!(self.screen, Screen::Splash) { if let Event::BasinsLoaded(result) = event { pending_basins = Some(result); @@ -1240,8 +1205,6 @@ impl App { } self.handle_event(event); } - - // Small sleep to prevent busy-looping when no events _ = tokio::time::sleep(Duration::from_millis(16)) => {} } @@ -1336,16 +1299,57 @@ impl App { match result { Ok(record) => { if !state.paused { + // Deduplicate by seq_num - skip if we already have this or a later record + let dominated = state.records.back() + .map(|last| record.seq_num <= last.seq_num) + .unwrap_or(false); + if dominated { + return; + } + + // Track throughput + let record_bytes = record.body.len() as u64; + state.bytes_this_second += record_bytes; + state.records_this_second += 1; + + // Check if a second has passed + if let Some(last_tick) = state.last_tick { + let elapsed = last_tick.elapsed(); + if elapsed >= std::time::Duration::from_secs(1) { + let secs = elapsed.as_secs_f64(); + let mibps = (state.bytes_this_second as f64) / (1024.0 * 1024.0) / secs; + let recps = (state.records_this_second as f64) / secs; + + state.current_mibps = mibps; + state.current_recps = recps; + state.throughput_history.push(mibps); + state.records_per_sec_history.push(recps); + + // Keep only last 60 samples + const MAX_HISTORY: usize = 60; + if state.throughput_history.len() > MAX_HISTORY { + state.throughput_history.remove(0); + } + if state.records_per_sec_history.len() > MAX_HISTORY { + state.records_per_sec_history.remove(0); + } + + state.bytes_this_second = 0; + state.records_this_second = 0; + state.last_tick = Some(std::time::Instant::now()); + } + } + state.records.push_back(record); - // Keep buffer bounded + while state.records.len() > MAX_RECORDS_BUFFER { state.records.pop_front(); - // Adjust selected if we removed records from front + if state.selected > 0 { state.selected = state.selected.saturating_sub(1); } } - // Auto-follow: keep selected at latest when tailing + if state.is_tailing { state.selected = state.records.len().saturating_sub(1); } @@ -1373,6 +1377,63 @@ impl App { } } + Event::PipRecordReceived(result) => { + if let Some(ref mut pip) = self.pip + && !pip.paused + { + match result { + Ok(record) => { + // Deduplicate by seq_num + let dominated = pip.records.back() + .map(|last| record.seq_num <= last.seq_num) + .unwrap_or(false); + if dominated { + return; + } + + // Track throughput + let record_bytes = record.body.len() as u64; + pip.bytes_this_second += record_bytes; + pip.records_this_second += 1; + + // Check if a second has passed + if let Some(last_tick) = pip.last_tick { + let elapsed = last_tick.elapsed(); + if elapsed >= std::time::Duration::from_secs(1) { + let secs = elapsed.as_secs_f64(); + pip.current_mibps = (pip.bytes_this_second as f64) / (1024.0 * 1024.0) / secs; + pip.current_recps = (pip.records_this_second as f64) / secs; + pip.bytes_this_second = 0; + pip.records_this_second = 0; + pip.last_tick = Some(std::time::Instant::now()); + } + } + + pip.records.push_back(record); + + // Keep PiP buffer small + while pip.records.len() > MAX_PIP_RECORDS { + pip.records.pop_front(); + } + } + Err(_) => { + // Silently ignore errors in PiP to not disrupt main workflow + } + } + } + } + + Event::PipReadEnded => { + // PiP stream ended - could happen if stream is deleted + if self.pip.is_some() { + self.pip = None; + self.message = Some(StatusMessage { + text: "PiP stream ended".to_string(), + level: MessageLevel::Info, + }); + } + } + Event::BasinCreated(result) => { self.input_mode = InputMode::Normal; match result { @@ -1381,7 +1442,7 @@ impl App { text: format!("Created basin '{}'", basin.name), level: MessageLevel::Success, }); - // Refresh basins list + if let Screen::Basins(state) = &mut self.screen { state.loading = true; } @@ -1403,7 +1464,7 @@ impl App { text: format!("Deleted basin '{}'", name), level: MessageLevel::Success, }); - // Refresh basins list + if let Screen::Basins(state) = &mut self.screen { state.loading = true; } @@ -1425,7 +1486,7 @@ impl App { text: format!("Created stream '{}'", stream.name), level: MessageLevel::Success, }); - // Refresh streams list + if let Screen::Streams(state) = &mut self.screen { state.loading = true; } @@ -1447,7 +1508,7 @@ impl App { text: format!("Deleted stream '{}'", name), level: MessageLevel::Success, }); - // Refresh streams list + if let Screen::Streams(state) = &mut self.screen { state.loading = true; } @@ -1523,7 +1584,7 @@ impl App { } *timestamping_mode = info.timestamping_mode; *timestamping_uncapped = Some(info.timestamping_uncapped); - // Delete on empty + if let Some(min_age_secs) = info.delete_on_empty_min_age_secs { *delete_on_empty_enabled = true; *delete_on_empty_min_age = format!("{}s", min_age_secs); @@ -1656,13 +1717,13 @@ impl App { self.input_mode = InputMode::Normal; match result { Ok(token) => { - // Show the token in a special dialog (one-time display) + self.input_mode = InputMode::ShowIssuedToken { token: token.clone() }; self.message = Some(StatusMessage { text: "Access token issued - copy it now, it won't be shown again!".to_string(), level: MessageLevel::Success, }); - // Refresh tokens list + if let Screen::AccessTokens(state) = &mut self.screen { state.loading = true; } @@ -1684,7 +1745,7 @@ impl App { text: format!("Revoked access token '{}'", id), level: MessageLevel::Success, }); - // Refresh tokens list + if let Screen::AccessTokens(state) = &mut self.screen { state.loading = true; } @@ -1782,8 +1843,8 @@ impl App { state.write_bytes = sample.bytes; state.write_records = sample.records; state.elapsed_secs = sample.elapsed.as_secs_f64(); - state.progress_pct = (state.elapsed_secs / state.duration_secs as f64) * 100.0; - // Keep last 60 samples for sparkline + state.progress_pct = ((state.elapsed_secs / state.duration_secs as f64) * 100.0).min(100.0); + state.write_history.push(sample.mib_per_sec); if state.write_history.len() > 60 { state.write_history.remove(0); @@ -1797,7 +1858,8 @@ impl App { state.read_recps = sample.records_per_sec; state.read_bytes = sample.bytes; state.read_records = sample.records; - // Keep last 60 samples for sparkline + state.elapsed_secs = sample.elapsed.as_secs_f64(); + state.read_history.push(sample.mib_per_sec); if state.read_history.len() > 60 { state.read_history.remove(0); @@ -1811,6 +1873,7 @@ impl App { state.catchup_recps = sample.records_per_sec; state.catchup_bytes = sample.bytes; state.catchup_records = sample.records; + state.elapsed_secs = sample.elapsed.as_secs_f64(); } } @@ -1847,16 +1910,12 @@ impl App { } fn handle_key(&mut self, key: KeyEvent, tx: mpsc::UnboundedSender) { - // Clear message on any keypress - self.message = None; - // Handle input mode first + self.message = None; if !matches!(self.input_mode, InputMode::Normal) { self.handle_input_key(key, tx); return; } - - // Global keys match key.code { KeyCode::Char('q') | KeyCode::Esc if self.show_help => { self.show_help = false; @@ -1866,12 +1925,29 @@ impl App { self.show_help = !self.show_help; return; } + KeyCode::Char('P') => { + // Toggle PiP visibility or close it + if let Some(ref mut pip) = self.pip { + if pip.minimized { + // Restore minimized PiP + pip.minimized = false; + } else { + // Close PiP entirely + self.pip = None; + self.message = Some(StatusMessage { + text: "PiP closed".to_string(), + level: MessageLevel::Info, + }); + } + } + return; + } KeyCode::Char('c') if key.modifiers.contains(KeyModifiers::CONTROL) => { self.should_quit = true; return; } KeyCode::Char('q') if !matches!(self.screen, Screen::Basins(_)) => { - // q goes back except on basins screen where it quits + } KeyCode::Char('q') => { self.should_quit = true; @@ -1883,8 +1959,6 @@ impl App { if self.show_help { return; } - - // Tab key to switch between tabs (only on top-level screens) if key.code == KeyCode::Tab { match &self.screen { Screen::Basins(_) | Screen::AccessTokens(_) | Screen::Settings(_) => { @@ -1894,8 +1968,6 @@ impl App { _ => {} } } - - // Screen-specific keys - handle in place to avoid borrow issues match &self.screen { Screen::Splash => {} // Keys handled in run loop Screen::Setup(_) => self.handle_setup_key(key, tx), @@ -1938,7 +2010,7 @@ impl App { } = &self.input_mode { if *selected == 16 && !*editing && !id.is_empty() { - // Clone all values we need + let id = id.clone(); let expiry = *expiry; let expiry_custom = expiry_custom.clone(); @@ -1955,8 +2027,6 @@ impl App { let stream_read = *stream_read; let stream_write = *stream_write; let auto_prefix_streams = *auto_prefix_streams; - - // Now we can safely call the method self.issue_access_token_v2( id, expiry, @@ -1999,23 +2069,10 @@ impl App { selected, editing, } => { - // Form fields: - // 0: Name (text) - // 1: Scope (cycle: AWS us-east-1) - // 2: Storage Class (cycle: None/Standard/Express) - // 3: Retention Policy (cycle: Infinite/Age) - // 4: Retention Age (text, only if Age) - // 5: Timestamping Mode (cycle: None/ClientPrefer/ClientRequire/Arrival) - // 6: Timestamping Uncapped (toggle) - // 7: Delete-on-empty (toggle) - // 8: Delete-on-empty Min Age (text, only if enabled) - // 9: Create Stream On Append (toggle) - // 10: Create Stream On Read (toggle) - // 11: Create button const FIELD_COUNT: usize = 12; if *editing { - // Text editing mode + match key.code { KeyCode::Esc | KeyCode::Enter => { *editing = false; @@ -2031,17 +2088,17 @@ impl App { } KeyCode::Char(c) => { if *selected == 0 { - // Basin names: lowercase letters, numbers, hyphens + if c.is_ascii_lowercase() || c.is_ascii_digit() || c == '-' { name.push(c); } } else if *selected == 4 { - // Retention age: alphanumeric for duration parsing + if c.is_ascii_alphanumeric() { retention_age_input.push(c); } } else if *selected == 8 { - // Delete-on-empty min age: alphanumeric for duration parsing + if c.is_ascii_alphanumeric() { delete_on_empty_min_age.push(c); } @@ -2057,11 +2114,11 @@ impl App { KeyCode::Up | KeyCode::Char('k') => { if *selected > 0 { *selected -= 1; - // Skip delete-on-empty min age if not enabled + if *selected == 8 && !*delete_on_empty_enabled { *selected = 7; } - // Skip retention age if not using Age policy + if *selected == 4 && *retention_policy != RetentionPolicyOption::Age { *selected = 3; } @@ -2070,11 +2127,11 @@ impl App { KeyCode::Down | KeyCode::Char('j') => { if *selected < FIELD_COUNT - 1 { *selected += 1; - // Skip retention age if not using Age policy + if *selected == 4 && *retention_policy != RetentionPolicyOption::Age { *selected = 5; } - // Skip delete-on-empty min age if not enabled + if *selected == 8 && !*delete_on_empty_enabled { *selected = 9; } @@ -2094,9 +2151,9 @@ impl App { } } 11 => { - // Create button - validate and submit + if name.len() >= 8 { - // Extract all values to avoid borrow conflict + let basin_name = name.clone(); let basin_scope = *scope; let csoa = *create_stream_on_append; @@ -2117,7 +2174,7 @@ impl App { } } KeyCode::Char(' ') => { - // Toggle for boolean fields + match *selected { 6 => *timestamping_uncapped = !*timestamping_uncapped, 9 => *create_stream_on_append = !*create_stream_on_append, @@ -2126,10 +2183,10 @@ impl App { } } KeyCode::Left | KeyCode::Char('h') => { - // Cycle left for enum fields + match *selected { 1 => { - // Scope: currently only AWS us-east-1, so no cycling + } 2 => { *storage_class = match storage_class { @@ -2153,17 +2210,17 @@ impl App { }; } 7 => { - // Delete on empty: Never <-> After threshold + *delete_on_empty_enabled = !*delete_on_empty_enabled; } _ => {} } } KeyCode::Right | KeyCode::Char('l') => { - // Cycle right for enum fields + match *selected { 1 => { - // Scope: currently only AWS us-east-1, so no cycling + } 2 => { *storage_class = match storage_class { @@ -2187,7 +2244,7 @@ impl App { }; } 7 => { - // Delete on empty: Never <-> After threshold + *delete_on_empty_enabled = !*delete_on_empty_enabled; } _ => {} @@ -2211,20 +2268,10 @@ impl App { selected, editing, } => { - // Form fields: - // 0: Name (text) - // 1: Storage Class (cycle: None/Standard/Express) - // 2: Retention Policy (cycle: Infinite/Age) - // 3: Retention Age (text, only if Age) - // 4: Timestamping Mode (cycle: None/ClientPrefer/ClientRequire/Arrival) - // 5: Timestamping Uncapped (toggle) - // 6: Delete-on-empty (cycle: Never/After threshold) - // 7: Delete-on-empty Min Age (text, only if enabled) - // 8: Create button const FIELD_COUNT: usize = 9; if *editing { - // Text editing mode + match key.code { KeyCode::Esc | KeyCode::Enter => { *editing = false; @@ -2240,15 +2287,15 @@ impl App { } KeyCode::Char(c) => { if *selected == 0 { - // Stream names: allow most characters + name.push(c); } else if *selected == 3 { - // Retention age: alphanumeric for duration parsing + if c.is_ascii_alphanumeric() { retention_age_input.push(c); } } else if *selected == 7 { - // Delete-on-empty min age: alphanumeric for duration parsing + if c.is_ascii_alphanumeric() { delete_on_empty_min_age.push(c); } @@ -2264,11 +2311,11 @@ impl App { KeyCode::Up | KeyCode::Char('k') => { if *selected > 0 { *selected -= 1; - // Skip delete-on-empty min age if not enabled + if *selected == 7 && !*delete_on_empty_enabled { *selected = 6; } - // Skip retention age if not using Age policy + if *selected == 3 && *retention_policy != RetentionPolicyOption::Age { *selected = 2; } @@ -2277,11 +2324,11 @@ impl App { KeyCode::Down | KeyCode::Char('j') => { if *selected < FIELD_COUNT - 1 { *selected += 1; - // Skip retention age if not using Age policy + if *selected == 3 && *retention_policy != RetentionPolicyOption::Age { *selected = 4; } - // Skip delete-on-empty min age if not enabled + if *selected == 7 && !*delete_on_empty_enabled { *selected = 8; } @@ -2301,7 +2348,7 @@ impl App { } } 8 => { - // Create button - validate and submit + if !name.is_empty() { let basin_name = basin.clone(); let stream_name = name.clone(); @@ -2321,13 +2368,13 @@ impl App { } } KeyCode::Char(' ') => { - // Toggle for boolean fields + if *selected == 5 { *timestamping_uncapped = !*timestamping_uncapped; } } KeyCode::Left | KeyCode::Char('h') => { - // Cycle left for enum fields + match *selected { 1 => { *storage_class = match storage_class { @@ -2351,14 +2398,14 @@ impl App { }; } 6 => { - // Delete on empty: Never <-> After threshold + *delete_on_empty_enabled = !*delete_on_empty_enabled; } _ => {} } } KeyCode::Right | KeyCode::Char('l') => { - // Cycle right for enum fields + match *selected { 1 => { *storage_class = match storage_class { @@ -2382,7 +2429,7 @@ impl App { }; } 6 => { - // Delete on empty: Never <-> After threshold + *delete_on_empty_enabled = !*delete_on_empty_enabled; } _ => {} @@ -2433,21 +2480,12 @@ impl App { editing_age, age_input, } => { - // Field indices: - // 0: Storage class - // 1: Retention policy - // 2: Retention age (if Age-based) - // 3: Timestamping mode - // 4: Timestamping uncapped - // 5: Create on append - // 6: Create on read - const BASIN_MAX_ROW: usize = 6; - // If editing age, handle number input + const BASIN_MAX_ROW: usize = 6; if *editing_age { match key.code { KeyCode::Esc | KeyCode::Enter => { - // Parse and apply the age + if let Ok(secs) = age_input.parse::() { *retention_age_secs = secs; } @@ -2471,7 +2509,7 @@ impl App { KeyCode::Up | KeyCode::Char('k') => { if *selected > 0 { *selected -= 1; - // Skip retention age if not using Age policy + if *selected == 2 && *retention_policy != RetentionPolicyOption::Age { *selected = 1; } @@ -2480,14 +2518,14 @@ impl App { KeyCode::Down | KeyCode::Char('j') => { if *selected < BASIN_MAX_ROW { *selected += 1; - // Skip retention age if not using Age policy + if *selected == 2 && *retention_policy != RetentionPolicyOption::Age { *selected = 3; } } } KeyCode::Char(' ') => { - // Toggle for boolean fields + match *selected { 4 => *timestamping_uncapped = Some(!timestamping_uncapped.unwrap_or(false)), 5 => *create_stream_on_append = Some(!create_stream_on_append.unwrap_or(false)), @@ -2496,14 +2534,14 @@ impl App { } } KeyCode::Enter => { - // Edit text fields + if *selected == 2 && *retention_policy == RetentionPolicyOption::Age { *editing_age = true; *age_input = retention_age_secs.to_string(); } } KeyCode::Left | KeyCode::Char('h') => { - // Cycle left for enum fields + match *selected { 0 => { *storage_class = match storage_class { @@ -2530,7 +2568,7 @@ impl App { } } KeyCode::Right | KeyCode::Char('l') => { - // Cycle right for enum fields + match *selected { 0 => { *storage_class = match storage_class { @@ -2587,18 +2625,18 @@ impl App { editing_age, age_input, } => { - // If editing age or delete-on-empty min age, handle text input + if *editing_age { match key.code { KeyCode::Esc | KeyCode::Enter => { - // Check which field we're editing + if *selected == 2 { - // Retention age + if let Ok(secs) = age_input.parse::() { *retention_age_secs = secs; } } else if *selected == 6 { - // Delete-on-empty min age - no parsing needed, store as string + } *editing_age = false; } @@ -2620,15 +2658,6 @@ impl App { } return; } - - // Stream has 7 rows: - // 0: Storage class - // 1: Retention policy - // 2: Retention age (if Age-based) - // 3: Timestamping mode - // 4: Timestamping uncapped - // 5: Delete on empty - // 6: Delete on empty threshold (if enabled) const STREAM_MAX_ROW: usize = 6; match key.code { @@ -2638,11 +2667,11 @@ impl App { KeyCode::Up | KeyCode::Char('k') => { if *selected > 0 { *selected -= 1; - // Skip delete-on-empty threshold if not enabled + if *selected == 6 && !*delete_on_empty_enabled { *selected = 5; } - // Skip retention age if not using Age policy + if *selected == 2 && *retention_policy != RetentionPolicyOption::Age { *selected = 1; } @@ -2651,25 +2680,25 @@ impl App { KeyCode::Down | KeyCode::Char('j') => { if *selected < STREAM_MAX_ROW { *selected += 1; - // Skip retention age if not using Age policy + if *selected == 2 && *retention_policy != RetentionPolicyOption::Age { *selected = 3; } - // Skip delete-on-empty threshold if not enabled + if *selected == 6 && !*delete_on_empty_enabled { - // Already at max, stay at 5 + *selected = 5; } } } KeyCode::Char(' ') => { - // Toggle for boolean fields + if *selected == 4 { *timestamping_uncapped = Some(!timestamping_uncapped.unwrap_or(false)); } } KeyCode::Enter => { - // Edit text fields + if *selected == 2 && *retention_policy == RetentionPolicyOption::Age { *editing_age = true; *age_input = retention_age_secs.to_string(); @@ -2678,7 +2707,7 @@ impl App { } } KeyCode::Left | KeyCode::Char('h') => { - // Cycle left for enum fields + match *selected { 0 => { *storage_class = match storage_class { @@ -2702,14 +2731,14 @@ impl App { }; } 5 => { - // Delete on empty: Never <-> After threshold + *delete_on_empty_enabled = !*delete_on_empty_enabled; } _ => {} } } KeyCode::Right | KeyCode::Char('l') => { - // Cycle right for enum fields + match *selected { 0 => { *storage_class = match storage_class { @@ -2733,7 +2762,7 @@ impl App { }; } 5 => { - // Delete on empty: Never <-> After threshold + *delete_on_empty_enabled = !*delete_on_empty_enabled; } _ => {} @@ -2775,14 +2804,14 @@ impl App { selected, editing, } => { - // If editing a value, handle text input + if *editing { match key.code { KeyCode::Esc | KeyCode::Enter => { *editing = false; } KeyCode::Tab if *selected == 2 => { - // Cycle time unit while editing ago value + *ago_unit = ago_unit.next(); } KeyCode::Backspace => { @@ -2820,17 +2849,6 @@ impl App { } // Navigation layout: - // 0: Sequence number (radio + input) - // 1: Timestamp (radio + input) - // 2: Time ago (radio + input, tab=unit) - // 3: Tail offset (radio + input) - // 4: Max records - // 5: Max bytes - // 6: Until timestamp - // 7: Clamp (checkbox) - // 8: Format (selector) - // 9: Output file - // 10: Start button const MAX_ROW: usize = 10; match key.code { @@ -2905,7 +2923,7 @@ impl App { let fmt = *format; let of = output_file.clone(); self.input_mode = InputMode::Normal; - // Show message if writing to file + if !of.is_empty() { self.message = Some(StatusMessage { text: format!("Writing to {}", of), @@ -3635,6 +3653,16 @@ impl App { let stream_name = state.stream_name.clone(); self.open_stream_metrics(basin_name, stream_name, tx); } + KeyCode::Char('p') => { + // Pin stream to PiP (picture-in-picture) - start tailing in background + let basin_name = state.basin_name.clone(); + let stream_name = state.stream_name.clone(); + self.start_pip(basin_name, stream_name, tx); + self.message = Some(StatusMessage { + text: "Stream pinned to PiP".to_string(), + level: MessageLevel::Success, + }); + } _ => {} } } @@ -3703,11 +3731,43 @@ impl App { state.hide_list = !state.hide_list; } KeyCode::Enter | KeyCode::Char('h') => { - // Show headers popup + if !state.records.is_empty() { state.show_detail = true; } } + KeyCode::Char('T') => { + // Toggle timeline scrubber + state.show_timeline = !state.show_timeline; + } + KeyCode::Char('[') => { + // Jump backward by 10% of records + let jump = (state.records.len() / 10).max(1); + state.selected = state.selected.saturating_sub(jump); + } + KeyCode::Char(']') => { + // Jump forward by 10% of records + let jump = (state.records.len() / 10).max(1); + let max_idx = state.records.len().saturating_sub(1); + state.selected = (state.selected + jump).min(max_idx); + } + KeyCode::Char('p') => { + // Pin current stream to PiP (picture-in-picture) + if state.is_tailing { + let basin_name = state.basin_name.clone(); + let stream_name = state.stream_name.clone(); + self.start_pip(basin_name, stream_name, tx); + self.message = Some(StatusMessage { + text: "Stream pinned to PiP".to_string(), + level: MessageLevel::Info, + }); + } else { + self.message = Some(StatusMessage { + text: "PiP only available when tailing".to_string(), + level: MessageLevel::Info, + }); + } + } _ => {} } } @@ -3818,7 +3878,7 @@ impl App { let s2 = self.s2.clone().expect("S2 client not initialized"); let tx_refresh = tx.clone(); tokio::spawn(async move { - // Parse basin name + let basin_name: BasinName = match name.parse() { Ok(n) => n, Err(e) => { @@ -3826,8 +3886,6 @@ impl App { return; } }; - - // Build CreateBasinInput with scope let sdk_scope = match scope { BasinScopeOption::AwsUsEast1 => s2_sdk::types::BasinScope::AwsUsEast1, }; @@ -3898,7 +3956,7 @@ impl App { let tx_refresh = tx.clone(); let basin_clone = basin.clone(); tokio::spawn(async move { - // Parse stream name + let stream_name: StreamName = match name.parse() { Ok(n) => n, Err(e) => { @@ -4001,6 +4059,14 @@ impl App { show_detail: false, hide_list: false, output_file: None, + throughput_history: Vec::new(), + records_per_sec_history: Vec::new(), + current_mibps: 0.0, + current_recps: 0.0, + bytes_this_second: 0, + records_this_second: 0, + last_tick: Some(std::time::Instant::now()), + show_timeline: false, }); let s2 = self.s2.clone().expect("S2 client not initialized"); @@ -4055,6 +4121,79 @@ impl App { }); } + /// Start a picture-in-picture tail for a stream + fn start_pip( + &mut self, + basin_name: BasinName, + stream_name: StreamName, + tx: mpsc::UnboundedSender, + ) { + // Initialize PiP state + self.pip = Some(PipState { + basin_name: basin_name.clone(), + stream_name: stream_name.clone(), + records: VecDeque::new(), + paused: false, + minimized: false, + current_mibps: 0.0, + current_recps: 0.0, + bytes_this_second: 0, + records_this_second: 0, + last_tick: Some(std::time::Instant::now()), + }); + + let s2 = self.s2.clone().expect("S2 client not initialized"); + let uri = S2BasinAndStreamUri { + basin: basin_name, + stream: stream_name, + }; + + tokio::spawn(async move { + // Simple tail for PiP: start at current tail, wait for new records + let args = ReadArgs { + uri, + seq_num: None, + timestamp: None, + ago: None, + tail_offset: None, + count: None, + bytes: None, + clamp: true, + until: None, + format: RecordFormat::default(), + output: RecordsOut::Stdout, + }; + + match ops::read(&s2, &args).await { + Ok(mut batch_stream) => { + use futures::StreamExt; + while let Some(batch_result) = batch_stream.next().await { + match batch_result { + Ok(batch) => { + for record in batch.records { + if tx.send(Event::PipRecordReceived(Ok(record))).is_err() { + return; + } + } + } + Err(e) => { + let _ = tx.send(Event::PipRecordReceived(Err(crate::error::CliError::op( + crate::error::OpKind::Read, + e, + )))); + return; + } + } + } + let _ = tx.send(Event::PipReadEnded); + } + Err(e) => { + let _ = tx.send(Event::Error(e)); + } + } + }); + } + /// Open custom read configuration dialog fn open_custom_read_dialog(&mut self, basin: BasinName, stream: StreamName) { self.input_mode = InputMode::CustomRead { @@ -4108,6 +4247,14 @@ impl App { show_detail: false, hide_list: false, output_file: if has_output { Some(output_file.clone()) } else { None }, + throughput_history: Vec::new(), + records_per_sec_history: Vec::new(), + current_mibps: 0.0, + current_recps: 0.0, + bytes_this_second: 0, + records_this_second: 0, + last_tick: Some(std::time::Instant::now()), + show_timeline: false, }); let s2 = self.s2.clone().expect("S2 client not initialized"); @@ -4117,7 +4264,7 @@ impl App { }; tokio::spawn(async move { - // Parse values + let seq_num = if start_from == ReadStartFrom::SeqNum { seq_num_value.parse().ok() } else { @@ -4345,7 +4492,7 @@ impl App { let s2 = self.s2.clone().expect("S2 client not initialized"); let tx_refresh = tx.clone(); tokio::spawn(async move { - // Build the default stream config + let retention_policy = match config.retention_policy { RetentionPolicyOption::Infinite => Some(crate::types::RetentionPolicy::Infinite), RetentionPolicyOption::Age => Some(crate::types::RetentionPolicy::Age(Duration::from_secs(config.retention_age_secs))), @@ -4516,7 +4663,7 @@ impl App { state.editing_header_key = false; } } else { - // Add the header if key is not empty + if !state.header_key_input.is_empty() { state.headers.push(( state.header_key_input.clone(), @@ -4562,7 +4709,7 @@ impl App { } } 2 => { - // Only allow digits for match_seq_num + if c.is_ascii_digit() { state.match_seq_num.push(c); } @@ -4599,7 +4746,7 @@ impl App { state.selected = state.selected.saturating_sub(1); } KeyCode::Char('d') if state.selected == 1 => { - // Delete last header + state.headers.pop(); } KeyCode::Enter => { @@ -4617,7 +4764,7 @@ impl App { Some(state.fencing_token.clone()) }; state.body.clear(); - // Keep headers for convenience (user might want to send similar records) + state.appending = true; self.append_record(basin_name, stream_name, body, headers, match_seq_num, fencing_token, tx); } @@ -4666,8 +4813,6 @@ impl App { return; } }; - - // Add headers if any if !headers.is_empty() { let parsed_headers: Vec
= headers .into_iter() @@ -4695,13 +4840,9 @@ impl App { }; let mut input = AppendInput::new(records); - - // Add match_seq_num if specified if let Some(seq) = match_seq_num { input = input.with_match_seq_num(seq); } - - // Add fencing token if specified if let Some(token_str) = fencing_token { match token_str.parse::() { Ok(token) => { @@ -4770,8 +4911,6 @@ impl App { use s2_sdk::types::{AppendInput, AppendRecordBatch, CommandRecord, FencingToken}; let stream_client = s2.basin(basin).stream(stream); - - // Parse the new fencing token let new_fencing_token = match new_token.parse::() { Ok(token) => token, Err(e) => { @@ -4781,8 +4920,6 @@ impl App { return; } }; - - // Create fence command record let command = CommandRecord::fence(new_fencing_token); let record: s2_sdk::types::AppendRecord = command.into(); let records = match AppendRecordBatch::try_from_iter([record]) { @@ -4796,8 +4933,6 @@ impl App { }; let mut input = AppendInput::new(records); - - // Add current fencing token if specified if let Some(token_str) = current_token { if !token_str.is_empty() { match token_str.parse::() { @@ -4843,8 +4978,6 @@ impl App { use s2_sdk::types::{AppendInput, AppendRecordBatch, CommandRecord, FencingToken}; let stream_client = s2.basin(basin).stream(stream); - - // Create trim command record let command = CommandRecord::trim(trim_point); let record: s2_sdk::types::AppendRecord = command.into(); let records = match AppendRecordBatch::try_from_iter([record]) { @@ -4858,8 +4991,6 @@ impl App { }; let mut input = AppendInput::new(records); - - // Add fencing token if specified if let Some(token_str) = fencing_token { if !token_str.is_empty() { match token_str.parse::() { @@ -4982,7 +5113,7 @@ impl App { state.filter_active = true; } KeyCode::Char('c') => { - // Create/Issue new token + self.input_mode = InputMode::IssueAccessToken { id: String::new(), expiry: ExpiryOption::ThirtyDays, @@ -5005,7 +5136,7 @@ impl App { }; } KeyCode::Char('d') => { - // Delete/Revoke selected token + if let Some(token) = filtered_tokens.get(state.selected) { self.input_mode = InputMode::ConfirmRevokeToken { token_id: token.id.to_string(), @@ -5013,7 +5144,7 @@ impl App { } } KeyCode::Char('r') => { - // Refresh + state.loading = true; self.load_access_tokens(tx); } @@ -5145,7 +5276,7 @@ impl App { } } KeyCode::Char('e') | KeyCode::Enter if state.selected < 3 => { - // Edit text fields + state.editing = true; } KeyCode::Char('h') | KeyCode::Left if state.selected == 3 => { @@ -5248,7 +5379,7 @@ impl App { let tx_refresh = tx.clone(); tokio::spawn(async move { - // Parse token ID + let token_id: AccessTokenId = match id.parse() { Ok(id) => id, Err(e) => { @@ -5258,8 +5389,6 @@ impl App { return; } }; - - // Build operations list based on read/write checkboxes let mut operations: Vec = Vec::new(); // Account level operations @@ -5307,8 +5436,6 @@ impl App { operations.push(Operation::RevokeAccessToken); } } - - // Build expiration let expires_in_str = match expiry { ExpiryOption::Never => None, ExpiryOption::Custom => { @@ -5316,8 +5443,6 @@ impl App { } _ => expiry.to_duration_str().map(|s| s.to_string()), }; - - // Build scope matchers let basins_matcher = match basins_scope { ScopeOption::All => None, ScopeOption::None => Some("".to_string()), // Empty string = no basins @@ -5338,8 +5463,6 @@ impl App { ScopeOption::Prefix => Some(tokens_value.clone()), ScopeOption::Exact => Some(format!("={}", tokens_value)), }; - - // Build args let args = IssueAccessTokenArgs { id: token_id, expires_in: expires_in_str.and_then(|s| s.parse().ok()), @@ -5392,7 +5515,7 @@ impl App { let tx_refresh = tx.clone(); tokio::spawn(async move { - // Parse token ID + let token_id: AccessTokenId = match id.parse() { Ok(id) => id, Err(e) => { @@ -5556,7 +5679,10 @@ impl App { MetricCategory::ReadThroughput => BasinMetricSet::ReadThroughput( s2_sdk::types::TimeRangeAndInterval::new(start, end) ), - _ => return, // Account metrics not valid for basin + MetricCategory::BasinOps => BasinMetricSet::BasinOps( + s2_sdk::types::TimeRangeAndInterval::new(start, end) + ), + _ => return, }; let input = s2_sdk::types::GetBasinMetricsInput::new(basin_name, set); @@ -5741,7 +5867,7 @@ impl App { } } KeyCode::Char('r') => { - // Refresh + if let Screen::MetricsView(state) = &mut self.screen { state.loading = true; state.metrics.clear(); @@ -6270,22 +6396,28 @@ impl App { }; tokio::spawn(async move { - use s2_sdk::types::{CreateStreamInput, DeleteStreamInput, StreamName}; + use s2_sdk::types::{CreateStreamInput, DeleteStreamInput, StreamName, StreamConfig as SdkStreamConfig, RetentionPolicy, TimestampingConfig, TimestampingMode, DeleteOnEmptyConfig}; use std::num::NonZeroU64; use std::time::Duration; - - // Create a temporary stream for the benchmark let stream_name: StreamName = format!("_bench_{}", uuid::Uuid::new_v4()) .parse() .expect("valid stream name"); let stream_name_str = stream_name.to_string(); - // Get basin client - let basin = s2.basin(basin_name.clone()); + let stream_config = SdkStreamConfig::new() + .with_retention_policy(RetentionPolicy::Age(3600)) + .with_delete_on_empty( + DeleteOnEmptyConfig::new().with_min_age(Duration::from_secs(60)), + ) + .with_timestamping( + TimestampingConfig::new() + .with_mode(TimestampingMode::ClientRequire) + .with_uncapped(true), + ); - // Create the stream + let basin = s2.basin(basin_name.clone()); if let Err(e) = basin - .create_stream(CreateStreamInput::new(stream_name.clone())) + .create_stream(CreateStreamInput::new(stream_name.clone()).with_config(stream_config)) .await { let _ = tx.send(Event::BenchStreamCreated(Err(CliError::op( @@ -6346,18 +6478,6 @@ async fn run_bench_with_events( // For now, let's use a simplified version that calls into bench.rs // and extracts the stats - // Actually, let's just run the benchmark and send periodic updates - // The bench module already has the core logic, we just need to wrap it - - let mut write_bytes: u64 = 0; - let mut write_records: u64 = 0; - let mut write_elapsed = Duration::ZERO; - let mut read_bytes: u64 = 0; - let mut read_records: u64 = 0; - let mut read_elapsed = Duration::ZERO; - let mut catchup_bytes: u64 = 0; - let mut catchup_records: u64 = 0; - let mut catchup_elapsed = Duration::ZERO; let mut all_ack_latencies: Vec = Vec::new(); let mut all_e2e_latencies: Vec = Vec::new(); @@ -6425,9 +6545,6 @@ async fn run_bench_with_events( match event { Some(BenchEvent::Write(Ok(sample))) => { all_ack_latencies.extend(sample.ack_latencies.iter().copied()); - write_bytes = sample.bytes; - write_records = sample.records; - write_elapsed = sample.elapsed; let mibps = sample.bytes as f64 / (1024.0 * 1024.0) / sample.elapsed.as_secs_f64().max(0.001); let recps = sample.records as f64 / sample.elapsed.as_secs_f64().max(0.001); let _ = tx.send(Event::BenchWriteSample(BenchSample { @@ -6449,9 +6566,6 @@ async fn run_bench_with_events( } Some(BenchEvent::Read(Ok(sample))) => { all_e2e_latencies.extend(sample.e2e_latencies.iter().copied()); - read_bytes = sample.bytes; - read_records = sample.records; - read_elapsed = sample.elapsed; let mibps = sample.bytes as f64 / (1024.0 * 1024.0) / sample.elapsed.as_secs_f64().max(0.001); let recps = sample.records as f64 / sample.elapsed.as_secs_f64().max(0.001); let _ = tx.send(Event::BenchReadSample(BenchSample { @@ -6493,9 +6607,6 @@ async fn run_bench_with_events( while let Some(result) = catchup_stream.next().await { match result { Ok(sample) => { - catchup_bytes = sample.bytes; - catchup_records = sample.records; - catchup_elapsed = sample.elapsed; let mibps = sample.bytes as f64 / (1024.0 * 1024.0) / sample.elapsed.as_secs_f64().max(0.001); let recps = sample.records as f64 / sample.elapsed.as_secs_f64().max(0.001); let _ = tx.send(Event::BenchCatchupSample(BenchSample { @@ -6513,29 +6624,7 @@ async fn run_bench_with_events( } let _ = tx.send(Event::BenchPhaseComplete(BenchPhase::Catchup)); - let write_mibps = write_bytes as f64 / (1024.0 * 1024.0) / write_elapsed.as_secs_f64().max(0.001); - let write_recps = write_records as f64 / write_elapsed.as_secs_f64().max(0.001); - let read_mibps = read_bytes as f64 / (1024.0 * 1024.0) / read_elapsed.as_secs_f64().max(0.001); - let read_recps = read_records as f64 / read_elapsed.as_secs_f64().max(0.001); - let catchup_mibps = catchup_bytes as f64 / (1024.0 * 1024.0) / catchup_elapsed.as_secs_f64().max(0.001); - let catchup_recps = catchup_records as f64 / catchup_elapsed.as_secs_f64().max(0.001); - Ok(BenchFinalStats { - write_mibps, - write_recps, - write_bytes, - write_records, - write_elapsed, - read_mibps, - read_recps, - read_bytes, - read_records, - read_elapsed, - catchup_mibps, - catchup_recps, - catchup_bytes, - catchup_records, - catchup_elapsed, ack_latency: if all_ack_latencies.is_empty() { None } else { diff --git a/src/tui/event.rs b/src/tui/event.rs index 7c5781e..3a8cacb 100644 --- a/src/tui/event.rs +++ b/src/tui/event.rs @@ -48,6 +48,12 @@ pub enum Event { /// Read stream ended ReadEnded, + /// A record was received for the PiP (picture-in-picture) tail + PipRecordReceived(Result), + + /// PiP read stream ended + PipReadEnded, + /// Basin created successfully BasinCreated(Result), @@ -140,24 +146,8 @@ pub enum BenchPhase { Catchup, } -/// Final benchmark statistics #[derive(Debug, Clone)] pub struct BenchFinalStats { - pub write_mibps: f64, - pub write_recps: f64, - pub write_bytes: u64, - pub write_records: u64, - pub write_elapsed: Duration, - pub read_mibps: f64, - pub read_recps: f64, - pub read_bytes: u64, - pub read_records: u64, - pub read_elapsed: Duration, - pub catchup_mibps: f64, - pub catchup_recps: f64, - pub catchup_bytes: u64, - pub catchup_records: u64, - pub catchup_elapsed: Duration, pub ack_latency: Option, pub e2e_latency: Option, } diff --git a/src/tui/mod.rs b/src/tui/mod.rs index 5f3b9c7..3062c34 100644 --- a/src/tui/mod.rs +++ b/src/tui/mod.rs @@ -27,27 +27,42 @@ pub async fn run() -> Result<(), CliError> { }; // Setup terminal - enable_raw_mode().map_err(|e| CliError::RecordWrite(format!("Failed to enable raw mode: {e}")))?; + enable_raw_mode().map_err(|e| CliError::RecordReaderInit(format!("terminal setup: {e}")))?; let mut stdout = io::stdout(); execute!(stdout, EnterAlternateScreen, EnableMouseCapture) - .map_err(|e| CliError::RecordWrite(format!("Failed to setup terminal: {e}")))?; + .map_err(|e| CliError::RecordReaderInit(format!("terminal setup: {e}")))?; let backend = CrosstermBackend::new(stdout); let mut terminal = Terminal::new(backend) - .map_err(|e| CliError::RecordWrite(format!("Failed to create terminal: {e}")))?; + .map_err(|e| CliError::RecordReaderInit(format!("terminal setup: {e}")))?; // Create and run app let app = App::new(s2); let result = app.run(&mut terminal).await; - // Restore terminal - disable_raw_mode().ok(); - execute!( + // Restore terminal - attempt all cleanup steps even if some fail + // This is critical: a partially restored terminal leaves the user's shell broken + let mut cleanup_errors = Vec::new(); + + if let Err(e) = disable_raw_mode() { + cleanup_errors.push(format!("disable_raw_mode: {e}")); + } + + if let Err(e) = execute!( terminal.backend_mut(), LeaveAlternateScreen, DisableMouseCapture - ) - .ok(); - terminal.show_cursor().ok(); + ) { + cleanup_errors.push(format!("leave_alternate_screen: {e}")); + } + + if let Err(e) = terminal.show_cursor() { + cleanup_errors.push(format!("show_cursor: {e}")); + } + + // Log cleanup errors to stderr (won't be visible in alternate screen anyway) + if !cleanup_errors.is_empty() { + eprintln!("Warning: terminal cleanup errors: {}", cleanup_errors.join(", ")); + } result } diff --git a/src/tui/ui.rs b/src/tui/ui.rs index 0010bb3..91c0f40 100644 --- a/src/tui/ui.rs +++ b/src/tui/ui.rs @@ -8,9 +8,9 @@ use ratatui::{ use crate::types::{StorageClass, TimestampingMode}; -use super::app::{AccessTokensState, App, AgoUnit, AppendViewState, BasinsState, BenchViewState, CompressionOption, ExpiryOption, InputMode, MessageLevel, MetricCategory, MetricsType, MetricsViewState, ReadStartFrom, ReadViewState, RetentionPolicyOption, ScopeOption, Screen, SettingsState, SetupState, StreamDetailState, StreamsState, Tab}; +use super::app::{AccessTokensState, App, AgoUnit, AppendViewState, BasinsState, BenchViewState, CompressionOption, ExpiryOption, InputMode, MessageLevel, MetricCategory, MetricsType, MetricsViewState, PipState, ReadStartFrom, ReadViewState, RetentionPolicyOption, ScopeOption, Screen, SettingsState, SetupState, StreamDetailState, StreamsState, Tab}; + -// S2 Console dark theme const GREEN: Color = Color::Rgb(34, 197, 94); // Active green const YELLOW: Color = Color::Rgb(250, 204, 21); // Warning yellow const RED: Color = Color::Rgb(239, 68, 68); // Error red @@ -23,7 +23,7 @@ const BG_DARK: Color = Color::Rgb(17, 17, 17); // Main background const BG_PANEL: Color = Color::Rgb(24, 24, 27); // Panel background const BORDER: Color = Color::Rgb(63, 63, 70); // Border gray -// Semantic aliases + const ACCENT: Color = WHITE; const SUCCESS: Color = GREEN; const WARNING: Color = YELLOW; @@ -32,26 +32,205 @@ const TEXT_PRIMARY: Color = WHITE; const TEXT_SECONDARY: Color = GRAY_100; const TEXT_MUTED: Color = GRAY_500; +// Additional gray shades for consistent styling +const GRAY_600: Color = Color::Rgb(80, 80, 80); // Medium gray for hints/placeholders +const GRAY_650: Color = Color::Rgb(60, 60, 60); // For toggle off state +const GRAY_750: Color = Color::Rgb(50, 50, 50); // For inactive pills + +// Consistent cursor character +const CURSOR: &str = "▎"; + +// Consistent selection indicator +const SELECTED_INDICATOR: &str = " ▸ "; +const UNSELECTED_INDICATOR: &str = " "; + +/// Safely truncate a string to max_len characters, adding suffix if truncated. +/// Returns the original string if it fits, otherwise truncates and adds suffix. +fn truncate_str(s: &str, max_len: usize, suffix: &str) -> String { + if s.len() <= max_len { + s.to_string() + } else { + let truncate_at = max_len.saturating_sub(suffix.len()); + // Find a valid char boundary + let mut end = truncate_at.min(s.len()); + while end > 0 && !s.is_char_boundary(end) { + end -= 1; + } + format!("{}{}", &s[..end], suffix) + } +} + +// S2 Logo (shared between splash and setup screens) +const S2_LOGO: &[&str] = &[ + " █████████████████████████ ", + " ██████████████████████████████ ", + " ███████████████████████████████ ", + "█████████████████████████████████", + "█████████████████████████████████ ", + "███████████████ ", + "███████████████ ", + "██████████████ ████████████████", + "██████████████ ████████████████", + "██████████████ ████████████████", + "███████████████ ███████", + "██████████████████ █████", + "█████████████████████████ ████", + "█████████████████████████ █████", + "██████ ██████", + "█████ ████████", + " ███ ██████████████████████ ", + " ██ ██████████████████████ ", + " ████████████████████ ", +]; + +/// Render the S2 logo as styled lines +fn render_logo() -> Vec> { + S2_LOGO.iter() + .map(|&line| Line::from(Span::styled(line, Style::default().fg(WHITE)))) + .collect() +} + +// ============================================================================ +// SHARED UI COMPONENTS +// ============================================================================ + +/// Render a toggle switch with consistent styling +fn render_toggle(on: bool, is_selected: bool) -> Vec> { + if on { + vec![ + Span::styled("", Style::default().fg(if is_selected { GREEN } else { GRAY_650 })), + Span::styled(" ON ", Style::default().fg(BG_DARK).bg(GREEN).bold()), + Span::styled("", Style::default().fg(GREEN)), + ] + } else { + vec![ + Span::styled("", Style::default().fg(if is_selected { TEXT_MUTED } else { GRAY_650 })), + Span::styled(" OFF ", Style::default().fg(TEXT_MUTED).bg(GRAY_650)), + Span::styled("", Style::default().fg(GRAY_650)), + ] + } +} + +/// Render a pill-style option selector +fn render_pill(label: &str, is_row_selected: bool, is_active: bool) -> Span<'static> { + let label = label.to_string(); + if is_active { + Span::styled( + format!(" {} ", label), + Style::default().fg(BG_DARK).bg(GREEN).bold() + ) + } else if is_row_selected { + Span::styled( + format!(" {} ", label), + Style::default().fg(TEXT_PRIMARY).bg(GRAY_750) + ) + } else { + Span::styled(format!(" {} ", label), Style::default().fg(TEXT_MUTED)) + } +} + +/// Render a form field row with selection indicator and label +fn render_field_row(field_idx: usize, label: &str, current_selected: usize) -> (Span<'static>, Span<'static>) { + let is_selected = field_idx == current_selected; + let indicator = if is_selected { + Span::styled(SELECTED_INDICATOR, Style::default().fg(GREEN).bold()) + } else { + Span::raw(UNSELECTED_INDICATOR) + }; + let label_span = Span::styled( + format!("{:<15}", label), + Style::default().fg(if is_selected { TEXT_PRIMARY } else { TEXT_MUTED }) + ); + (indicator, label_span) +} + +/// Render a form field row with bold label when selected +fn render_field_row_bold(field_idx: usize, label: &str, current_selected: usize) -> (Span<'static>, Span<'static>) { + let is_selected = field_idx == current_selected; + let indicator = if is_selected { + Span::styled(SELECTED_INDICATOR, Style::default().fg(GREEN).bold()) + } else { + Span::raw(UNSELECTED_INDICATOR) + }; + let label_style = if is_selected { + Style::default().fg(TEXT_PRIMARY).bold() + } else { + Style::default().fg(TEXT_MUTED) + }; + (indicator, Span::styled(label.to_string(), label_style)) +} + +/// Render a primary action button +fn render_button(label: &str, is_selected: bool, is_enabled: bool, color: Color) -> Line<'static> { + let (btn_fg, btn_bg) = if is_selected && is_enabled { + (BG_DARK, color) + } else if is_enabled { + (color, BG_PANEL) + } else { + (GRAY_600, BG_PANEL) + }; + + let indicator = if is_selected { + Span::styled(SELECTED_INDICATOR, Style::default().fg(GREEN).bold()) + } else { + Span::raw(UNSELECTED_INDICATOR) + }; + + Line::from(vec![ + indicator, + Span::styled(format!(" ▶ {} ", label), Style::default().fg(btn_fg).bg(btn_bg).bold()), + ]) +} + +/// Render a section header with divider line +fn render_section_header(title: &str, width: usize) -> Line<'static> { + let title_with_spaces = format!(" {} ", title); + let divider_len = width.saturating_sub(title_with_spaces.len()); + Line::from(vec![ + Span::styled(title_with_spaces, Style::default().fg(CYAN).bold()), + Span::styled("─".repeat(divider_len), Style::default().fg(GRAY_750)), + ]) +} + +/// Render text input with cursor +fn render_text_input(value: &str, is_editing: bool, placeholder: &str, color: Color) -> Vec> { + if value.is_empty() && !is_editing { + vec![Span::styled(placeholder.to_string(), Style::default().fg(GRAY_600).italic())] + } else { + let mut spans = vec![Span::styled(value.to_string(), Style::default().fg(color))]; + if is_editing { + spans.push(Span::styled(CURSOR, Style::default().fg(GREEN))); + } + spans + } +} + +/// Render a checkbox +#[allow(dead_code)] +fn render_checkbox(checked: bool) -> &'static str { + if checked { "[x]" } else { "[ ]" } +} + +/// Render a radio button +#[allow(dead_code)] +fn render_radio(active: bool) -> &'static str { + if active { "●" } else { "○" } +} + pub fn draw(f: &mut Frame, app: &App) { - // Clear with dark CRT background + let area = f.area(); f.render_widget(Block::default().style(Style::default().bg(BG_DARK)), area); - - // Splash screen uses full area if matches!(app.screen, Screen::Splash) { draw_splash(f, area); return; } - - // Setup screen uses full area (no tabs or status bar) if matches!(app.screen, Screen::Setup(_)) { if let Screen::Setup(state) = &app.screen { draw_setup(f, area, state); } return; } - - // Check if we should show tabs (only on top-level screens) let show_tabs = matches!(app.screen, Screen::Basins(_) | Screen::AccessTokens(_) | Screen::Settings(_)); let chunks = if show_tabs { @@ -60,7 +239,7 @@ pub fn draw(f: &mut Frame, app: &App) { .margin(1) .constraints([ Constraint::Length(1), // Tab bar - Constraint::Min(3), // Main content + Constraint::Min(3), Constraint::Length(1), // Status bar ]) .split(area) @@ -70,18 +249,14 @@ pub fn draw(f: &mut Frame, app: &App) { .margin(1) .constraints([ Constraint::Length(0), // No tab bar - Constraint::Min(3), // Main content + Constraint::Min(3), Constraint::Length(1), // Status bar ]) .split(area) }; - - // Draw tab bar if on top-level screen if show_tabs { draw_tab_bar(f, chunks[0], app.tab); } - - // Draw main content based on screen match &app.screen { Screen::Splash => unreachable!(), Screen::Setup(_) => unreachable!(), // Handled above @@ -95,8 +270,6 @@ pub fn draw(f: &mut Frame, app: &App) { Screen::Settings(state) => draw_settings(f, chunks[1], state), Screen::BenchView(state) => draw_bench_view(f, chunks[1], state), } - - // Draw time picker popup if open if let Screen::MetricsView(state) = &app.screen { if state.time_picker_open { draw_time_picker(f, state); @@ -105,63 +278,35 @@ pub fn draw(f: &mut Frame, app: &App) { draw_calendar_picker(f, state); } } - - // Draw status bar draw_status_bar(f, chunks[2], app); - - // Draw help overlay if visible if app.show_help { draw_help_overlay(f, &app.screen); } - - // Draw input dialog if in input mode if !matches!(app.input_mode, InputMode::Normal) { draw_input_dialog(f, &app.input_mode); } + // Draw PiP overlay last so it's on top + if let Some(ref pip) = app.pip { + if !pip.minimized { + draw_pip(f, pip); + } else { + draw_pip_minimized(f, pip); + } + } } fn draw_splash(f: &mut Frame, area: Rect) { - // Draw aurora background effect draw_aurora_background(f, area); - // S2 logo - let logo = vec![ - " █████████████████████████ ", - " ██████████████████████████████ ", - " ███████████████████████████████ ", - "█████████████████████████████████", - "█████████████████████████████████ ", - "███████████████ ", - "███████████████ ", - "██████████████ ████████████████", - "██████████████ ████████████████", - "██████████████ ████████████████", - "███████████████ ███████", - "██████████████████ █████", - "█████████████████████████ ████", - "█████████████████████████ █████", - "██████ ██████", - "█████ ████████", - " ███ ██████████████████████ ", - " ██ ██████████████████████ ", - " ████████████████████ ", - ]; - - // Create lines with logo (centered) - let mut lines: Vec = logo - .iter() - .map(|&line| Line::from(Span::styled(line, Style::default().fg(Color::White)))) - .collect(); - - // Add tagline below logo + let mut lines = render_logo(); lines.push(Line::from("")); lines.push(Line::from(Span::styled( "Streams as a cloud", - Style::default().fg(Color::White).bold(), + Style::default().fg(WHITE).bold(), ))); lines.push(Line::from(Span::styled( "storage primitive", - Style::default().fg(Color::White).bold(), + Style::default().fg(WHITE).bold(), ))); lines.push(Line::from("")); lines.push(Line::from(Span::styled( @@ -170,8 +315,6 @@ fn draw_splash(f: &mut Frame, area: Rect) { ))); let content_height = lines.len() as u16; - - // Center vertically let y = area.y + area.height.saturating_sub(content_height) / 2; let centered_area = Rect::new(area.x, y, area.width, content_height); @@ -181,39 +324,9 @@ fn draw_splash(f: &mut Frame, area: Rect) { /// Draw the setup screen (first-time token entry) fn draw_setup(f: &mut Frame, area: Rect, state: &SetupState) { - // Draw aurora background draw_aurora_background(f, area); - // S2 logo (same as splash) - let logo = vec![ - " █████████████████████████ ", - " ██████████████████████████████ ", - " ███████████████████████████████ ", - "█████████████████████████████████", - "█████████████████████████████████ ", - "███████████████ ", - "███████████████ ", - "██████████████ ████████████████", - "██████████████ ████████████████", - "██████████████ ████████████████", - "███████████████ ███████", - "██████████████████ █████", - "█████████████████████████ ████", - "█████████████████████████ █████", - "██████ ██████", - "█████ ████████", - " ███ ██████████████████████ ", - " ██ ██████████████████████ ", - " ████████████████████ ", - ]; - - // Build content - let mut lines: Vec = logo - .iter() - .map(|&line| Line::from(Span::styled(line, Style::default().fg(Color::White)))) - .collect(); - - // Tagline + let mut lines = render_logo(); lines.push(Line::from("")); lines.push(Line::from(Span::styled( "Streams as a cloud storage primitive", @@ -223,12 +336,8 @@ fn draw_setup(f: &mut Frame, area: Rect, state: &SetupState) { "The serverless API for unlimited, durable, real-time streams.", Style::default().fg(TEXT_MUTED), ))); - - // Spacer lines.push(Line::from("")); lines.push(Line::from("")); - - // Token input - minimal style let token_display = if state.access_token.is_empty() { vec![ Span::styled("Token ", Style::default().fg(TEXT_MUTED)), @@ -236,11 +345,7 @@ fn draw_setup(f: &mut Frame, area: Rect, state: &SetupState) { Span::styled("_", Style::default().fg(CYAN)), ] } else { - let display = if state.access_token.len() > 40 { - format!("{}...", &state.access_token[..40]) - } else { - state.access_token.clone() - }; + let display = truncate_str(&state.access_token, 40, "..."); vec![ Span::styled("Token ", Style::default().fg(TEXT_MUTED)), Span::styled("› ", Style::default().fg(GREEN)), @@ -249,8 +354,6 @@ fn draw_setup(f: &mut Frame, area: Rect, state: &SetupState) { ] }; lines.push(Line::from(token_display)); - - // Status/error line lines.push(Line::from("")); if let Some(error) = &state.error { lines.push(Line::from(Span::styled( @@ -268,8 +371,6 @@ fn draw_setup(f: &mut Frame, area: Rect, state: &SetupState) { Span::styled("s2.dev/dashboard/access-tokens", Style::default().fg(CYAN)), ])); } - - // Footer hint lines.push(Line::from("")); lines.push(Line::from(Span::styled( "Enter to continue · Esc to quit", @@ -287,8 +388,6 @@ fn draw_setup(f: &mut Frame, area: Rect, state: &SetupState) { /// Draw the settings screen fn draw_settings(f: &mut Frame, area: Rect, state: &SettingsState) { use ratatui::widgets::BorderType; - - // Layout: Title bar + content let chunks = Layout::default() .direction(Direction::Vertical) .constraints([ @@ -296,8 +395,6 @@ fn draw_settings(f: &mut Frame, area: Rect, state: &SettingsState) { Constraint::Min(1), // Content ]) .split(area); - - // === TITLE BAR === let title_block = Block::default() .borders(Borders::BOTTOM) .border_style(Style::default().fg(BORDER)) @@ -308,11 +405,7 @@ fn draw_settings(f: &mut Frame, area: Rect, state: &SettingsState) { ])) .block(title_block); f.render_widget(title_content, chunks[0]); - - // === CONTENT === let content_area = chunks[1]; - - // Create centered settings panel let panel_width = 70.min(content_area.width.saturating_sub(4)); let panel_x = content_area.x + (content_area.width.saturating_sub(panel_width)) / 2; let panel_area = Rect::new(panel_x, content_area.y + 1, panel_width, content_area.height.saturating_sub(2)); @@ -325,8 +418,6 @@ fn draw_settings(f: &mut Frame, area: Rect, state: &SettingsState) { .padding(Padding::new(2, 2, 1, 1)); let inner = settings_block.inner(panel_area); f.render_widget(settings_block, panel_area); - - // Settings fields let field_chunks = Layout::default() .direction(Direction::Vertical) .constraints([ @@ -340,11 +431,9 @@ fn draw_settings(f: &mut Frame, area: Rect, state: &SettingsState) { ]) .split(inner); - // Access Token field - // Always show actual value when editing, otherwise respect mask flag let is_editing_token = state.editing && state.selected == 0; let token_display = if is_editing_token { - // When editing, always show actual value + state.access_token.clone() } else if state.access_token_masked && !state.access_token.is_empty() { format!("{}...", "*".repeat(20.min(state.access_token.len()))) @@ -363,8 +452,6 @@ fn draw_settings(f: &mut Frame, area: Rect, state: &SettingsState) { is_editing_token, Some("Space to toggle visibility"), ); - - // Account Endpoint field draw_settings_field( f, field_chunks[1], @@ -378,8 +465,6 @@ fn draw_settings(f: &mut Frame, area: Rect, state: &SettingsState) { state.editing && state.selected == 1, None, ); - - // Basin Endpoint field draw_settings_field( f, field_chunks[2], @@ -393,8 +478,6 @@ fn draw_settings(f: &mut Frame, area: Rect, state: &SettingsState) { state.editing && state.selected == 2, None, ); - - // Compression field (pill selector) let compression_label = Line::from(vec![ Span::styled("Compression", Style::default().fg(TEXT_SECONDARY)), ]); @@ -428,8 +511,6 @@ fn draw_settings(f: &mut Frame, area: Rect, state: &SettingsState) { .style(Style::default().bg(BG_DARK)), compression_row, ); - - // Save button let save_style = if state.selected == 4 { Style::default().fg(BG_DARK).bg(GREEN).bold() } else if state.has_changes { @@ -445,13 +526,13 @@ fn draw_settings(f: &mut Frame, area: Rect, state: &SettingsState) { let save_button = Paragraph::new(Line::from(Span::styled(save_text, save_style))) .alignment(Alignment::Center); f.render_widget(save_button, field_chunks[5]); - - // Message/footer if let Some(msg) = &state.message { - let msg_style = if msg.contains("success") || msg.contains("saved") { - Style::default().fg(SUCCESS) - } else { + let msg_lower = msg.to_lowercase(); + let is_error = msg_lower.contains("error") || msg_lower.contains("fail") || msg_lower.contains("invalid"); + let msg_style = if is_error { Style::default().fg(ERROR) + } else { + Style::default().fg(SUCCESS) }; let msg_para = Paragraph::new(Line::from(Span::styled(msg.as_str(), msg_style))) .alignment(Alignment::Center); @@ -510,55 +591,64 @@ fn draw_settings_field( .style(Style::default().bg(BG_DARK)); let value_para = Paragraph::new(Span::styled(value_display, value_style)) .block(value_block); - // Height must be 3: top border (1) + content (1) + bottom border (1) + f.render_widget(value_para, Rect::new(area.x, area.y + 1, area.width, 3)); } /// Draw a subtle aurora/gradient background effect +/// Optimized to compute one color per row (at center) rather than per-cell fn draw_aurora_background(f: &mut Frame, area: Rect) { - let width = area.width as f64; + if area.width == 0 || area.height == 0 { + return; + } + let height = area.height as f64; for row in 0..area.height { - let mut spans: Vec = Vec::new(); - for col in 0..area.width { - // Normalize coordinates - let x = col as f64 / width; - let y = row as f64 / height; - - // Create aurora effect - subtle glow from bottom-right and center - // Distance from bottom-right corner - let dist_br = ((x - 0.8).powi(2) + (y - 0.9).powi(2)).sqrt(); - // Distance from center-bottom - let dist_cb = ((x - 0.5).powi(2) + (y - 0.85).powi(2)).sqrt(); - - // Aurora intensity (stronger near bottom) - let intensity_br = (1.0 - dist_br * 1.5).max(0.0) * 0.4; - let intensity_cb = (1.0 - dist_cb * 1.8).max(0.0) * 0.3; - let intensity = (intensity_br + intensity_cb).min(1.0); - - // Base dark color with subtle blue/teal tint - let base_r = 8; - let base_g = 12; - let base_b = 18; - - // Aurora colors (teal/cyan) - let aurora_r = 0; - let aurora_g = 40; - let aurora_b = 60; - - let r = base_r + ((aurora_r - base_r as i32) as f64 * intensity) as u8; - let g = base_g + ((aurora_g - base_g as i32) as f64 * intensity) as u8; - let b = base_b + ((aurora_b - base_b as i32) as f64 * intensity) as u8; - - spans.push(Span::styled(" ", Style::default().bg(Color::Rgb(r, g, b)))); - } - let line = Line::from(spans); + let y = row as f64 / height; + + // Build the row string efficiently - all same character + let row_str: String = " ".repeat(area.width as usize); + + // Use the row's center color for performance + // This reduces allocations from O(width) to O(1) per row + let color = aurora_color_at(0.5, y); + + let line = Line::from(Span::styled(row_str, Style::default().bg(color))); let row_area = Rect::new(area.x, area.y + row, area.width, 1); f.render_widget(Paragraph::new(line), row_area); } } +/// Compute aurora color at normalized coordinates (0.0-1.0) +fn aurora_color_at(x: f64, y: f64) -> Color { + // Distance from bottom-right corner + let dist_br = ((x - 0.8).powi(2) + (y - 0.9).powi(2)).sqrt(); + // Distance from center-bottom + let dist_cb = ((x - 0.5).powi(2) + (y - 0.85).powi(2)).sqrt(); + + // Aurora intensity (stronger near bottom) + let intensity_br = (1.0 - dist_br * 1.5).max(0.0) * 0.4; + let intensity_cb = (1.0 - dist_cb * 1.8).max(0.0) * 0.3; + let intensity = (intensity_br + intensity_cb).min(1.0); + + // Base dark color with subtle blue/teal tint + let base_r: i32 = 8; + let base_g: i32 = 12; + let base_b: i32 = 18; + + // Aurora colors (teal/cyan) + let aurora_r: i32 = 0; + let aurora_g: i32 = 40; + let aurora_b: i32 = 60; + + let r = (base_r as f64 + (aurora_r - base_r) as f64 * intensity) as u8; + let g = (base_g as f64 + (aurora_g - base_g) as f64 * intensity) as u8; + let b = (base_b as f64 + (aurora_b - base_b) as f64 * intensity) as u8; + + Color::Rgb(r, g, b) +} + fn draw_tab_bar(f: &mut Frame, area: Rect, current_tab: Tab) { let basins_style = if current_tab == Tab::Basins { Style::default().fg(GREEN).bold() @@ -592,7 +682,7 @@ fn draw_tab_bar(f: &mut Frame, area: Rect, current_tab: Tab) { } fn draw_access_tokens(f: &mut Frame, area: Rect, state: &AccessTokensState) { - // Layout: Title bar, Search bar, Header, Table rows + let chunks = Layout::default() .direction(Direction::Vertical) .constraints([ @@ -602,8 +692,6 @@ fn draw_access_tokens(f: &mut Frame, area: Rect, state: &AccessTokensState) { Constraint::Min(1), // Table rows ]) .split(area); - - // === Title Bar === let count_text = if state.loading { " loading...".to_string() } else { @@ -628,8 +716,6 @@ fn draw_access_tokens(f: &mut Frame, area: Rect, state: &AccessTokensState) { .borders(Borders::BOTTOM) .border_style(Style::default().fg(Color::Rgb(40, 40, 40)))); f.render_widget(title_block, chunks[0]); - - // === Search Bar === let search_block = Block::default() .borders(Borders::ALL) .border_style(Style::default().fg(if state.filter_active { GREEN } else { BORDER })) @@ -657,8 +743,6 @@ fn draw_access_tokens(f: &mut Frame, area: Rect, state: &AccessTokensState) { .style(Style::default().bg(BG_PANEL)); f.render_widget(search_para, chunks[1]); - // === Header === - // Column widths: prefix(2) + token_id(30) + expires_at(28) + scope(rest) let header = Line::from(vec![ Span::styled(" ", Style::default()), // Space for selection prefix Span::styled( @@ -674,8 +758,6 @@ fn draw_access_tokens(f: &mut Frame, area: Rect, state: &AccessTokensState) { let header_para = Paragraph::new(header); f.render_widget(header_para, chunks[2]); - // === Token List === - // Filter tokens let filtered_tokens: Vec<_> = state .tokens .iter() @@ -712,8 +794,6 @@ fn draw_access_tokens(f: &mut Frame, area: Rect, state: &AccessTokensState) { .map(|(i, token)| { let actual_index = start + i; let is_selected = actual_index == state.selected; - - // Format scope summary let scope_summary = format_scope_summary(token); let style = if is_selected { @@ -723,22 +803,10 @@ fn draw_access_tokens(f: &mut Frame, area: Rect, state: &AccessTokensState) { }; let prefix = if is_selected { "▶ " } else { " " }; - - // Truncate token ID if too long (max 28 chars to leave room for padding) let token_id_str = token.id.to_string(); - let token_id_display = if token_id_str.len() > 28 { - format!("{}…", &token_id_str[..27]) - } else { - token_id_str - }; - - // Format expires_at more compactly + let token_id_display = truncate_str(&token_id_str, 28, "…"); let expires_str = token.expires_at.to_string(); - let expires_display = if expires_str.len() > 26 { - format!("{}…", &expires_str[..25]) - } else { - expires_str - }; + let expires_display = truncate_str(&expires_str, 26, "…"); Line::from(vec![ Span::styled(prefix, style), @@ -864,19 +932,15 @@ fn is_token_op(op: &s2_sdk::types::Operation) -> bool { fn draw_metrics_view(f: &mut Frame, area: Rect, state: &MetricsViewState) { use s2_sdk::types::Metric; - - // Layout: Title+tabs, Stats header, Main graph area, Timeline let chunks = Layout::default() .direction(Direction::Vertical) .constraints([ Constraint::Length(3), // Title with tabs Constraint::Length(3), // Stats header row - Constraint::Min(12), // Main graph (area chart) + Constraint::Min(12), Constraint::Length(6), // Timeline (scrollable) ]) .split(area); - - // === Title with integrated category tabs === let title = match &state.metrics_type { MetricsType::Account => "Account".to_string(), MetricsType::Basin { basin_name } => basin_name.to_string(), @@ -884,7 +948,7 @@ fn draw_metrics_view(f: &mut Frame, area: Rect, state: &MetricsViewState) { }; if matches!(state.metrics_type, MetricsType::Account) { - // Account metrics have different categories + let categories = [ MetricCategory::ActiveBasins, MetricCategory::AccountOps, @@ -907,8 +971,6 @@ fn draw_metrics_view(f: &mut Frame, area: Rect, state: &MetricsViewState) { }; title_spans.push(Span::styled(format!(" {} ", cat.as_str()), style)); } - - // Add time range to title title_spans.push(Span::styled(" ", Style::default())); title_spans.push(Span::styled(format!("[{}]", state.time_range.as_str()), Style::default().fg(CYAN))); @@ -929,6 +991,7 @@ fn draw_metrics_view(f: &mut Frame, area: Rect, state: &MetricsViewState) { MetricCategory::ReadOps, MetricCategory::AppendThroughput, MetricCategory::ReadThroughput, + MetricCategory::BasinOps, ]; let mut title_spans: Vec = vec![ @@ -948,8 +1011,6 @@ fn draw_metrics_view(f: &mut Frame, area: Rect, state: &MetricsViewState) { }; title_spans.push(Span::styled(format!(" {} ", cat.as_str()), style)); } - - // Add time range to title title_spans.push(Span::styled(" ", Style::default())); title_spans.push(Span::styled(format!("[{}]", state.time_range.as_str()), Style::default().fg(CYAN))); @@ -974,14 +1035,14 @@ fn draw_metrics_view(f: &mut Frame, area: Rect, state: &MetricsViewState) { Span::styled(" [ ", Style::default().fg(BORDER)), Span::styled(&title, Style::default().fg(GREEN).bold()), Span::styled(" ] ", Style::default().fg(BORDER)), - Span::styled(format!("Storage [{}]", state.time_range.as_str()), Style::default().fg(TEXT_PRIMARY)), + Span::styled(" Storage ", Style::default().fg(BG_DARK).bg(GREEN).bold()), + Span::styled(" ", Style::default()), + Span::styled(format!("[{}]", state.time_range.as_str()), Style::default().fg(CYAN)), ])) .block(title_block) .alignment(Alignment::Center); f.render_widget(title_para, chunks[0]); } - - // === Loading / Empty states === if state.loading { let loading_block = Block::default() .borders(Borders::ALL) @@ -1021,8 +1082,6 @@ fn draw_metrics_view(f: &mut Frame, area: Rect, state: &MetricsViewState) { f.render_widget(empty, remaining[0]); return; } - - // Check for Label metrics first (like Active Basins) let mut label_values: Vec = Vec::new(); let mut label_name = String::new(); @@ -1032,25 +1091,19 @@ fn draw_metrics_view(f: &mut Frame, area: Rect, state: &MetricsViewState) { label_values.extend(m.values.iter().cloned()); } } - - // If we have label metrics, render them differently if !label_values.is_empty() { render_label_metric(f, chunks, &label_name, &label_values, state); return; } - - // Check if we have multiple Accumulation metrics (like Account Ops) let accumulation_metrics: Vec<_> = state.metrics.iter() .filter_map(|m| if let Metric::Accumulation(a) = m { Some(a) } else { None }) .collect(); if accumulation_metrics.len() > 1 { - // Multiple metrics - render as operations breakdown + render_multi_metric(f, chunks, &accumulation_metrics, state); return; } - - // Collect all time-series values for rendering (single metric) let mut all_values: Vec<(u32, f64)> = Vec::new(); let mut metric_name = String::new(); let mut metric_unit = s2_sdk::types::MetricUnit::Bytes; @@ -1079,11 +1132,7 @@ fn draw_metrics_view(f: &mut Frame, area: Rect, state: &MetricsViewState) { if all_values.is_empty() { return; } - - // Sort by timestamp all_values.sort_by_key(|(ts, _)| *ts); - - // Calculate stats let values_only: Vec = all_values.iter().map(|(_, v)| *v).collect(); let min_val = values_only.iter().cloned().fold(f64::MAX, f64::min); let max_val = values_only.iter().cloned().fold(f64::MIN, f64::max); @@ -1094,8 +1143,6 @@ fn draw_metrics_view(f: &mut Frame, area: Rect, state: &MetricsViewState) { }; let latest_val = values_only.last().cloned().unwrap_or(0.0); let first_val = values_only.first().cloned().unwrap_or(0.0); - - // Calculate change for trend indicator let change = if first_val > 0.0 { ((latest_val - first_val) / first_val) * 100.0 } else if latest_val > 0.0 { @@ -1103,12 +1150,8 @@ fn draw_metrics_view(f: &mut Frame, area: Rect, state: &MetricsViewState) { } else { 0.0 }; - - // Time range let first_ts = all_values.first().map(|(ts, _)| *ts).unwrap_or(0); let last_ts = all_values.last().map(|(ts, _)| *ts).unwrap_or(0); - - // === Stats header row === let stats_block = Block::default() .borders(Borders::ALL) .border_style(Style::default().fg(BORDER)) @@ -1116,14 +1159,12 @@ fn draw_metrics_view(f: &mut Frame, area: Rect, state: &MetricsViewState) { let stats_inner = stats_block.inner(chunks[1]); f.render_widget(stats_block, chunks[1]); - - // Trend indicator let (trend_arrow, trend_color) = if change > 1.0 { - ("^", Color::Rgb(34, 197, 94)) + ("↑", GREEN) } else if change < -1.0 { - ("v", Color::Rgb(239, 68, 68)) + ("↓", ERROR) } else { - ("=", TEXT_MUTED) + ("→", TEXT_MUTED) }; let trend_text = if change.abs() > 0.1 { format!("{:+.1}%", change) @@ -1147,8 +1188,6 @@ fn draw_metrics_view(f: &mut Frame, area: Rect, state: &MetricsViewState) { ]); let stats_para = Paragraph::new(stats_line).alignment(Alignment::Center); f.render_widget(stats_para, stats_inner); - - // === Main Area Chart === let chart_block = Block::default() .borders(Borders::ALL) .border_style(Style::default().fg(GREEN)) @@ -1161,11 +1200,7 @@ fn draw_metrics_view(f: &mut Frame, area: Rect, state: &MetricsViewState) { let chart_inner = chart_block.inner(chunks[2]); f.render_widget(chart_block, chunks[2]); - - // Render the area chart render_area_chart(f, chart_inner, &all_values, min_val, max_val, metric_unit, first_ts, last_ts); - - // === Timeline (scrollable detail) === let timeline_block = Block::default() .borders(Borders::ALL) .border_style(Style::default().fg(BORDER)) @@ -1178,8 +1213,6 @@ fn draw_metrics_view(f: &mut Frame, area: Rect, state: &MetricsViewState) { let timeline_inner = timeline_block.inner(chunks[3]); f.render_widget(timeline_block, chunks[3]); - - // Compact timeline bars let bar_width = timeline_inner.width.saturating_sub(26) as usize; let visible_rows = timeline_inner.height as usize; @@ -1194,8 +1227,6 @@ fn draw_metrics_view(f: &mut Frame, area: Rect, state: &MetricsViewState) { 0 }; let intensity = if max_val > 0.0 { *value / max_val } else { 0.0 }; - - // Gradient color based on intensity let bar_color = intensity_to_color(intensity); let bar: String = (0..bar_len).map(|i| { @@ -1240,8 +1271,6 @@ fn render_multi_metric( state: &MetricsViewState, ) { use std::collections::BTreeMap; - - // Color palette for different operation types (purple/blue gradient like web console) let colors = [ Color::Rgb(139, 92, 246), // Purple (primary) Color::Rgb(124, 58, 237), // Violet @@ -1254,8 +1283,6 @@ fn render_multi_metric( Color::Rgb(250, 204, 21), // Yellow (for highlights) Color::Rgb(251, 146, 60), // Orange ]; - - // Calculate totals for each metric and sort by total let mut metric_totals: Vec<(String, f64, usize)> = metrics .iter() .enumerate() @@ -1265,8 +1292,6 @@ fn render_multi_metric( }) .collect(); metric_totals.sort_by(|a, b| b.1.partial_cmp(&a.1).unwrap_or(std::cmp::Ordering::Equal)); - - // Aggregate all values by timestamp for the area chart (sum of all operation types) let mut time_buckets: BTreeMap = BTreeMap::new(); for metric in metrics.iter() { for (ts, val) in &metric.values { @@ -1289,8 +1314,6 @@ fn render_multi_metric( let first_val = values_only.first().cloned().unwrap_or(0.0); let first_ts = all_values.first().map(|(ts, _)| *ts).unwrap_or(0); let last_ts = all_values.last().map(|(ts, _)| *ts).unwrap_or(0); - - // Calculate change for trend indicator let change = if first_val > 0.0 { ((latest_val - first_val) / first_val) * 100.0 } else if latest_val > 0.0 { @@ -1298,8 +1321,6 @@ fn render_multi_metric( } else { 0.0 }; - - // === Stats header row (nerdy style) === let stats_block = Block::default() .borders(Borders::ALL) .border_style(Style::default().fg(BORDER)) @@ -1309,9 +1330,9 @@ fn render_multi_metric( f.render_widget(stats_block, chunks[1]); let (trend_arrow, trend_color) = if change > 1.0 { - ("↑", Color::Rgb(34, 197, 94)) + ("↑", GREEN) } else if change < -1.0 { - ("↓", Color::Rgb(239, 68, 68)) + ("↓", ERROR) } else { ("→", TEXT_MUTED) }; @@ -1337,8 +1358,6 @@ fn render_multi_metric( ]); let stats_para = Paragraph::new(stats_line).alignment(Alignment::Center); f.render_widget(stats_para, stats_inner); - - // === Main Area Chart (total operations over time) === let chart_block = Block::default() .borders(Borders::ALL) .border_style(Style::default().fg(GREEN)) @@ -1355,8 +1374,6 @@ fn render_multi_metric( if !all_values.is_empty() { render_area_chart(f, chart_inner, &all_values, min_val, max_val, s2_sdk::types::MetricUnit::Operations, first_ts, last_ts); } - - // === Timeline/Legend area - show breakdown by operation type === let timeline_block = Block::default() .borders(Borders::ALL) .border_style(Style::default().fg(BORDER)) @@ -1369,8 +1386,6 @@ fn render_multi_metric( let timeline_inner = timeline_block.inner(chunks[3]); f.render_widget(timeline_block, chunks[3]); - - // Render breakdown as horizontal bars let visible_rows = timeline_inner.height as usize; let bar_width = timeline_inner.width.saturating_sub(28) as usize; let max_total = metric_totals.iter().map(|(_, t, _)| *t).fold(0.0, f64::max); @@ -1425,7 +1440,7 @@ fn render_label_metric( values: &[String], state: &MetricsViewState, ) { - // Stats header showing count + let stats_block = Block::default() .borders(Borders::ALL) .border_style(Style::default().fg(BORDER)) @@ -1441,8 +1456,6 @@ fn render_label_metric( ]); let stats_para = Paragraph::new(stats_line).alignment(Alignment::Center); f.render_widget(stats_para, stats_inner); - - // Main list area let list_block = Block::default() .borders(Borders::ALL) .border_style(Style::default().fg(GREEN)) @@ -1461,7 +1474,7 @@ fn render_label_metric( .alignment(Alignment::Center); f.render_widget(empty, list_inner); } else { - // Render the list of values + let visible_rows = list_inner.height as usize; let total_items = values.len(); @@ -1480,8 +1493,6 @@ fn render_label_metric( let list_para = Paragraph::new(items); f.render_widget(list_para, list_inner); - - // Scroll indicator in timeline area let scroll_block = Block::default() .borders(Borders::ALL) .border_style(Style::default().fg(BORDER)) @@ -1520,13 +1531,9 @@ fn render_area_chart( if height < 2 || width < 10 { return; } - - // Calculate value range with some padding let chart_min = if min_val > 0.0 { 0.0 } else { min_val }; let chart_max = max_val * 1.1; // 10% headroom let chart_range = chart_max - chart_min; - - // Resample values to fit width let values_only: Vec = values.iter().map(|(_, v)| *v).collect(); let step = values_only.len() as f64 / width as f64; @@ -1626,16 +1633,12 @@ fn render_sparkline_gradient(values: &[(u32, f64)], width: usize) -> String { if values.is_empty() { return "-".repeat(width); } - - // Sparkline characters from lowest to highest let spark_chars = [' ', '▁', '▂', '▃', '▄', '▅', '▆', '▇', '█']; let values_only: Vec = values.iter().map(|(_, v)| *v).collect(); let min_val = values_only.iter().cloned().fold(f64::MAX, f64::min); let max_val = values_only.iter().cloned().fold(f64::MIN, f64::max); let range = max_val - min_val; - - // Resample values to fit width let step = values_only.len() as f64 / width as f64; let mut sparkline = String::new(); @@ -1659,7 +1662,7 @@ fn render_sparkline_gradient(values: &[(u32, f64)], width: usize) -> String { fn format_metric_timestamp_short(ts: u32) -> String { use std::time::{Duration, UNIX_EPOCH}; let time = UNIX_EPOCH + Duration::from_secs(ts as u64); - // Just show time portion + humantime::format_rfc3339_seconds(time) .to_string() .chars() @@ -1714,23 +1717,23 @@ fn draw_time_picker(f: &mut Frame, state: &MetricsViewState) { let area = f.area(); - // PRESETS.len() + 1 for "Custom" option + let item_count = TimeRangeOption::PRESETS.len() + 1; - // Calculate popup dimensions + let popup_width = 30u16; let popup_height = (item_count as u16) + 4; // Items + borders + title - // Center the popup + let popup_x = (area.width.saturating_sub(popup_width)) / 2; let popup_y = (area.height.saturating_sub(popup_height)) / 2; let popup_area = Rect::new(popup_x, popup_y, popup_width, popup_height); - // Clear the area behind the popup + f.render_widget(Clear, popup_area); - // Build the list items + let mut items: Vec = TimeRangeOption::PRESETS .iter() .enumerate() @@ -1751,7 +1754,7 @@ fn draw_time_picker(f: &mut Frame, state: &MetricsViewState) { }) .collect(); - // Add "Custom" option at index 7 + let custom_index = TimeRangeOption::PRESETS.len(); let is_custom_selected = state.time_picker_selected == custom_index; let is_custom_current = matches!(state.time_range, TimeRangeOption::Custom { .. }); @@ -1787,21 +1790,21 @@ fn draw_time_picker(f: &mut Frame, state: &MetricsViewState) { /// Draw calendar date picker fn draw_calendar_picker(f: &mut Frame, state: &MetricsViewState) { - use chrono::{Datelike, NaiveDate}; + use chrono::{Datelike, Local, NaiveDate}; let area = f.area(); - // Calendar dimensions: 7 columns * 4 chars + padding = 32, height for header + 6 weeks + status + let popup_width = 36u16; let popup_height = 14u16; - // Center the popup + let popup_x = (area.width.saturating_sub(popup_width)) / 2; let popup_y = (area.height.saturating_sub(popup_height)) / 2; let popup_area = Rect::new(popup_x, popup_y, popup_width, popup_height); - // Clear the area behind the popup + f.render_widget(Clear, popup_area); // Month names @@ -1809,11 +1812,12 @@ fn draw_calendar_picker(f: &mut Frame, state: &MetricsViewState) { "January", "February", "March", "April", "May", "June", "July", "August", "September", "October", "November", "December" ]; - let month_name = month_names.get(state.calendar_month as usize - 1).unwrap_or(&"???"); + let month_name = month_names.get(state.calendar_month.saturating_sub(1) as usize).unwrap_or(&"???"); - // Calculate first day of month and days in month + // Calculate first day of month and days in month (with safe fallbacks) + let today = Local::now().date_naive(); let first_of_month = NaiveDate::from_ymd_opt(state.calendar_year, state.calendar_month, 1) - .unwrap_or(NaiveDate::from_ymd_opt(2026, 1, 1).unwrap()); + .unwrap_or(today.with_day(1).unwrap_or(today)); let first_weekday = first_of_month.weekday().num_days_from_sunday() as usize; let days_in_month = { let next_month = if state.calendar_month == 12 { @@ -1821,7 +1825,10 @@ fn draw_calendar_picker(f: &mut Frame, state: &MetricsViewState) { } else { NaiveDate::from_ymd_opt(state.calendar_year, state.calendar_month + 1, 1) }; - next_month.unwrap().pred_opt().map(|d| d.day()).unwrap_or(28) + next_month + .and_then(|d| d.pred_opt()) + .map(|d| d.day()) + .unwrap_or(28) // Safe fallback - February minimum }; // Build calendar lines @@ -1933,7 +1940,7 @@ fn draw_calendar_picker(f: &mut Frame, state: &MetricsViewState) { } fn draw_basins(f: &mut Frame, area: Rect, state: &BasinsState) { - // Layout: Title bar, Search bar, Header, Table rows + let chunks = Layout::default() .direction(Direction::Vertical) .constraints([ @@ -1943,8 +1950,6 @@ fn draw_basins(f: &mut Frame, area: Rect, state: &BasinsState) { Constraint::Min(1), // Table rows ]) .split(area); - - // === Title Bar === let count_text = if state.loading { " loading...".to_string() } else { @@ -1969,8 +1974,6 @@ fn draw_basins(f: &mut Frame, area: Rect, state: &BasinsState) { .borders(Borders::BOTTOM) .border_style(Style::default().fg(Color::Rgb(40, 40, 40)))); f.render_widget(title_block, chunks[0]); - - // === Search Bar === let search_block = Block::default() .borders(Borders::ALL) .border_style(Style::default().fg(if state.filter_active { GREEN } else { BORDER })) @@ -2010,8 +2013,6 @@ fn draw_basins(f: &mut Frame, area: Rect, state: &BasinsState) { Span::styled("Scope", Style::default().fg(TEXT_MUTED)), ]); f.render_widget(Paragraph::new(header), Rect::new(header_area.x, header_area.y, header_area.width, 1)); - - // Header separator let sep = "─".repeat(total_width); f.render_widget( Paragraph::new(Span::styled(sep, Style::default().fg(BORDER))), @@ -2076,11 +2077,8 @@ fn draw_basins(f: &mut Frame, area: Rect, state: &BasinsState) { // Name column let name = basin.name.to_string(); - let display_name = if name.len() > name_col - 2 { - format!("{}…", &name[..name_col - 3]) - } else { - name - }; + let max_name_len = name_col.saturating_sub(2); + let display_name = truncate_str(&name, max_name_len, "…"); // State badge let (state_text, state_bg) = match basin.state { @@ -2125,7 +2123,7 @@ fn draw_basins(f: &mut Frame, area: Rect, state: &BasinsState) { } fn draw_streams(f: &mut Frame, area: Rect, state: &StreamsState) { - // Layout: Title bar, Search bar, Header, Table rows + let chunks = Layout::default() .direction(Direction::Vertical) .constraints([ @@ -2135,8 +2133,6 @@ fn draw_streams(f: &mut Frame, area: Rect, state: &StreamsState) { Constraint::Min(1), // Table rows ]) .split(area); - - // === Title Bar === let count_text = if state.loading { " loading...".to_string() } else { @@ -2164,8 +2160,6 @@ fn draw_streams(f: &mut Frame, area: Rect, state: &StreamsState) { .borders(Borders::BOTTOM) .border_style(Style::default().fg(Color::Rgb(40, 40, 40)))); f.render_widget(title_block, chunks[0]); - - // === Search Bar === let search_block = Block::default() .borders(Borders::ALL) .border_style(Style::default().fg(if state.filter_active { GREEN } else { BORDER })) @@ -2202,8 +2196,6 @@ fn draw_streams(f: &mut Frame, area: Rect, state: &StreamsState) { Span::styled("Created", Style::default().fg(TEXT_MUTED)), ]); f.render_widget(Paragraph::new(header), Rect::new(header_area.x, header_area.y, header_area.width, 1)); - - // Header separator let sep = "─".repeat(total_width); f.render_widget( Paragraph::new(Span::styled(sep, Style::default().fg(BORDER))), @@ -2268,11 +2260,8 @@ fn draw_streams(f: &mut Frame, area: Rect, state: &StreamsState) { // Name column let name = stream.name.to_string(); - let display_name = if name.len() > name_col - 2 { - format!("{}…", &name[..name_col - 3]) - } else { - name - }; + let max_name_len = name_col.saturating_sub(2); + let display_name = truncate_str(&name, max_name_len, "…"); // Created timestamp - S2DateTime implements Display let created = stream.created_at.to_string(); @@ -2302,7 +2291,7 @@ fn draw_stream_detail(f: &mut Frame, area: Rect, state: &StreamDetailState) { let chunks = Layout::default() .direction(Direction::Vertical) .constraints([ - Constraint::Length(3), // Header with URI (consistent height) + Constraint::Length(3), Constraint::Length(5), // Stats cards Constraint::Min(12), // Actions ]) @@ -2330,9 +2319,9 @@ fn draw_stream_detail(f: &mut Frame, area: Rect, state: &StreamDetailState) { let stats_area = Layout::default() .direction(Direction::Horizontal) .constraints([ - Constraint::Length(2), // Left padding + Constraint::Length(2), Constraint::Min(20), // Stats content - Constraint::Length(2), // Right padding + Constraint::Length(2), ]) .split(chunks[1])[1]; @@ -2430,8 +2419,6 @@ fn draw_stream_detail(f: &mut Frame, area: Rect, state: &StreamDetailState) { ("--".to_string(), Color::Rgb(100, 100, 100)) }; render_stat_card_v2(f, stats_chunks[2], "◈", "Storage", &storage_val, storage_color); - - // Retention let (retention_val, retention_color) = if let Some(config) = &state.config { let val = config.retention_policy .as_ref() @@ -2557,12 +2544,19 @@ fn draw_read_view(f: &mut Frame, area: Rect, state: &ReadViewState) { ("READING", ACCENT) }; - // Split into header and content + // Show sparklines when tailing with throughput data + let show_sparklines = state.is_tailing && !state.throughput_history.is_empty(); + let sparkline_height = if show_sparklines { 4 } else { 0 }; + let timeline_height = if state.show_timeline { 3 } else { 0 }; + + // Split into header, optional sparklines, content, and optional timeline let main_chunks = Layout::default() .direction(Direction::Vertical) .constraints([ - Constraint::Length(3), // Header (consistent height) + Constraint::Length(3), + Constraint::Length(sparkline_height), Constraint::Min(1), // Content + Constraint::Length(timeline_height), ]) .split(area); @@ -2581,6 +2575,19 @@ fn draw_read_view(f: &mut Frame, area: Rect, state: &ReadViewState) { Span::styled(&record_count, Style::default().fg(Color::Rgb(100, 100, 100))), ]; + // Add throughput indicator when tailing + if state.is_tailing && state.current_mibps > 0.0 { + header_spans.push(Span::styled(" ", Style::default())); + header_spans.push(Span::styled( + format!("{:.1} MiB/s", state.current_mibps), + Style::default().fg(CYAN).bold(), + )); + header_spans.push(Span::styled( + format!(" {:.0} rec/s", state.current_recps), + Style::default().fg(TEXT_MUTED), + )); + } + // Add output file indicator if writing to file if let Some(ref output) = state.output_file { header_spans.push(Span::styled(" → ", Style::default().fg(Color::Rgb(100, 100, 100)))); @@ -2597,10 +2604,12 @@ fn draw_read_view(f: &mut Frame, area: Rect, state: &ReadViewState) { .border_style(Style::default().fg(Color::Rgb(40, 40, 40)))); f.render_widget(header, main_chunks[0]); - // === CONTENT === - let content_area = main_chunks[1]; + // === SPARKLINES (when tailing) === + if show_sparklines { + draw_tail_sparklines(f, main_chunks[1], &state.throughput_history, &state.records_per_sec_history); + } - // Main container + let content_area = main_chunks[2]; let outer_block = Block::default() .borders(Borders::NONE); @@ -2631,7 +2640,7 @@ fn draw_read_view(f: &mut Frame, area: Rect, state: &ReadViewState) { let panes = Layout::default() .direction(Direction::Horizontal) .constraints([ - Constraint::Length(28), // Record list - compact + Constraint::Length(28), Constraint::Min(20), // Body preview - takes remaining space ]) .split(inner_area); @@ -2711,10 +2720,8 @@ fn draw_read_view(f: &mut Frame, area: Rect, state: &ReadViewState) { let cinema_mode = state.hide_list && state.is_tailing && !state.paused; let (content_start_y, content_height) = if cinema_mode { - // Full height for body in cinema mode (body_area.y, body_height) } else { - // Header line with metadata let header_line = Line::from(vec![ Span::styled(format!(" #{}", record.seq_num), Style::default().fg(GREEN).bold()), Span::styled(format!(" {}ms", record.timestamp), Style::default().fg(TEXT_MUTED)), @@ -2727,7 +2734,6 @@ fn draw_read_view(f: &mut Frame, area: Rect, state: &ReadViewState) { ]); f.render_widget(Paragraph::new(header_line), Rect::new(body_area.x, body_area.y, body_area.width, 1)); - // Separator let sep = "─".repeat(body_width); f.render_widget( Paragraph::new(Span::styled(format!(" {}", sep), Style::default().fg(BORDER))), @@ -2743,11 +2749,9 @@ fn draw_read_view(f: &mut Frame, area: Rect, state: &ReadViewState) { Rect::new(body_area.x, content_start_y, body_area.width, 1), ); } else { - // Display body text line by line (no wrapping for ASCII art) let mut display_lines: Vec = Vec::new(); for line in body.lines().take(content_height) { - // For cinema mode, preserve spacing for ASCII art; otherwise wrap if cinema_mode { display_lines.push(Line::from(Span::styled(line.to_string(), Style::default().fg(TEXT_PRIMARY)))); } else { @@ -2758,16 +2762,11 @@ fn draw_read_view(f: &mut Frame, area: Rect, state: &ReadViewState) { for chunk in chars.chunks(body_width.max(1)) { let text: String = chunk.iter().collect(); display_lines.push(Line::from(Span::styled(text, Style::default().fg(TEXT_PRIMARY)))); - if display_lines.len() >= content_height { - break; - } + if display_lines.len() >= content_height { break; } } } } - - if display_lines.len() >= content_height { - break; - } + if display_lines.len() >= content_height { break; } } let body_para = Paragraph::new(display_lines) @@ -2776,6 +2775,11 @@ fn draw_read_view(f: &mut Frame, area: Rect, state: &ReadViewState) { } } + // Draw timeline scrubber if enabled + if state.show_timeline && !state.records.is_empty() { + draw_timeline_scrubber(f, main_chunks[3], state); + } + // Draw headers popup if showing if state.show_detail { if let Some(record) = state.records.get(selected) { @@ -2784,6 +2788,90 @@ fn draw_read_view(f: &mut Frame, area: Rect, state: &ReadViewState) { } } +fn draw_timeline_scrubber(f: &mut Frame, area: Rect, state: &ReadViewState) { + let total = state.records.len(); + if total == 0 { return; } + + let selected = state.selected.min(total.saturating_sub(1)); + let width = area.width.saturating_sub(4) as usize; + + // Calculate record density histogram + let bucket_count = width.max(1); + let mut buckets = vec![0u64; bucket_count]; + + for (i, _record) in state.records.iter().enumerate() { + let bucket = (i * bucket_count) / total.max(1); + let bucket = bucket.min(bucket_count - 1); + buckets[bucket] += 1; + } + + // Normalize buckets for display + let max_bucket = buckets.iter().copied().max().unwrap_or(1).max(1); + + // Build histogram line + let bar_chars = ['▁', '▂', '▃', '▄', '▅', '▆', '▇', '█']; + let mut histogram_spans = Vec::new(); + + let current_bucket = (selected * bucket_count) / total.max(1); + let current_bucket = current_bucket.min(bucket_count - 1); + + for (i, &count) in buckets.iter().enumerate() { + let level = ((count as f64 / max_bucket as f64) * 7.0).round() as usize; + let level = level.min(7); + let ch = bar_chars[level]; + + let color = if i == current_bucket { + GREEN + } else if count > 0 { + CYAN + } else { + BORDER + }; + + histogram_spans.push(Span::styled(ch.to_string(), Style::default().fg(color))); + } + + // Position indicator + let position_pct = (selected as f64 / (total - 1).max(1) as f64) * 100.0; + + // Time info from records + let (first_ts, last_ts) = if let (Some(first), Some(last)) = (state.records.front(), state.records.back()) { + (first.timestamp, last.timestamp) + } else { + (0, 0) + }; + + let time_span = if last_ts > first_ts { + let span_ms = last_ts - first_ts; + if span_ms >= 3600000 { + format!("{:.1}h span", span_ms as f64 / 3600000.0) + } else if span_ms >= 60000 { + format!("{:.1}m span", span_ms as f64 / 60000.0) + } else { + format!("{:.1}s span", span_ms as f64 / 1000.0) + } + } else { + "".to_string() + }; + + let block = Block::default() + .borders(Borders::ALL) + .border_style(Style::default().fg(BORDER)) + .title(Line::from(vec![ + Span::styled(" Timeline ", Style::default().fg(TEXT_MUTED)), + Span::styled(format!("#{}", state.records.get(selected).map(|r| r.seq_num).unwrap_or(0)), Style::default().fg(GREEN).bold()), + Span::styled(format!(" ({:.0}%) ", position_pct), Style::default().fg(TEXT_MUTED)), + Span::styled(time_span, Style::default().fg(CYAN)), + ])) + .title_bottom(Line::from(Span::styled(" [ ] seek T toggle ", Style::default().fg(TEXT_MUTED)))); + + let inner = block.inner(area); + f.render_widget(block, area); + + // Draw histogram + f.render_widget(Paragraph::new(Line::from(histogram_spans)).alignment(Alignment::Center), inner); +} + fn draw_headers_popup(f: &mut Frame, record: &s2_sdk::types::SequencedRecord) { // Size popup based on number of headers (min height for "no headers" message) let content_lines = if record.headers.is_empty() { 1 } else { record.headers.len() }; @@ -2837,7 +2925,7 @@ fn draw_append_view(f: &mut Frame, area: Rect, state: &AppendViewState) { let outer_chunks = Layout::default() .direction(Direction::Vertical) .constraints([ - Constraint::Length(3), // Header (consistent height) + Constraint::Length(3), Constraint::Min(1), // Content ]) .split(area); @@ -2861,8 +2949,6 @@ fn draw_append_view(f: &mut Frame, area: Rect, state: &AppendViewState) { .borders(Borders::BOTTOM) .border_style(Style::default().fg(Color::Rgb(40, 40, 40)))); f.render_widget(header, outer_chunks[0]); - - // === CONTENT === // Split into form (left) and history (right) let main_chunks = Layout::default() .direction(Direction::Horizontal) @@ -2888,7 +2974,6 @@ fn draw_append_view(f: &mut Frame, area: Rect, state: &AppendViewState) { let mut lines: Vec = Vec::new(); - // Row 0: Body let body_selected = state.selected == 0; let body_editing = body_selected && state.editing; lines.push(Line::from(vec![ @@ -2908,7 +2993,6 @@ fn draw_append_view(f: &mut Frame, area: Rect, state: &AppendViewState) { ])); lines.push(Line::from("")); - // Row 1: Headers let headers_selected = state.selected == 1; let headers_editing = headers_selected && state.editing; lines.push(Line::from(vec![ @@ -2922,7 +3006,6 @@ fn draw_append_view(f: &mut Frame, area: Rect, state: &AppendViewState) { }, ])); - // Show existing headers for (key, value) in &state.headers { lines.push(Line::from(vec![ Span::styled(" ", Style::default()), @@ -2932,7 +3015,6 @@ fn draw_append_view(f: &mut Frame, area: Rect, state: &AppendViewState) { ])); } - // Show header input if editing if headers_editing { lines.push(Line::from(vec![ Span::styled(" + ", Style::default().fg(GREEN)), @@ -2955,7 +3037,6 @@ fn draw_append_view(f: &mut Frame, area: Rect, state: &AppendViewState) { } lines.push(Line::from("")); - // Row 2: Match seq num let match_selected = state.selected == 2; let match_editing = match_selected && state.editing; lines.push(Line::from(vec![ @@ -2973,7 +3054,6 @@ fn draw_append_view(f: &mut Frame, area: Rect, state: &AppendViewState) { ])); lines.push(Line::from("")); - // Row 3: Fencing token let fence_selected = state.selected == 3; let fence_editing = fence_selected && state.editing; lines.push(Line::from(vec![ @@ -2991,7 +3071,6 @@ fn draw_append_view(f: &mut Frame, area: Rect, state: &AppendViewState) { ])); lines.push(Line::from("")); - // Row 4: Send button let send_selected = state.selected == 4; let can_send = !state.body.is_empty() && !state.appending; let (btn_fg, btn_bg) = if state.appending { @@ -3071,9 +3150,22 @@ fn draw_status_bar(f: &mut Frame, area: Rect, app: &App) { Span::styled(&m.text, Style::default().fg(color)) }); - // Calculate available width for hints after message + // PiP indicator + let pip_indicator: Option> = app.pip.as_ref().map(|pip| { + vec![ + Span::styled(" PiP:", Style::default().fg(TEXT_MUTED)), + Span::styled( + format!("{}", pip.stream_name), + Style::default().fg(CYAN), + ), + Span::styled(" ", Style::default()), + ] + }); + + // Calculate available width for hints after message and PiP indicator let msg_len = app.message.as_ref().map(|m| m.text.len() + 2).unwrap_or(0); - let available = width.saturating_sub(msg_len); + let pip_len = app.pip.as_ref().map(|p| p.stream_name.to_string().len() + 7).unwrap_or(0); + let available = width.saturating_sub(msg_len + pip_len); // Truncate hints if needed let display_hints: String = if hints.len() > available && available > 3 { @@ -3082,16 +3174,20 @@ fn draw_status_bar(f: &mut Frame, area: Rect, app: &App) { hints }; - let line = if let Some(msg) = message_span { - Line::from(vec![ - msg, - Span::styled(" ", Style::default()), - Span::styled(display_hints, Style::default().fg(TEXT_MUTED)), - ]) - } else { - Line::from(Span::styled(display_hints, Style::default().fg(TEXT_MUTED))) - }; + let mut spans = Vec::new(); + + if let Some(msg) = message_span { + spans.push(msg); + spans.push(Span::styled(" ", Style::default())); + } + if let Some(pip_spans) = pip_indicator { + spans.extend(pip_spans); + } + + spans.push(Span::styled(display_hints, Style::default().fg(TEXT_MUTED))); + + let line = Line::from(spans); let status = Paragraph::new(line); f.render_widget(status, area); } @@ -3133,11 +3229,11 @@ fn get_responsive_hints(screen: &Screen, width: usize) -> String { } Screen::StreamDetail(_) => { if wide { - "t tail | r read | a append | f fence | m trim | M metrics | e cfg | esc".to_string() + "t tail | r read | a append | f fence | m trim | p pip | M metrics | e cfg | esc".to_string() } else if medium { - "t tail | r read | a append | f m M e | esc".to_string() + "t tail | r read | a append | p pip | f m M e | esc".to_string() } else { - "t r a f m M esc".to_string() + "t r a p f m M esc".to_string() } } Screen::ReadView(s) => { @@ -3145,19 +3241,19 @@ fn get_responsive_hints(screen: &Screen, width: usize) -> String { "esc/⏎ close".to_string() } else if s.is_tailing { if wide { - "jk nav | h headers | ⇥ list | space pause | gG top/bot | esc".to_string() + "jk nav | [] seek | h headers | T timeline | ⇥ list | space pause | esc".to_string() } else if medium { - "jk nav | h hdrs | ⇥ | space | gG | esc".to_string() + "jk [] nav | h | T time | ⇥ | space | esc".to_string() } else { - "jk h ⇥ space gG esc".to_string() + "jk [] h T ⇥ space esc".to_string() } } else { if wide { - "jk nav | h headers | ⇥ list | gG top/bot | esc".to_string() + "jk nav | [] seek | h headers | T timeline | ⇥ list | esc".to_string() } else if medium { - "jk nav | h hdrs | ⇥ | gG | esc".to_string() + "jk [] nav | h | T time | ⇥ | esc".to_string() } else { - "jk h ⇥ gG esc".to_string() + "jk [] h T ⇥ esc".to_string() } } } @@ -3395,6 +3491,10 @@ fn draw_help_overlay(f: &mut Frame, screen: &Screen) { Span::styled(" M ", Style::default().fg(GREEN).bold()), Span::styled("Stream metrics", Style::default().fg(TEXT_SECONDARY)), ]), + Line::from(vec![ + Span::styled(" p ", Style::default().fg(CYAN).bold()), + Span::styled("Pin to PiP", Style::default().fg(TEXT_SECONDARY)), + ]), Line::from(vec![ Span::styled(" esc ", Style::default().fg(GREEN).bold()), Span::styled("Back", Style::default().fg(TEXT_SECONDARY)), @@ -3415,6 +3515,10 @@ fn draw_help_overlay(f: &mut Frame, screen: &Screen) { Span::styled("space ", Style::default().fg(GREEN).bold()), Span::styled("Pause / Resume", Style::default().fg(TEXT_SECONDARY)), ]), + Line::from(vec![ + Span::styled(" p ", Style::default().fg(CYAN).bold()), + Span::styled("Pin to PiP", Style::default().fg(TEXT_SECONDARY)), + ]), Line::from(vec![ Span::styled(" esc ", Style::default().fg(GREEN).bold()), Span::styled("Back", Style::default().fg(TEXT_SECONDARY)), @@ -3615,55 +3719,8 @@ fn draw_input_dialog(f: &mut Frame, mode: &InputMode) { } => { use crate::tui::app::BasinScopeOption; - let cursor = "▎"; let name_valid = name.len() >= 8 && name.len() <= 48; - // Modern toggle switch rendering - let toggle = |on: bool, selected: bool| -> Vec> { - if on { - vec![ - Span::styled("", Style::default().fg(if selected { GREEN } else { Color::Rgb(60, 60, 60) })), - Span::styled(" ON ", Style::default().fg(BG_DARK).bg(GREEN).bold()), - Span::styled("", Style::default().fg(GREEN)), - ] - } else { - vec![ - Span::styled("", Style::default().fg(if selected { TEXT_MUTED } else { Color::Rgb(60, 60, 60) })), - Span::styled(" OFF ", Style::default().fg(TEXT_MUTED).bg(Color::Rgb(60, 60, 60))), - Span::styled("", Style::default().fg(Color::Rgb(60, 60, 60))), - ] - } - }; - - // Pill-style selector for enum options - let pill = |label: &str, is_selected: bool, is_active: bool| -> Span<'static> { - let label = label.to_string(); - if is_active { - Span::styled(format!(" {} ", label), Style::default().fg(BG_DARK).bg(GREEN).bold()) - } else if is_selected { - Span::styled(format!(" {} ", label), Style::default().fg(TEXT_PRIMARY).bg(Color::Rgb(50, 50, 50))) - } else { - Span::styled(format!(" {} ", label), Style::default().fg(TEXT_MUTED)) - } - }; - - - // Field row with label and value - let field_row = |idx: usize, label: &str, sel: usize| -> (Span<'static>, Span<'static>) { - let is_sel = sel == idx; - let indicator = if is_sel { - Span::styled(" > ", Style::default().fg(GREEN).bold()) - } else { - Span::styled(" ", Style::default()) - }; - let label_style = if is_sel { - Style::default().fg(TEXT_PRIMARY).bold() - } else { - Style::default().fg(TEXT_MUTED) - }; - (indicator, Span::styled(label.to_string(), label_style)) - }; - // Scope options let scope_opts = [ ("AWS us-east-1", *scope == BasinScopeOption::AwsUsEast1), @@ -3683,8 +3740,6 @@ fn draw_input_dialog(f: &mut Frame, mode: &InputMode) { ("ClientRequire", matches!(timestamping_mode, Some(TimestampingMode::ClientRequire))), ("Arrival", matches!(timestamping_mode, Some(TimestampingMode::Arrival))), ]; - - // Retention options let ret_opts = [ ("Infinite", *retention_policy == RetentionPolicyOption::Infinite), ("Age-based", *retention_policy == RetentionPolicyOption::Age), @@ -3692,31 +3747,17 @@ fn draw_input_dialog(f: &mut Frame, mode: &InputMode) { let mut lines = vec![]; - // ═══════════════════════════════════════════════════════════════ - // BASIN NAME SECTION - // ═══════════════════════════════════════════════════════════════ + // Basin name section lines.push(Line::from("")); - lines.push(Line::from(vec![ - Span::styled(" Basin name ", Style::default().fg(CYAN).bold()), - Span::styled("─".repeat(35), Style::default().fg(Color::Rgb(50, 50, 50))), - ])); + lines.push(render_section_header("Basin name", 48)); lines.push(Line::from("")); - // Basin Name - let (ind, lbl) = field_row(0, "Name", *selected); - let name_display = if name.is_empty() { - Span::styled("enter name...", Style::default().fg(Color::Rgb(80, 80, 80)).italic()) - } else { - let color = if name_valid { GREEN } else { YELLOW }; - Span::styled(name.clone(), Style::default().fg(color)) - }; - let cursor_span = if *selected == 0 && *editing { - Span::styled(cursor, Style::default().fg(GREEN)) - } else { - Span::raw("") - }; - - lines.push(Line::from(vec![ind, lbl, Span::raw(" "), name_display, cursor_span])); + // Basin Name field + let (ind, lbl) = render_field_row_bold(0, "Name", *selected); + let name_color = if name.is_empty() { GRAY_600 } else if name_valid { GREEN } else { YELLOW }; + let mut name_spans = vec![ind, lbl, Span::raw(" ")]; + name_spans.extend(render_text_input(name, *selected == 0 && *editing, "enter name...", name_color)); + lines.push(Line::from(name_spans)); // Validation hint let hint_text = if name.is_empty() { @@ -3728,7 +3769,7 @@ fn draw_input_dialog(f: &mut Frame, mode: &InputMode) { } else { format!("{}/48 chars", name.len()) }; - let hint_color = if name_valid { Color::Rgb(80, 80, 80) } else if name.is_empty() { Color::Rgb(80, 80, 80) } else { YELLOW }; + let hint_color = if name_valid || name.is_empty() { GRAY_600 } else { YELLOW }; lines.push(Line::from(vec![ Span::raw(" "), Span::styled(hint_text, Style::default().fg(hint_color).italic()), @@ -3736,69 +3777,59 @@ fn draw_input_dialog(f: &mut Frame, mode: &InputMode) { // Basin Scope (Cloud Provider/Region) lines.push(Line::from("")); - let (ind, lbl) = field_row(1, "Region", *selected); + let (ind, lbl) = render_field_row_bold(1, "Region", *selected); let mut scope_spans = vec![ind, lbl, Span::raw(" ")]; for (label, active) in &scope_opts { - scope_spans.push(pill(label, *selected == 1, *active)); + scope_spans.push(render_pill(label, *selected == 1, *active)); scope_spans.push(Span::raw(" ")); } lines.push(Line::from(scope_spans)); - // ═══════════════════════════════════════════════════════════════ - // DEFAULT STREAM CONFIGURATION SECTION - // ═══════════════════════════════════════════════════════════════ + // Default stream configuration section lines.push(Line::from("")); - lines.push(Line::from(vec![ - Span::styled(" Default stream configuration ", Style::default().fg(CYAN).bold()), - Span::styled("─".repeat(17), Style::default().fg(Color::Rgb(50, 50, 50))), - ])); + lines.push(render_section_header("Default stream configuration", 48)); lines.push(Line::from("")); // Storage Class - let (ind, lbl) = field_row(2, "Storage", *selected); + let (ind, lbl) = render_field_row_bold(2, "Storage", *selected); let mut storage_spans = vec![ind, lbl, Span::raw(" ")]; for (label, active) in &storage_opts { - storage_spans.push(pill(label, *selected == 2, *active)); + storage_spans.push(render_pill(label, *selected == 2, *active)); storage_spans.push(Span::raw(" ")); } lines.push(Line::from(storage_spans)); - // Retention lines.push(Line::from("")); - let (ind, lbl) = field_row(3, "Retention", *selected); + let (ind, lbl) = render_field_row_bold(3, "Retention", *selected); let mut ret_spans = vec![ind, lbl, Span::raw(" ")]; for (label, active) in &ret_opts { - ret_spans.push(pill(label, *selected == 3, *active)); + ret_spans.push(render_pill(label, *selected == 3, *active)); ret_spans.push(Span::raw(" ")); } lines.push(Line::from(ret_spans)); - // Retention Age (conditional) if *retention_policy == RetentionPolicyOption::Age { - let (ind, lbl) = field_row(4, " Duration", *selected); - let age_cursor = if *selected == 4 && *editing { cursor } else { "" }; - lines.push(Line::from(vec![ - ind, lbl, Span::raw(" "), - Span::styled(retention_age_input.clone(), Style::default().fg(YELLOW)), - Span::styled(age_cursor, Style::default().fg(GREEN)), - Span::styled(" e.g. 7d, 30d, 1y", Style::default().fg(Color::Rgb(80, 80, 80)).italic()), - ])); + let (ind, lbl) = render_field_row_bold(4, " Duration", *selected); + let mut duration_spans = vec![ind, lbl, Span::raw(" ")]; + duration_spans.extend(render_text_input(retention_age_input, *selected == 4 && *editing, "", YELLOW)); + duration_spans.push(Span::styled(" e.g. 7d, 30d, 1y", Style::default().fg(GRAY_600).italic())); + lines.push(Line::from(duration_spans)); } // Timestamping Mode lines.push(Line::from("")); - let (ind, lbl) = field_row(5, "Timestamps", *selected); + let (ind, lbl) = render_field_row_bold(5, "Timestamps", *selected); let mut ts_spans = vec![ind, lbl, Span::raw(" ")]; for (label, active) in &ts_opts { - ts_spans.push(pill(label, *selected == 5, *active)); + ts_spans.push(render_pill(label, *selected == 5, *active)); ts_spans.push(Span::raw(" ")); } lines.push(Line::from(ts_spans)); // Uncapped Timestamps - let (ind, lbl) = field_row(6, " Uncapped", *selected); + let (ind, lbl) = render_field_row_bold(6, " Uncapped", *selected); let mut uncapped_spans = vec![ind, lbl, Span::raw(" ")]; - uncapped_spans.extend(toggle(*timestamping_uncapped, *selected == 6)); + uncapped_spans.extend(render_toggle(*timestamping_uncapped, *selected == 6)); lines.push(Line::from(uncapped_spans)); // Delete on Empty @@ -3807,89 +3838,63 @@ fn draw_input_dialog(f: &mut Frame, mode: &InputMode) { ("After threshold", *delete_on_empty_enabled), ]; lines.push(Line::from("")); - let (ind, lbl) = field_row(7, "Delete on empty", *selected); + let (ind, lbl) = render_field_row_bold(7, "Delete on empty", *selected); let mut del_spans = vec![ind, lbl, Span::raw(" ")]; for (label, active) in &delete_opts { - del_spans.push(pill(label, *selected == 7, *active)); + del_spans.push(render_pill(label, *selected == 7, *active)); del_spans.push(Span::raw(" ")); } lines.push(Line::from(del_spans)); // Delete on Empty Threshold (conditional) if *delete_on_empty_enabled { - let (ind, lbl) = field_row(8, " Threshold", *selected); - let age_cursor = if *selected == 8 && *editing { cursor } else { "" }; - lines.push(Line::from(vec![ - ind, lbl, Span::raw(" "), - Span::styled(delete_on_empty_min_age.clone(), Style::default().fg(YELLOW)), - Span::styled(age_cursor, Style::default().fg(GREEN)), - Span::styled(" e.g. 1h, 7d", Style::default().fg(Color::Rgb(80, 80, 80)).italic()), - ])); + let (ind, lbl) = render_field_row_bold(8, " Threshold", *selected); + let mut threshold_spans = vec![ind, lbl, Span::raw(" ")]; + threshold_spans.extend(render_text_input(delete_on_empty_min_age, *selected == 8 && *editing, "", YELLOW)); + threshold_spans.push(Span::styled(" e.g. 1h, 7d", Style::default().fg(GRAY_600).italic())); + lines.push(Line::from(threshold_spans)); } - // ═══════════════════════════════════════════════════════════════ - // CREATE STREAMS AUTOMATICALLY SECTION - // ═══════════════════════════════════════════════════════════════ + // Create streams automatically section lines.push(Line::from("")); - lines.push(Line::from(vec![ - Span::styled(" Create streams automatically ", Style::default().fg(CYAN).bold()), - Span::styled("─".repeat(17), Style::default().fg(Color::Rgb(50, 50, 50))), - ])); + lines.push(render_section_header("Create streams automatically", 48)); lines.push(Line::from("")); // On Append - let (ind, lbl) = field_row(9, "On append", *selected); + let (ind, lbl) = render_field_row_bold(9, "On append", *selected); let mut append_spans = vec![ind, lbl, Span::raw(" ")]; - append_spans.extend(toggle(*create_stream_on_append, *selected == 9)); + append_spans.extend(render_toggle(*create_stream_on_append, *selected == 9)); lines.push(Line::from(append_spans)); // On Read lines.push(Line::from("")); - let (ind, lbl) = field_row(10, "On read", *selected); + let (ind, lbl) = render_field_row_bold(10, "On read", *selected); let mut read_spans = vec![ind, lbl, Span::raw(" ")]; - read_spans.extend(toggle(*create_stream_on_read, *selected == 10)); + read_spans.extend(render_toggle(*create_stream_on_read, *selected == 10)); lines.push(Line::from(read_spans)); - // ═══════════════════════════════════════════════════════════════ - // CREATE BUTTON - // ═══════════════════════════════════════════════════════════════ + // Create button section lines.push(Line::from("")); lines.push(Line::from(vec![ - Span::styled("─".repeat(52), Style::default().fg(Color::Rgb(50, 50, 50))), + Span::styled("─".repeat(52), Style::default().fg(GRAY_750)), ])); lines.push(Line::from("")); let can_create = name_valid; - let btn_style = if *selected == 11 && can_create { - Style::default().fg(BG_DARK).bg(GREEN).bold() - } else if can_create { - Style::default().fg(GREEN).bold() - } else { - Style::default().fg(Color::Rgb(80, 80, 80)) - }; - - let btn_indicator = if *selected == 11 { - Span::styled(" > ", Style::default().fg(GREEN).bold()) - } else { - Span::raw(" ") - }; - - lines.push(Line::from(vec![ - btn_indicator, - Span::styled(" CREATE BASIN ", btn_style), - if !can_create { - Span::styled(" (enter valid name)", Style::default().fg(Color::Rgb(80, 80, 80)).italic()) - } else { - Span::raw("") - }, - ])); + lines.push(render_button("CREATE BASIN", *selected == 11, can_create, GREEN)); + if !can_create { + lines.push(Line::from(vec![ + Span::raw(" "), + Span::styled("(enter valid name)", Style::default().fg(GRAY_600).italic()), + ])); + } lines.push(Line::from("")); ( " Create Basin ", lines, - "j/k navigate | h/l cycle | Space toggle | Enter edit | Esc cancel", + "j/k navigate · h/l cycle · Space toggle · Enter edit · Esc cancel", ) } @@ -3906,59 +3911,6 @@ fn draw_input_dialog(f: &mut Frame, mode: &InputMode) { selected, editing, } => { - let cursor = "▎"; - - // Modern toggle switch rendering - let toggle = |on: bool, is_selected: bool| -> Vec> { - if on { - vec![ - Span::styled("", Style::default().fg(if is_selected { GREEN } else { Color::Rgb(60, 60, 60) })), - Span::styled(" ON ", Style::default().fg(BG_DARK).bg(GREEN).bold()), - Span::styled("▁▂▃", Style::default().fg(GREEN)), - ] - } else { - vec![ - Span::styled("▃▂▁", Style::default().fg(Color::Rgb(80, 80, 80))), - Span::styled(" OFF ", Style::default().fg(TEXT_MUTED).bg(Color::Rgb(50, 50, 50))), - Span::styled("", Style::default().fg(if is_selected { GREEN } else { Color::Rgb(60, 60, 60) })), - ] - } - }; - - // Pill-style option renderer - let pill = |label: &str, is_row_selected: bool, is_active: bool| -> Span<'static> { - if is_active { - Span::styled( - format!(" {} ", label), - Style::default() - .fg(BG_DARK) - .bg(if is_row_selected { GREEN } else { Color::Rgb(120, 120, 120) }) - .bold() - ) - } else { - Span::styled( - format!(" {} ", label), - Style::default() - .fg(if is_row_selected { TEXT_PRIMARY } else { Color::Rgb(80, 80, 80) }) - ) - } - }; - - // Field row helper with selection indicator - let field_row = |field_idx: usize, label: &str, current_selected: usize| -> (Span<'static>, Span<'static>) { - let is_selected = field_idx == current_selected; - let indicator = if is_selected { - Span::styled(" > ", Style::default().fg(GREEN).bold()) - } else { - Span::raw(" ") - }; - let label_span = Span::styled( - format!("{:<15}", label), - Style::default().fg(if is_selected { TEXT_PRIMARY } else { TEXT_MUTED }) - ); - (indicator, label_span) - }; - // Storage options let storage_opts = [ ("Default", storage_class.is_none()), @@ -3973,8 +3925,6 @@ fn draw_input_dialog(f: &mut Frame, mode: &InputMode) { ("ClientRequire", matches!(timestamping_mode, Some(TimestampingMode::ClientRequire))), ("Arrival", matches!(timestamping_mode, Some(TimestampingMode::Arrival))), ]; - - // Retention options let ret_opts = [ ("Infinite", *retention_policy == RetentionPolicyOption::Infinite), ("Age-based", *retention_policy == RetentionPolicyOption::Age), @@ -3982,94 +3932,71 @@ fn draw_input_dialog(f: &mut Frame, mode: &InputMode) { let mut lines = vec![]; - // ═══════════════════════════════════════════════════════════════ - // STREAM NAME SECTION - // ═══════════════════════════════════════════════════════════════ + // Stream name section lines.push(Line::from("")); - lines.push(Line::from(vec![ - Span::styled(" Stream name ", Style::default().fg(CYAN).bold()), - Span::styled("─".repeat(34), Style::default().fg(Color::Rgb(50, 50, 50))), - ])); + lines.push(render_section_header("Stream name", 48)); lines.push(Line::from("")); // Show which basin this stream will be created in lines.push(Line::from(vec![ Span::raw(" "), - Span::styled("in basin: ", Style::default().fg(Color::Rgb(80, 80, 80))), + Span::styled("in basin: ", Style::default().fg(GRAY_600)), Span::styled(basin.to_string(), Style::default().fg(TEXT_SECONDARY)), ])); lines.push(Line::from("")); - // Stream Name - let (ind, lbl) = field_row(0, "Name", *selected); - let name_display = if name.is_empty() { - Span::styled("enter name...", Style::default().fg(Color::Rgb(80, 80, 80)).italic()) - } else { - Span::styled(name.clone(), Style::default().fg(GREEN)) - }; - let cursor_span = if *selected == 0 && *editing { - Span::styled(cursor, Style::default().fg(GREEN)) - } else { - Span::raw("") - }; - - lines.push(Line::from(vec![ind, lbl, Span::raw(" "), name_display, cursor_span])); + // Stream Name field + let (ind, lbl) = render_field_row(0, "Name", *selected); + let name_color = if name.is_empty() { GRAY_600 } else { GREEN }; + let mut name_spans = vec![ind, lbl, Span::raw(" ")]; + name_spans.extend(render_text_input(name, *selected == 0 && *editing, "enter name...", name_color)); + lines.push(Line::from(name_spans)); - // ═══════════════════════════════════════════════════════════════ - // STREAM CONFIGURATION SECTION - // ═══════════════════════════════════════════════════════════════ + // Stream configuration section lines.push(Line::from("")); - lines.push(Line::from(vec![ - Span::styled(" Stream configuration ", Style::default().fg(CYAN).bold()), - Span::styled("─".repeat(25), Style::default().fg(Color::Rgb(50, 50, 50))), - ])); + lines.push(render_section_header("Stream configuration", 48)); lines.push(Line::from("")); // Storage Class - let (ind, lbl) = field_row(1, "Storage", *selected); + let (ind, lbl) = render_field_row(1, "Storage", *selected); let mut storage_spans = vec![ind, lbl, Span::raw(" ")]; for (label, active) in &storage_opts { - storage_spans.push(pill(label, *selected == 1, *active)); + storage_spans.push(render_pill(label, *selected == 1, *active)); storage_spans.push(Span::raw(" ")); } lines.push(Line::from(storage_spans)); - // Retention lines.push(Line::from("")); - let (ind, lbl) = field_row(2, "Retention", *selected); + let (ind, lbl) = render_field_row(2, "Retention", *selected); let mut ret_spans = vec![ind, lbl, Span::raw(" ")]; for (label, active) in &ret_opts { - ret_spans.push(pill(label, *selected == 2, *active)); + ret_spans.push(render_pill(label, *selected == 2, *active)); ret_spans.push(Span::raw(" ")); } lines.push(Line::from(ret_spans)); - // Retention Age (conditional) if *retention_policy == RetentionPolicyOption::Age { - let (ind, lbl) = field_row(3, " Duration", *selected); - let age_cursor = if *selected == 3 && *editing { cursor } else { "" }; - lines.push(Line::from(vec![ - ind, lbl, Span::raw(" "), - Span::styled(retention_age_input.clone(), Style::default().fg(YELLOW)), - Span::styled(age_cursor, Style::default().fg(GREEN)), - Span::styled(" e.g. 7d, 30d, 1y", Style::default().fg(Color::Rgb(80, 80, 80)).italic()), - ])); + let (ind, lbl) = render_field_row(3, " Duration", *selected); + let mut duration_spans = vec![ind, lbl, Span::raw(" ")]; + duration_spans.extend(render_text_input(retention_age_input, *selected == 3 && *editing, "", YELLOW)); + duration_spans.push(Span::styled(" e.g. 7d, 30d, 1y", Style::default().fg(GRAY_600).italic())); + lines.push(Line::from(duration_spans)); } // Timestamping Mode lines.push(Line::from("")); - let (ind, lbl) = field_row(4, "Timestamps", *selected); + let (ind, lbl) = render_field_row(4, "Timestamps", *selected); let mut ts_spans = vec![ind, lbl, Span::raw(" ")]; for (label, active) in &ts_opts { - ts_spans.push(pill(label, *selected == 4, *active)); + ts_spans.push(render_pill(label, *selected == 4, *active)); ts_spans.push(Span::raw(" ")); } lines.push(Line::from(ts_spans)); // Uncapped Timestamps - let (ind, lbl) = field_row(5, " Uncapped", *selected); + let (ind, lbl) = render_field_row(5, " Uncapped", *selected); let mut uncapped_spans = vec![ind, lbl, Span::raw(" ")]; - uncapped_spans.extend(toggle(*timestamping_uncapped, *selected == 5)); + uncapped_spans.extend(render_toggle(*timestamping_uncapped, *selected == 5)); lines.push(Line::from(uncapped_spans)); // Delete on Empty @@ -4078,66 +4005,45 @@ fn draw_input_dialog(f: &mut Frame, mode: &InputMode) { ("After threshold", *delete_on_empty_enabled), ]; lines.push(Line::from("")); - let (ind, lbl) = field_row(6, "Delete on empty", *selected); + let (ind, lbl) = render_field_row(6, "Delete on empty", *selected); let mut del_spans = vec![ind, lbl, Span::raw(" ")]; for (label, active) in &delete_opts { - del_spans.push(pill(label, *selected == 6, *active)); + del_spans.push(render_pill(label, *selected == 6, *active)); del_spans.push(Span::raw(" ")); } lines.push(Line::from(del_spans)); // Delete on Empty Threshold (conditional) if *delete_on_empty_enabled { - let (ind, lbl) = field_row(7, " Threshold", *selected); - let age_cursor = if *selected == 7 && *editing { cursor } else { "" }; - lines.push(Line::from(vec![ - ind, lbl, Span::raw(" "), - Span::styled(delete_on_empty_min_age.clone(), Style::default().fg(YELLOW)), - Span::styled(age_cursor, Style::default().fg(GREEN)), - Span::styled(" e.g. 1h, 7d", Style::default().fg(Color::Rgb(80, 80, 80)).italic()), - ])); + let (ind, lbl) = render_field_row(7, " Threshold", *selected); + let mut threshold_spans = vec![ind, lbl, Span::raw(" ")]; + threshold_spans.extend(render_text_input(delete_on_empty_min_age, *selected == 7 && *editing, "", YELLOW)); + threshold_spans.push(Span::styled(" e.g. 1h, 7d", Style::default().fg(GRAY_600).italic())); + lines.push(Line::from(threshold_spans)); } - // ═══════════════════════════════════════════════════════════════ - // CREATE BUTTON - // ═══════════════════════════════════════════════════════════════ + // Create button section lines.push(Line::from("")); lines.push(Line::from(vec![ - Span::styled("─".repeat(52), Style::default().fg(Color::Rgb(50, 50, 50))), + Span::styled("─".repeat(52), Style::default().fg(GRAY_750)), ])); lines.push(Line::from("")); let can_create = !name.is_empty(); - let btn_style = if *selected == 8 && can_create { - Style::default().fg(BG_DARK).bg(GREEN).bold() - } else if can_create { - Style::default().fg(GREEN).bold() - } else { - Style::default().fg(Color::Rgb(80, 80, 80)) - }; - - let btn_indicator = if *selected == 8 { - Span::styled(" > ", Style::default().fg(GREEN).bold()) - } else { - Span::raw(" ") - }; - - lines.push(Line::from(vec![ - btn_indicator, - Span::styled(" CREATE STREAM ", btn_style), - if !can_create { - Span::styled(" (enter stream name)", Style::default().fg(Color::Rgb(80, 80, 80)).italic()) - } else { - Span::raw("") - }, - ])); + lines.push(render_button("CREATE STREAM", *selected == 8, can_create, GREEN)); + if !can_create { + lines.push(Line::from(vec![ + Span::raw(" "), + Span::styled("(enter stream name)", Style::default().fg(GRAY_600).italic()), + ])); + } lines.push(Line::from("")); ( " Create Stream ", lines, - "j/k navigate | h/l cycle | Space toggle | Enter edit | Esc cancel", + "j/k navigate · h/l cycle · Space toggle · Enter edit · Esc cancel", ) } @@ -4196,53 +4102,6 @@ fn draw_input_dialog(f: &mut Frame, mode: &InputMode) { editing_age, age_input, } => { - let cursor = "▎"; - - // Modern toggle switch rendering - let toggle = |on: bool, is_selected: bool| -> Vec> { - if on { - vec![ - Span::styled("", Style::default().fg(if is_selected { GREEN } else { Color::Rgb(60, 60, 60) })), - Span::styled(" ON ", Style::default().fg(BG_DARK).bg(GREEN).bold()), - Span::styled("", Style::default().fg(GREEN)), - ] - } else { - vec![ - Span::styled("", Style::default().fg(if is_selected { TEXT_MUTED } else { Color::Rgb(60, 60, 60) })), - Span::styled(" OFF ", Style::default().fg(TEXT_MUTED).bg(Color::Rgb(60, 60, 60))), - Span::styled("", Style::default().fg(Color::Rgb(60, 60, 60))), - ] - } - }; - - // Pill-style selector - let pill = |label: &str, is_selected: bool, is_active: bool| -> Span<'static> { - let label = label.to_string(); - if is_active { - Span::styled(format!(" {} ", label), Style::default().fg(BG_DARK).bg(GREEN).bold()) - } else if is_selected { - Span::styled(format!(" {} ", label), Style::default().fg(TEXT_PRIMARY).bg(Color::Rgb(50, 50, 50))) - } else { - Span::styled(format!(" {} ", label), Style::default().fg(TEXT_MUTED)) - } - }; - - // Field row helper - let field_row = |idx: usize, label: &str, sel: usize| -> (Span<'static>, Span<'static>) { - let is_sel = sel == idx; - let indicator = if is_sel { - Span::styled(" > ", Style::default().fg(GREEN).bold()) - } else { - Span::styled(" ", Style::default()) - }; - let label_style = if is_sel { - Style::default().fg(TEXT_PRIMARY).bold() - } else { - Style::default().fg(TEXT_MUTED) - }; - (indicator, Span::styled(label.to_string(), label_style)) - }; - // Options let storage_opts = [ ("Default", storage_class.is_none()), @@ -4271,79 +4130,68 @@ fn draw_input_dialog(f: &mut Frame, mode: &InputMode) { // Default stream configuration section lines.push(Line::from("")); - lines.push(Line::from(vec![ - Span::styled(" Default stream configuration ", Style::default().fg(CYAN).bold()), - Span::styled("─".repeat(17), Style::default().fg(Color::Rgb(50, 50, 50))), - ])); + lines.push(render_section_header("Default stream configuration", 48)); lines.push(Line::from("")); // Storage Class - let (ind, lbl) = field_row(0, "Storage", *selected); + let (ind, lbl) = render_field_row_bold(0, "Storage", *selected); let mut storage_spans = vec![ind, lbl, Span::raw(" ")]; for (label, active) in &storage_opts { - storage_spans.push(pill(label, *selected == 0, *active)); + storage_spans.push(render_pill(label, *selected == 0, *active)); storage_spans.push(Span::raw(" ")); } lines.push(Line::from(storage_spans)); - // Retention lines.push(Line::from("")); - let (ind, lbl) = field_row(1, "Retention", *selected); + let (ind, lbl) = render_field_row_bold(1, "Retention", *selected); let mut ret_spans = vec![ind, lbl, Span::raw(" ")]; for (label, active) in &ret_opts { - ret_spans.push(pill(label, *selected == 1, *active)); + ret_spans.push(render_pill(label, *selected == 1, *active)); ret_spans.push(Span::raw(" ")); } lines.push(Line::from(ret_spans)); - // Retention Age (conditional) if *retention_policy == RetentionPolicyOption::Age { - let (ind, lbl) = field_row(2, " Duration", *selected); - let age_cursor = if *selected == 2 && *editing_age { cursor } else { "" }; + let (ind, lbl) = render_field_row_bold(2, " Duration", *selected); let age_display = if *editing_age { age_input.clone() } else { format!("{}s", retention_age_secs) }; - lines.push(Line::from(vec![ - ind, lbl, Span::raw(" "), - Span::styled(age_display, Style::default().fg(YELLOW)), - Span::styled(age_cursor, Style::default().fg(GREEN)), - Span::styled(" e.g. 604800 (7 days)", Style::default().fg(Color::Rgb(80, 80, 80)).italic()), - ])); + let mut duration_spans = vec![ind, lbl, Span::raw(" ")]; + duration_spans.extend(render_text_input(&age_display, *selected == 2 && *editing_age, "", YELLOW)); + duration_spans.push(Span::styled(" e.g. 604800 (7 days)", Style::default().fg(GRAY_600).italic())); + lines.push(Line::from(duration_spans)); } // Timestamping Mode lines.push(Line::from("")); - let (ind, lbl) = field_row(3, "Timestamps", *selected); + let (ind, lbl) = render_field_row_bold(3, "Timestamps", *selected); let mut ts_spans = vec![ind, lbl, Span::raw(" ")]; for (label, active) in &ts_opts { - ts_spans.push(pill(label, *selected == 3, *active)); + ts_spans.push(render_pill(label, *selected == 3, *active)); ts_spans.push(Span::raw(" ")); } lines.push(Line::from(ts_spans)); // Uncapped Timestamps - let (ind, lbl) = field_row(4, " Uncapped", *selected); + let (ind, lbl) = render_field_row_bold(4, " Uncapped", *selected); let mut uncapped_spans = vec![ind, lbl, Span::raw(" ")]; - uncapped_spans.extend(toggle(timestamping_uncapped.unwrap_or(false), *selected == 4)); + uncapped_spans.extend(render_toggle(timestamping_uncapped.unwrap_or(false), *selected == 4)); lines.push(Line::from(uncapped_spans)); // Create streams automatically section lines.push(Line::from("")); - lines.push(Line::from(vec![ - Span::styled(" Create streams automatically ", Style::default().fg(CYAN).bold()), - Span::styled("─".repeat(17), Style::default().fg(Color::Rgb(50, 50, 50))), - ])); + lines.push(render_section_header("Create streams automatically", 48)); lines.push(Line::from("")); // On Append - let (ind, lbl) = field_row(5, "On append", *selected); + let (ind, lbl) = render_field_row_bold(5, "On append", *selected); let mut append_spans = vec![ind, lbl, Span::raw(" ")]; - append_spans.extend(toggle(create_stream_on_append.unwrap_or(false), *selected == 5)); + append_spans.extend(render_toggle(create_stream_on_append.unwrap_or(false), *selected == 5)); lines.push(Line::from(append_spans)); // On Read lines.push(Line::from("")); - let (ind, lbl) = field_row(6, "On read", *selected); + let (ind, lbl) = render_field_row_bold(6, "On read", *selected); let mut read_spans = vec![ind, lbl, Span::raw(" ")]; - read_spans.extend(toggle(create_stream_on_read.unwrap_or(false), *selected == 6)); + read_spans.extend(render_toggle(create_stream_on_read.unwrap_or(false), *selected == 6)); lines.push(Line::from(read_spans)); lines.push(Line::from("")); @@ -4351,7 +4199,7 @@ fn draw_input_dialog(f: &mut Frame, mode: &InputMode) { ( " Reconfigure Basin ", lines, - "j/k navigate | h/l cycle | Space toggle | Enter edit | s save | Esc cancel", + "j/k navigate · h/l cycle · Space toggle · Enter edit · s save · Esc cancel", ) } @@ -4369,60 +4217,6 @@ fn draw_input_dialog(f: &mut Frame, mode: &InputMode) { editing_age, age_input, } => { - let cursor = "▎"; - - // Modern toggle switch rendering - let toggle = |on: bool, is_selected: bool| -> Vec> { - if on { - vec![ - Span::styled("", Style::default().fg(if is_selected { GREEN } else { Color::Rgb(60, 60, 60) })), - Span::styled(" ON ", Style::default().fg(BG_DARK).bg(GREEN).bold()), - Span::styled("▁▂▃", Style::default().fg(GREEN)), - ] - } else { - vec![ - Span::styled("▃▂▁", Style::default().fg(Color::Rgb(80, 80, 80))), - Span::styled(" OFF ", Style::default().fg(TEXT_MUTED).bg(Color::Rgb(50, 50, 50))), - Span::styled("", Style::default().fg(if is_selected { GREEN } else { Color::Rgb(60, 60, 60) })), - ] - } - }; - - // Pill-style selector - let pill = |label: &str, is_row_selected: bool, is_active: bool| -> Span<'static> { - if is_active { - Span::styled( - format!(" {} ", label), - Style::default() - .fg(BG_DARK) - .bg(if is_row_selected { GREEN } else { Color::Rgb(120, 120, 120) }) - .bold() - ) - } else { - Span::styled( - format!(" {} ", label), - Style::default() - .fg(if is_row_selected { TEXT_PRIMARY } else { Color::Rgb(80, 80, 80) }) - ) - } - }; - - // Field row helper - let field_row = |idx: usize, label: &str, sel: usize| -> (Span<'static>, Span<'static>) { - let is_sel = sel == idx; - let indicator = if is_sel { - Span::styled(" > ", Style::default().fg(GREEN).bold()) - } else { - Span::styled(" ", Style::default()) - }; - let label_style = if is_sel { - Style::default().fg(TEXT_PRIMARY) - } else { - Style::default().fg(TEXT_MUTED) - }; - (indicator, Span::styled(format!("{:<15}", label), label_style)) - }; - // Options let storage_opts = [ ("Default", storage_class.is_none()), @@ -4451,58 +4245,50 @@ fn draw_input_dialog(f: &mut Frame, mode: &InputMode) { // Stream configuration section lines.push(Line::from("")); - lines.push(Line::from(vec![ - Span::styled(" Stream configuration ", Style::default().fg(CYAN).bold()), - Span::styled("─".repeat(25), Style::default().fg(Color::Rgb(50, 50, 50))), - ])); + lines.push(render_section_header("Stream configuration", 48)); lines.push(Line::from("")); // Storage Class - let (ind, lbl) = field_row(0, "Storage", *selected); + let (ind, lbl) = render_field_row(0, "Storage", *selected); let mut storage_spans = vec![ind, lbl, Span::raw(" ")]; for (label, active) in &storage_opts { - storage_spans.push(pill(label, *selected == 0, *active)); + storage_spans.push(render_pill(label, *selected == 0, *active)); storage_spans.push(Span::raw(" ")); } lines.push(Line::from(storage_spans)); - // Retention lines.push(Line::from("")); - let (ind, lbl) = field_row(1, "Retention", *selected); + let (ind, lbl) = render_field_row(1, "Retention", *selected); let mut ret_spans = vec![ind, lbl, Span::raw(" ")]; for (label, active) in &ret_opts { - ret_spans.push(pill(label, *selected == 1, *active)); + ret_spans.push(render_pill(label, *selected == 1, *active)); ret_spans.push(Span::raw(" ")); } lines.push(Line::from(ret_spans)); - // Retention Age (conditional) if *retention_policy == RetentionPolicyOption::Age { - let (ind, lbl) = field_row(2, " Duration", *selected); - let age_cursor = if *selected == 2 && *editing_age { cursor } else { "" }; + let (ind, lbl) = render_field_row(2, " Duration", *selected); let age_display = if *selected == 2 && *editing_age { age_input.clone() } else { format!("{}s", retention_age_secs) }; - lines.push(Line::from(vec![ - ind, lbl, Span::raw(" "), - Span::styled(age_display, Style::default().fg(YELLOW)), - Span::styled(age_cursor, Style::default().fg(GREEN)), - Span::styled(" e.g. 604800 (7 days)", Style::default().fg(Color::Rgb(80, 80, 80)).italic()), - ])); + let mut duration_spans = vec![ind, lbl, Span::raw(" ")]; + duration_spans.extend(render_text_input(&age_display, *selected == 2 && *editing_age, "", YELLOW)); + duration_spans.push(Span::styled(" e.g. 604800 (7 days)", Style::default().fg(GRAY_600).italic())); + lines.push(Line::from(duration_spans)); } // Timestamping Mode lines.push(Line::from("")); - let (ind, lbl) = field_row(3, "Timestamps", *selected); + let (ind, lbl) = render_field_row(3, "Timestamps", *selected); let mut ts_spans = vec![ind, lbl, Span::raw(" ")]; for (label, active) in &ts_opts { - ts_spans.push(pill(label, *selected == 3, *active)); + ts_spans.push(render_pill(label, *selected == 3, *active)); ts_spans.push(Span::raw(" ")); } lines.push(Line::from(ts_spans)); // Uncapped Timestamps - let (ind, lbl) = field_row(4, " Uncapped", *selected); + let (ind, lbl) = render_field_row(4, " Uncapped", *selected); let mut uncapped_spans = vec![ind, lbl, Span::raw(" ")]; - uncapped_spans.extend(toggle(timestamping_uncapped.unwrap_or(false), *selected == 4)); + uncapped_spans.extend(render_toggle(timestamping_uncapped.unwrap_or(false), *selected == 4)); lines.push(Line::from(uncapped_spans)); // Delete on Empty @@ -4511,24 +4297,21 @@ fn draw_input_dialog(f: &mut Frame, mode: &InputMode) { ("After threshold", *delete_on_empty_enabled), ]; lines.push(Line::from("")); - let (ind, lbl) = field_row(5, "Delete on empty", *selected); + let (ind, lbl) = render_field_row(5, "Delete on empty", *selected); let mut del_spans = vec![ind, lbl, Span::raw(" ")]; for (label, active) in &delete_opts { - del_spans.push(pill(label, *selected == 5, *active)); + del_spans.push(render_pill(label, *selected == 5, *active)); del_spans.push(Span::raw(" ")); } lines.push(Line::from(del_spans)); // Delete on Empty Threshold (conditional) if *delete_on_empty_enabled { - let (ind, lbl) = field_row(6, " Threshold", *selected); - let age_cursor = if *selected == 6 && *editing_age { cursor } else { "" }; - lines.push(Line::from(vec![ - ind, lbl, Span::raw(" "), - Span::styled(delete_on_empty_min_age.clone(), Style::default().fg(YELLOW)), - Span::styled(age_cursor, Style::default().fg(GREEN)), - Span::styled(" e.g. 1h, 7d", Style::default().fg(Color::Rgb(80, 80, 80)).italic()), - ])); + let (ind, lbl) = render_field_row(6, " Threshold", *selected); + let mut threshold_spans = vec![ind, lbl, Span::raw(" ")]; + threshold_spans.extend(render_text_input(delete_on_empty_min_age, *selected == 6 && *editing_age, "", YELLOW)); + threshold_spans.push(Span::styled(" e.g. 1h, 7d", Style::default().fg(GRAY_600).italic())); + lines.push(Line::from(threshold_spans)); } lines.push(Line::from("")); @@ -4536,7 +4319,7 @@ fn draw_input_dialog(f: &mut Frame, mode: &InputMode) { ( " Reconfigure Stream ", lines, - "j/k navigate | h/l cycle | Space toggle | Enter edit | s save | Esc cancel", + "j/k navigate · h/l cycle · Space toggle · Enter edit · s save · Esc cancel", ) } @@ -4935,8 +4718,6 @@ fn draw_input_dialog(f: &mut Frame, mode: &InputMode) { ), ])); } - - // Resources section header lines.push(Line::from("")); lines.push(Line::from(Span::styled("── Resources ──", Style::default().fg(TEXT_MUTED)))); @@ -5275,7 +5056,7 @@ fn draw_bench_config(f: &mut Frame, area: Rect, state: &BenchViewState) { .direction(Direction::Vertical) .constraints([ Constraint::Length(3), // Title - Constraint::Length(3), // Record size + Constraint::Length(3), Constraint::Length(3), // Target MiB/s Constraint::Length(3), // Duration Constraint::Length(3), // Catchup delay @@ -5311,8 +5092,6 @@ fn draw_bench_config(f: &mut Frame, area: Rect, state: &BenchViewState) { ]); f.render_widget(Paragraph::new(line), area); }; - - // Record size let record_size_str = if state.editing && state.config_field == BenchConfigField::RecordSize { format!("{}_", state.edit_buffer) } else { @@ -5391,7 +5170,7 @@ fn draw_bench_running(f: &mut Frame, area: Rect, state: &BenchViewState) { .constraints([ Constraint::Length(3), // Progress bar Constraint::Length(5), // Write stats - Constraint::Length(5), // Read stats + Constraint::Length(5), Constraint::Length(5), // Catchup stats (or waiting) Constraint::Min(3), // Latency stats or chart ]) @@ -5426,14 +5205,16 @@ fn draw_bench_running(f: &mut Frame, area: Rect, state: &BenchViewState) { let filled = ((progress_pct / 100.0) * bar_width as f64) as usize; let empty = bar_width.saturating_sub(filled); + let time_display = if state.phase == BenchPhase::Write { + format!(" {:.1}s / {}s", state.elapsed_secs.min(state.duration_secs as f64), state.duration_secs) + } else { + format!(" {:.1}s", state.elapsed_secs) + }; let progress_line = Line::from(vec![ Span::styled(format!("{:>5.1}% ", progress_pct), Style::default().fg(TEXT_PRIMARY)), Span::styled("█".repeat(filled), Style::default().fg(GREEN)), Span::styled("░".repeat(empty), Style::default().fg(TEXT_MUTED)), - Span::styled( - format!(" {:.1}s / {}s", state.elapsed_secs, state.duration_secs), - Style::default().fg(TEXT_SECONDARY), - ), + Span::styled(time_display, Style::default().fg(TEXT_SECONDARY)), ]); f.render_widget(Paragraph::new(progress_line), progress_inner); @@ -5449,8 +5230,6 @@ fn draw_bench_running(f: &mut Frame, area: Rect, state: &BenchViewState) { state.write_records, &state.write_history, ); - - // Read stats draw_bench_stat_box( f, chunks[2], @@ -5603,8 +5382,6 @@ fn draw_throughput_sparklines(f: &mut Frame, area: Rect, write_history: &[f64], .style(Style::default().fg(BLUE)); f.render_widget(write_spark, chunks[0]); } - - // Read sparkline if !read_history.is_empty() { let read_data: Vec = read_history.iter().map(|v| (*v * 100.0) as u64).collect(); let read_spark = ratatui::widgets::Sparkline::default() @@ -5685,6 +5462,70 @@ fn draw_latency_box( f.render_widget(Paragraph::new(lines), inner); } +fn draw_tail_sparklines(f: &mut Frame, area: Rect, throughput_history: &[f64], records_history: &[f64]) { + let chunks = Layout::default() + .direction(Direction::Horizontal) + .constraints([ + Constraint::Length(2), + Constraint::Percentage(50), + Constraint::Percentage(50), + Constraint::Length(2), + ]) + .split(area); + + // Throughput sparkline (MiB/s) + if !throughput_history.is_empty() { + let max_val = throughput_history.iter().cloned().fold(0.1_f64, f64::max); + let data: Vec = throughput_history + .iter() + .map(|v| ((v / max_val) * 100.0) as u64) + .collect(); + + let current = throughput_history.last().copied().unwrap_or(0.0); + let block = Block::default() + .title(Line::from(vec![ + Span::styled(" ▲ ", Style::default().fg(CYAN)), + Span::styled(format!("{:.2} MiB/s ", current), Style::default().fg(CYAN).bold()), + ])) + .borders(Borders::ALL) + .border_style(Style::default().fg(BORDER)); + + let inner = block.inner(chunks[1]); + f.render_widget(block, chunks[1]); + + let sparkline = ratatui::widgets::Sparkline::default() + .data(&data) + .style(Style::default().fg(CYAN)); + f.render_widget(sparkline, inner); + } + + // Records/s sparkline + if !records_history.is_empty() { + let max_val = records_history.iter().cloned().fold(1.0_f64, f64::max); + let data: Vec = records_history + .iter() + .map(|v| ((v / max_val) * 100.0) as u64) + .collect(); + + let current = records_history.last().copied().unwrap_or(0.0); + let block = Block::default() + .title(Line::from(vec![ + Span::styled(" ◆ ", Style::default().fg(GREEN)), + Span::styled(format!("{:.0} rec/s ", current), Style::default().fg(GREEN).bold()), + ])) + .borders(Borders::ALL) + .border_style(Style::default().fg(BORDER)); + + let inner = block.inner(chunks[2]); + f.render_widget(block, chunks[2]); + + let sparkline = ratatui::widgets::Sparkline::default() + .data(&data) + .style(Style::default().fg(GREEN)); + f.render_widget(sparkline, inner); + } +} + fn format_number(n: u64) -> String { if n >= 1_000_000_000 { format!("{:.2}B", n as f64 / 1_000_000_000.0) @@ -5696,3 +5537,141 @@ fn format_number(n: u64) -> String { n.to_string() } } + +/// Draw Picture-in-Picture overlay in bottom-right corner +fn draw_pip(f: &mut Frame, pip: &PipState) { + let area = f.area(); + + // PiP window size: 40 chars wide, 12 lines tall + let pip_width = 44.min(area.width.saturating_sub(4)); + let pip_height = 14.min(area.height.saturating_sub(4)); + + // Position in bottom-right corner with some margin + let pip_area = Rect::new( + area.width.saturating_sub(pip_width + 2), + area.height.saturating_sub(pip_height + 2), + pip_width, + pip_height, + ); + + // Clear background + f.render_widget(Clear, pip_area); + + // Create title with stream info + let title = format!(" {}/{} ", pip.basin_name, pip.stream_name); + let status = if pip.paused { " PAUSED " } else { " LIVE " }; + let status_color = if pip.paused { WARNING } else { SUCCESS }; + + let block = Block::default() + .title(Line::from(vec![ + Span::styled(&title, Style::default().fg(CYAN).bold()), + Span::styled(status, Style::default().fg(BG_DARK).bg(status_color).bold()), + ])) + .title_bottom(Line::from(vec![ + Span::styled(" P", Style::default().fg(TEXT_MUTED)), + Span::styled("=close ", Style::default().fg(TEXT_MUTED)), + ])) + .borders(Borders::ALL) + .border_style(Style::default().fg(CYAN)) + .style(Style::default().bg(BG_PANEL)); + + let inner = block.inner(pip_area); + f.render_widget(block, pip_area); + + // Split inner area: header line + records list + let chunks = Layout::default() + .direction(Direction::Vertical) + .constraints([ + Constraint::Length(1), // Stats line + Constraint::Min(1), // Records + ]) + .split(inner); + + // Stats line + let stats = if pip.current_mibps > 0.0 { + format!( + "{:.1} MiB/s {:.0} rec/s {} records", + pip.current_mibps, + pip.current_recps, + pip.records.len() + ) + } else { + format!("{} records", pip.records.len()) + }; + let stats_para = Paragraph::new(Span::styled(&stats, Style::default().fg(TEXT_MUTED))); + f.render_widget(stats_para, chunks[0]); + + // Records list (show last N that fit) + let visible_height = chunks[1].height as usize; + let records_to_show: Vec<_> = pip.records.iter() + .rev() + .take(visible_height) + .collect::>() + .into_iter() + .rev() + .collect(); + + if records_to_show.is_empty() { + let waiting = Paragraph::new(Span::styled( + "Waiting for records...", + Style::default().fg(TEXT_MUTED).italic(), + )).alignment(Alignment::Center); + f.render_widget(waiting, chunks[1]); + } else { + let items: Vec = records_to_show.iter().map(|record| { + let seq = format!("#{:<6}", record.seq_num); + let body_preview: String = String::from_utf8_lossy(&record.body) + .chars() + .take(28) + .filter(|c| !c.is_control()) + .collect(); + + ListItem::new(Line::from(vec![ + Span::styled(seq, Style::default().fg(TEXT_MUTED)), + Span::styled(" ", Style::default()), + Span::styled(body_preview, Style::default().fg(TEXT_PRIMARY)), + ])) + }).collect(); + + let list = List::new(items); + f.render_widget(list, chunks[1]); + } +} + +/// Draw minimized PiP indicator in bottom-right corner +fn draw_pip_minimized(f: &mut Frame, pip: &PipState) { + let area = f.area(); + + // Small indicator: just shows stream name and record count + let indicator_width = 24.min(area.width.saturating_sub(4)); + let indicator_height = 3; + + let indicator_area = Rect::new( + area.width.saturating_sub(indicator_width + 2), + area.height.saturating_sub(indicator_height + 2), + indicator_width, + indicator_height, + ); + + f.render_widget(Clear, indicator_area); + + let status_char = if pip.paused { "⏸" } else { "●" }; + let status_color = if pip.paused { WARNING } else { SUCCESS }; + + let block = Block::default() + .borders(Borders::ALL) + .border_style(Style::default().fg(BORDER)) + .style(Style::default().bg(BG_PANEL)); + + let para = Paragraph::new(Line::from(vec![ + Span::styled(status_char, Style::default().fg(status_color)), + Span::styled(" ", Style::default()), + Span::styled( + format!("{} ({})", pip.stream_name, pip.records.len()), + Style::default().fg(TEXT_SECONDARY), + ), + ])) + .block(block); + + f.render_widget(para, indicator_area); +} From 855c118f552728472e1b0ba4bd371a1ec3e809f2 Mon Sep 17 00:00:00 2001 From: Mehul Arora Date: Mon, 26 Jan 2026 12:24:39 -0500 Subject: [PATCH 18/31] / --- src/tui/app.rs | 125 ++++++++--- src/tui/mod.rs | 6 +- src/tui/ui.rs | 594 +++++++++++++++++++++++-------------------------- 3 files changed, 378 insertions(+), 347 deletions(-) diff --git a/src/tui/app.rs b/src/tui/app.rs index dc8875b..2536a5b 100644 --- a/src/tui/app.rs +++ b/src/tui/app.rs @@ -1,4 +1,6 @@ use std::collections::VecDeque; +use std::sync::atomic::{AtomicBool, Ordering}; +use std::sync::Arc; use std::time::Duration; use chrono::{Datelike, NaiveDate}; @@ -469,7 +471,6 @@ pub struct BenchViewState { pub stream_name: Option, pub phase: BenchPhase, pub running: bool, - pub paused: bool, pub stopping: bool, pub elapsed_secs: f64, pub progress_pct: f64, @@ -507,7 +508,6 @@ impl BenchViewState { stream_name: None, phase: BenchPhase::Write, running: false, - paused: false, stopping: false, elapsed_secs: 0.0, progress_pct: 0.0, @@ -925,6 +925,8 @@ pub struct App { pub input_mode: InputMode, pub pip: Option, should_quit: bool, + /// Stop signal for the benchmark task + bench_stop_signal: Option>, } /// Build a basin config from form values @@ -1034,6 +1036,7 @@ impl App { input_mode: InputMode::Normal, pip: None, should_quit: false, + bench_stop_signal: None, } } @@ -6224,23 +6227,20 @@ impl App { return; }; - // If running, only allow stop/pause + // If running, only allow stop if state.running { match key.code { KeyCode::Esc | KeyCode::Char('q') => { state.stopping = true; + // Signal the benchmark task to stop + if let Some(stop_signal) = &self.bench_stop_signal { + stop_signal.store(true, Ordering::Relaxed); + } self.message = Some(StatusMessage { text: "Stopping benchmark...".to_string(), level: MessageLevel::Info, }); } - KeyCode::Char(' ') => { - state.paused = !state.paused; - self.message = Some(StatusMessage { - text: if state.paused { "Paused".to_string() } else { "Resumed".to_string() }, - level: MessageLevel::Info, - }); - } _ => {} } return; @@ -6273,26 +6273,42 @@ impl App { } KeyCode::Enter => { // Apply the edit - if let Ok(val) = state.edit_buffer.parse::() { - match state.config_field { - BenchConfigField::RecordSize => { - let val = val.clamp(128, 1024 * 1024) as u32; - state.record_size = val; - } - BenchConfigField::TargetMibps => { - state.target_mibps = val.max(1); - } - BenchConfigField::Duration => { - state.duration_secs = val.max(1); - } - BenchConfigField::CatchupDelay => { - state.catchup_delay_secs = val; + match state.edit_buffer.parse::() { + Ok(val) if val > 0 => { + match state.config_field { + BenchConfigField::RecordSize => { + let val = val.clamp(128, 1024 * 1024) as u32; + state.record_size = val; + } + BenchConfigField::TargetMibps => { + state.target_mibps = val.clamp(1, 100); + } + BenchConfigField::Duration => { + state.duration_secs = val.clamp(10, 600); + } + BenchConfigField::CatchupDelay => { + state.catchup_delay_secs = val.min(120); + } + BenchConfigField::Start => {} } - BenchConfigField::Start => {} + state.editing = false; + state.edit_buffer.clear(); + } + Ok(_) => { + // Value is 0, show error + self.message = Some(StatusMessage { + text: "Value must be greater than 0".to_string(), + level: MessageLevel::Error, + }); + } + Err(_) => { + // Invalid number, show error + self.message = Some(StatusMessage { + text: "Invalid number".to_string(), + level: MessageLevel::Error, + }); } } - state.editing = false; - state.edit_buffer.clear(); } KeyCode::Char(c) if c.is_ascii_digit() => { state.edit_buffer.push(c); @@ -6380,7 +6396,7 @@ impl App { } fn start_benchmark( - &self, + &mut self, basin_name: BasinName, record_size: u32, target_mibps: u64, @@ -6395,6 +6411,10 @@ impl App { return; }; + // Create a stop signal that can be triggered from the UI + let user_stop = Arc::new(AtomicBool::new(false)); + self.bench_stop_signal = Some(user_stop.clone()); + tokio::spawn(async move { use s2_sdk::types::{CreateStreamInput, DeleteStreamInput, StreamName, StreamConfig as SdkStreamConfig, RetentionPolicy, TimestampingConfig, TimestampingMode, DeleteOnEmptyConfig}; use std::num::NonZeroU64; @@ -6439,6 +6459,7 @@ impl App { NonZeroU64::new(target_mibps).unwrap_or(NonZeroU64::new(1).unwrap()), Duration::from_secs(duration_secs), Duration::from_secs(catchup_delay_secs), + user_stop, tx.clone(), ) .await; @@ -6458,20 +6479,21 @@ async fn run_bench_with_events( target_mibps: std::num::NonZeroU64, duration: std::time::Duration, catchup_delay: std::time::Duration, + user_stop: Arc, tx: mpsc::UnboundedSender, ) -> Result { use crate::bench::*; use crate::types::LatencyStats; use futures::StreamExt; - use std::sync::atomic::{AtomicBool, AtomicU64, Ordering}; - use std::sync::Arc; + use std::sync::atomic::{AtomicU64, Ordering}; use std::time::Duration; use tokio::time::Instant; const WRITE_DONE_SENTINEL: u64 = u64::MAX; let bench_start = Instant::now(); - let stop = Arc::new(AtomicBool::new(false)); + // Use the user_stop signal to control the benchmark - this allows the UI to stop it + let stop = user_stop; let write_done_records = Arc::new(AtomicU64::new(WRITE_DONE_SENTINEL)); // We need to re-implement the bench logic here to send events @@ -6598,13 +6620,50 @@ async fn run_bench_with_events( let _ = write_handle.await; let _ = read_handle.await; - // Catchup phase + // Check if user stopped before starting catchup + if stop.load(Ordering::Relaxed) { + return Ok(BenchFinalStats { + ack_latency: if all_ack_latencies.is_empty() { + None + } else { + Some(LatencyStats::compute(all_ack_latencies)) + }, + e2e_latency: if all_e2e_latencies.is_empty() { + None + } else { + Some(LatencyStats::compute(all_e2e_latencies)) + }, + }); + } + + // Catchup phase - wait with periodic stop checks let _ = tx.send(Event::BenchPhaseComplete(BenchPhase::CatchupWait)); - tokio::time::sleep(catchup_delay).await; + let catchup_wait_start = Instant::now(); + while catchup_wait_start.elapsed() < catchup_delay { + if stop.load(Ordering::Relaxed) { + return Ok(BenchFinalStats { + ack_latency: if all_ack_latencies.is_empty() { + None + } else { + Some(LatencyStats::compute(all_ack_latencies)) + }, + e2e_latency: if all_e2e_latencies.is_empty() { + None + } else { + Some(LatencyStats::compute(all_e2e_latencies)) + }, + }); + } + tokio::time::sleep(Duration::from_millis(100)).await; + } let catchup_stream = bench_read_catchup(stream.clone(), record_size, bench_start); let mut catchup_stream = std::pin::pin!(catchup_stream); while let Some(result) = catchup_stream.next().await { + // Check stop signal during catchup + if stop.load(Ordering::Relaxed) { + break; + } match result { Ok(sample) => { let mibps = sample.bytes as f64 / (1024.0 * 1024.0) / sample.elapsed.as_secs_f64().max(0.001); diff --git a/src/tui/mod.rs b/src/tui/mod.rs index 3062c34..3a90470 100644 --- a/src/tui/mod.rs +++ b/src/tui/mod.rs @@ -7,7 +7,6 @@ mod widgets; use std::io; use crossterm::{ - event::{DisableMouseCapture, EnableMouseCapture}, execute, terminal::{EnterAlternateScreen, LeaveAlternateScreen, disable_raw_mode, enable_raw_mode}, }; @@ -29,7 +28,7 @@ pub async fn run() -> Result<(), CliError> { // Setup terminal enable_raw_mode().map_err(|e| CliError::RecordReaderInit(format!("terminal setup: {e}")))?; let mut stdout = io::stdout(); - execute!(stdout, EnterAlternateScreen, EnableMouseCapture) + execute!(stdout, EnterAlternateScreen) .map_err(|e| CliError::RecordReaderInit(format!("terminal setup: {e}")))?; let backend = CrosstermBackend::new(stdout); let mut terminal = Terminal::new(backend) @@ -49,8 +48,7 @@ pub async fn run() -> Result<(), CliError> { if let Err(e) = execute!( terminal.backend_mut(), - LeaveAlternateScreen, - DisableMouseCapture + LeaveAlternateScreen ) { cleanup_errors.push(format!("leave_alternate_screen: {e}")); } diff --git a/src/tui/ui.rs b/src/tui/ui.rs index 91c0f40..80d407c 100644 --- a/src/tui/ui.rs +++ b/src/tui/ui.rs @@ -775,9 +775,9 @@ fn draw_access_tokens(f: &mut Frame, area: Rect, state: &AccessTokensState) { f.render_widget(loading, chunks[3]); } else if filtered_tokens.is_empty() { let empty_msg = if state.tokens.is_empty() { - "No access tokens. Press 'c' to issue a new token." + "No access tokens yet. Press c to issue your first token." } else { - "No tokens match filter." + "No tokens match the filter. Press Esc to clear." }; let empty = Paragraph::new(Line::from(Span::styled( empty_msg, @@ -2029,9 +2029,9 @@ fn draw_basins(f: &mut Frame, area: Rect, state: &BasinsState) { if filtered.is_empty() && !state.loading { let msg = if state.filter.is_empty() { - "No basins found. Press c to create one." + "No basins yet. Press c to create your first basin." } else { - "No basins match the filter." + "No basins match the filter. Press Esc to clear." }; let text = Paragraph::new(Span::styled(msg, Style::default().fg(TEXT_MUTED))) .alignment(Alignment::Center); @@ -2212,9 +2212,9 @@ fn draw_streams(f: &mut Frame, area: Rect, state: &StreamsState) { if filtered.is_empty() && !state.loading { let msg = if state.filter.is_empty() { - "No streams found. Press c to create one." + "No streams in this basin. Press c to create your first stream." } else { - "No streams match the filter." + "No streams match the filter. Press Esc to clear." }; let text = Paragraph::new(Span::styled(msg, Style::default().fg(TEXT_MUTED))) .alignment(Alignment::Center); @@ -3138,16 +3138,20 @@ fn draw_status_bar(f: &mut Frame, area: Rect, app: &App) { // Get hints based on available width - full, medium, or compact let hints = get_responsive_hints(&app.screen, width); - let message_span = app + // Create message spans with accessible text prefixes (not just colors) + let message_spans: Option> = app .message .as_ref() .map(|m| { - let color = match m.level { - MessageLevel::Info => ACCENT, - MessageLevel::Success => SUCCESS, - MessageLevel::Error => ERROR, + let (prefix, prefix_color, text_color) = match m.level { + MessageLevel::Info => ("ℹ ", CYAN, ACCENT), + MessageLevel::Success => ("✓ ", SUCCESS, SUCCESS), + MessageLevel::Error => ("✗ ", ERROR, ERROR), }; - Span::styled(&m.text, Style::default().fg(color)) + vec![ + Span::styled(prefix, Style::default().fg(prefix_color).bold()), + Span::styled(&m.text, Style::default().fg(text_color)), + ] }); // PiP indicator @@ -3176,8 +3180,8 @@ fn draw_status_bar(f: &mut Frame, area: Rect, app: &App) { let mut spans = Vec::new(); - if let Some(msg) = message_span { - spans.push(msg); + if let Some(msg_spans) = message_spans { + spans.extend(msg_spans); spans.push(Span::styled(" ", Style::default())); } @@ -3187,6 +3191,20 @@ fn draw_status_bar(f: &mut Frame, area: Rect, app: &App) { spans.push(Span::styled(display_hints, Style::default().fg(TEXT_MUTED))); + // Add persistent help hint indicator (always visible, stands out) + let show_help_hint = !matches!(app.screen, Screen::Splash | Screen::Setup(_)); + if show_help_hint { + // Calculate padding to right-align the help hint + let current_len: usize = spans.iter().map(|s| s.content.len()).sum(); + let help_hint = " ? help"; + let padding_needed = width.saturating_sub(current_len + help_hint.len()); + if padding_needed > 0 { + spans.push(Span::styled(" ".repeat(padding_needed), Style::default())); + } + spans.push(Span::styled("?", Style::default().fg(CYAN).bold())); + spans.push(Span::styled(" help", Style::default().fg(TEXT_MUTED))); + } + let line = Line::from(spans); let status = Paragraph::new(line); f.render_widget(status, area); @@ -3321,356 +3339,284 @@ fn get_responsive_hints(screen: &Screen, width: usize) -> String { } fn draw_help_overlay(f: &mut Frame, screen: &Screen) { - let area = centered_rect(50, 50, f.area()); + let area = centered_rect(60, 70, f.area()); + + // Helper to create a section header + fn section(title: &str) -> Line<'static> { + Line::from(vec![ + Span::styled(" ", Style::default()), + Span::styled(format!("─── {} ", title), Style::default().fg(CYAN).bold()), + Span::styled("─".repeat(20), Style::default().fg(GRAY_650)), + ]) + } + + // Helper to create a key binding line with description + fn key(keys: &str, action: &str, desc: &str) -> Line<'static> { + let mut spans = vec![ + Span::styled(format!("{:>6} ", keys), Style::default().fg(GREEN).bold()), + Span::styled(format!("{:<20}", action), Style::default().fg(TEXT_SECONDARY)), + ]; + if !desc.is_empty() { + spans.push(Span::styled(format!(" {}", desc), Style::default().fg(TEXT_MUTED).italic())); + } + Line::from(spans) + } - let help_text = match screen { - Screen::Splash | Screen::Setup(_) => vec![], // Never shown + // Get the screen title for the help header + let screen_title = match screen { + Screen::Splash | Screen::Setup(_) => "", + Screen::Settings(_) => "Settings", + Screen::Basins(_) => "Basins", + Screen::Streams(_) => "Streams", + Screen::StreamDetail(_) => "Stream Detail", + Screen::ReadView(_) => "Read View", + Screen::AppendView(_) => "Append Records", + Screen::AccessTokens(_) => "Access Tokens", + Screen::MetricsView(_) => "Metrics", + Screen::BenchView(_) => "Benchmark", + }; + + let mut help_text = match screen { + Screen::Splash | Screen::Setup(_) => vec![], Screen::Settings(_) => vec![ Line::from(""), - Line::from(vec![ - Span::styled(" j/k ", Style::default().fg(GREEN).bold()), - Span::styled("Navigate", Style::default().fg(TEXT_SECONDARY)), - ]), - Line::from(vec![ - Span::styled(" e ", Style::default().fg(GREEN).bold()), - Span::styled("Edit field", Style::default().fg(TEXT_SECONDARY)), - ]), - Line::from(vec![ - Span::styled(" h/l ", Style::default().fg(GREEN).bold()), - Span::styled("Change compression", Style::default().fg(TEXT_SECONDARY)), - ]), - Line::from(vec![ - Span::styled("space ", Style::default().fg(GREEN).bold()), - Span::styled("Toggle token visibility", Style::default().fg(TEXT_SECONDARY)), - ]), - Line::from(vec![ - Span::styled("enter ", Style::default().fg(GREEN).bold()), - Span::styled("Save changes", Style::default().fg(TEXT_SECONDARY)), - ]), - Line::from(vec![ - Span::styled(" r ", Style::default().fg(GREEN).bold()), - Span::styled("Reload from file", Style::default().fg(TEXT_SECONDARY)), - ]), - Line::from(vec![ - Span::styled(" tab ", Style::default().fg(GREEN).bold()), - Span::styled("Switch tabs", Style::default().fg(TEXT_SECONDARY)), - ]), - Line::from(vec![ - Span::styled(" q ", Style::default().fg(GREEN).bold()), - Span::styled("Quit", Style::default().fg(TEXT_SECONDARY)), - ]), + section("Navigation"), + key("j / k", "Move down / up", "Navigate between settings"), + key("g / G", "Jump to top / bottom", ""), + Line::from(""), + section("Editing"), + key("e", "Edit field", "Modify the selected setting"), + key("h / l", "Cycle option left / right", "Change compression level"), + key("space", "Toggle visibility", "Show/hide auth token"), + key("enter", "Save changes", "Write settings to config file"), + key("r", "Reload", "Discard changes, reload from file"), + Line::from(""), + section("Application"), + key("tab", "Switch tab", "Navigate between Basins/Tokens/Settings"), + key("q", "Quit", "Exit the application"), Line::from(""), ], Screen::Basins(_) => vec![ Line::from(""), - Line::from(vec![ - Span::styled(" j/k ", Style::default().fg(GREEN).bold()), - Span::styled("Navigate", Style::default().fg(TEXT_SECONDARY)), - ]), - Line::from(vec![ - Span::styled(" g/G ", Style::default().fg(GREEN).bold()), - Span::styled("Top / Bottom", Style::default().fg(TEXT_SECONDARY)), - ]), - Line::from(vec![ - Span::styled(" / ", Style::default().fg(GREEN).bold()), - Span::styled("Filter", Style::default().fg(TEXT_SECONDARY)), - ]), - Line::from(vec![ - Span::styled("enter ", Style::default().fg(GREEN).bold()), - Span::styled("Select basin", Style::default().fg(TEXT_SECONDARY)), - ]), - Line::from(vec![ - Span::styled(" c ", Style::default().fg(GREEN).bold()), - Span::styled("Create basin", Style::default().fg(TEXT_SECONDARY)), - ]), - Line::from(vec![ - Span::styled(" e ", Style::default().fg(GREEN).bold()), - Span::styled("Reconfigure basin", Style::default().fg(TEXT_SECONDARY)), - ]), - Line::from(vec![ - Span::styled(" d ", Style::default().fg(GREEN).bold()), - Span::styled("Delete basin", Style::default().fg(TEXT_SECONDARY)), - ]), - Line::from(vec![ - Span::styled(" r ", Style::default().fg(GREEN).bold()), - Span::styled("Refresh", Style::default().fg(TEXT_SECONDARY)), - ]), - Line::from(vec![ - Span::styled(" B ", Style::default().fg(GREEN).bold()), - Span::styled("Benchmark", Style::default().fg(TEXT_SECONDARY)), - ]), - Line::from(vec![ - Span::styled(" M ", Style::default().fg(GREEN).bold()), - Span::styled("Basin metrics", Style::default().fg(TEXT_SECONDARY)), - ]), - Line::from(vec![ - Span::styled(" A ", Style::default().fg(GREEN).bold()), - Span::styled("Account metrics", Style::default().fg(TEXT_SECONDARY)), - ]), - Line::from(vec![ - Span::styled(" tab ", Style::default().fg(GREEN).bold()), - Span::styled("Switch to Access Tokens", Style::default().fg(TEXT_SECONDARY)), - ]), - Line::from(vec![ - Span::styled(" q ", Style::default().fg(GREEN).bold()), - Span::styled("Quit", Style::default().fg(TEXT_SECONDARY)), - ]), + section("Navigation"), + key("j / k", "Move down / up", "Navigate basin list"), + key("g / G", "Jump to top / bottom", ""), + key("/", "Filter", "Search basins by name"), + key("enter", "Open basin", "View streams in selected basin"), + Line::from(""), + section("Basin Actions"), + key("c", "Create basin", "Create a new basin"), + key("e", "Configure", "Modify basin settings"), + key("d", "Delete", "Remove selected basin (requires confirm)"), + key("r", "Refresh", "Reload basin list from server"), + Line::from(""), + section("Analytics"), + key("B", "Benchmark", "Run performance benchmark on basin"), + key("M", "Basin metrics", "View metrics for selected basin"), + key("A", "Account metrics", "View account-level metrics"), + Line::from(""), + section("Application"), + key("tab", "Switch tab", "Go to Access Tokens or Settings"), + key("q", "Quit", "Exit the application"), Line::from(""), ], Screen::Streams(_) => vec![ Line::from(""), - Line::from(vec![ - Span::styled(" j/k ", Style::default().fg(GREEN).bold()), - Span::styled("Navigate", Style::default().fg(TEXT_SECONDARY)), - ]), - Line::from(vec![ - Span::styled(" / ", Style::default().fg(GREEN).bold()), - Span::styled("Filter", Style::default().fg(TEXT_SECONDARY)), - ]), - Line::from(vec![ - Span::styled("enter ", Style::default().fg(GREEN).bold()), - Span::styled("Select stream", Style::default().fg(TEXT_SECONDARY)), - ]), - Line::from(vec![ - Span::styled(" c ", Style::default().fg(GREEN).bold()), - Span::styled("Create stream", Style::default().fg(TEXT_SECONDARY)), - ]), - Line::from(vec![ - Span::styled(" e ", Style::default().fg(GREEN).bold()), - Span::styled("Reconfigure stream", Style::default().fg(TEXT_SECONDARY)), - ]), - Line::from(vec![ - Span::styled(" d ", Style::default().fg(GREEN).bold()), - Span::styled("Delete stream", Style::default().fg(TEXT_SECONDARY)), - ]), - Line::from(vec![ - Span::styled(" r ", Style::default().fg(GREEN).bold()), - Span::styled("Refresh", Style::default().fg(TEXT_SECONDARY)), - ]), - Line::from(vec![ - Span::styled(" esc ", Style::default().fg(GREEN).bold()), - Span::styled("Back", Style::default().fg(TEXT_SECONDARY)), - ]), + section("Navigation"), + key("j / k", "Move down / up", "Navigate stream list"), + key("g / G", "Jump to top / bottom", ""), + key("/", "Filter", "Search streams by name"), + key("enter", "Open stream", "View stream details and actions"), + Line::from(""), + section("Stream Actions"), + key("c", "Create stream", "Create a new stream in this basin"), + key("e", "Configure", "Modify stream settings"), + key("d", "Delete", "Remove selected stream (requires confirm)"), + key("r", "Refresh", "Reload stream list from server"), + key("M", "Metrics", "View metrics for selected stream"), + Line::from(""), + section("Navigation"), + key("esc", "Back", "Return to basins list"), Line::from(""), ], Screen::StreamDetail(_) => vec![ Line::from(""), - Line::from(vec![ - Span::styled(" j/k ", Style::default().fg(GREEN).bold()), - Span::styled("Navigate actions", Style::default().fg(TEXT_SECONDARY)), - ]), - Line::from(vec![ - Span::styled("enter ", Style::default().fg(GREEN).bold()), - Span::styled("Execute action", Style::default().fg(TEXT_SECONDARY)), - ]), - Line::from(vec![ - Span::styled(" t ", Style::default().fg(GREEN).bold()), - Span::styled("Tail (live follow)", Style::default().fg(TEXT_SECONDARY)), - ]), - Line::from(vec![ - Span::styled(" r ", Style::default().fg(GREEN).bold()), - Span::styled("Read records", Style::default().fg(TEXT_SECONDARY)), - ]), - Line::from(vec![ - Span::styled(" a ", Style::default().fg(GREEN).bold()), - Span::styled("Append records", Style::default().fg(TEXT_SECONDARY)), - ]), - Line::from(vec![ - Span::styled(" f ", Style::default().fg(GREEN).bold()), - Span::styled("Fence stream", Style::default().fg(TEXT_SECONDARY)), - ]), - Line::from(vec![ - Span::styled(" m ", Style::default().fg(GREEN).bold()), - Span::styled("Trim stream", Style::default().fg(TEXT_SECONDARY)), - ]), - Line::from(vec![ - Span::styled(" e ", Style::default().fg(GREEN).bold()), - Span::styled("Reconfigure stream", Style::default().fg(TEXT_SECONDARY)), - ]), - Line::from(vec![ - Span::styled(" M ", Style::default().fg(GREEN).bold()), - Span::styled("Stream metrics", Style::default().fg(TEXT_SECONDARY)), - ]), - Line::from(vec![ - Span::styled(" p ", Style::default().fg(CYAN).bold()), - Span::styled("Pin to PiP", Style::default().fg(TEXT_SECONDARY)), - ]), - Line::from(vec![ - Span::styled(" esc ", Style::default().fg(GREEN).bold()), - Span::styled("Back", Style::default().fg(TEXT_SECONDARY)), - ]), + section("Navigation"), + key("j / k", "Move down / up", "Navigate action menu"), + key("enter", "Execute", "Run the selected action"), Line::from(""), - ], - Screen::ReadView(_) => vec![ + section("Data Operations"), + key("t", "Tail", "Follow stream in real-time (live updates)"), + key("r", "Read", "Read records from a specific position"), + key("a", "Append", "Add new records to the stream"), Line::from(""), - Line::from(vec![ - Span::styled(" j/k ", Style::default().fg(GREEN).bold()), - Span::styled("Scroll", Style::default().fg(TEXT_SECONDARY)), - ]), - Line::from(vec![ - Span::styled(" g/G ", Style::default().fg(GREEN).bold()), - Span::styled("Top / Bottom", Style::default().fg(TEXT_SECONDARY)), - ]), - Line::from(vec![ - Span::styled("space ", Style::default().fg(GREEN).bold()), - Span::styled("Pause / Resume", Style::default().fg(TEXT_SECONDARY)), - ]), - Line::from(vec![ - Span::styled(" p ", Style::default().fg(CYAN).bold()), - Span::styled("Pin to PiP", Style::default().fg(TEXT_SECONDARY)), - ]), - Line::from(vec![ - Span::styled(" esc ", Style::default().fg(GREEN).bold()), - Span::styled("Back", Style::default().fg(TEXT_SECONDARY)), - ]), + section("Stream Management"), + key("f", "Fence", "Create a fencing token for coordination"), + key("m", "Trim", "Remove old records up to a sequence"), + key("e", "Configure", "Modify stream settings"), + key("M", "Metrics", "View stream performance metrics"), Line::from(""), - ], - Screen::AppendView(_) => vec![ + section("Multi-tasking"), + key("p", "Pin to PiP", "Monitor stream in picture-in-picture"), + key("P", "Toggle PiP", "Show/hide the PiP window"), Line::from(""), - Line::from(vec![ - Span::styled(" j/k ", Style::default().fg(GREEN).bold()), - Span::styled("Navigate fields", Style::default().fg(TEXT_SECONDARY)), - ]), - Line::from(vec![ - Span::styled("enter ", Style::default().fg(GREEN).bold()), - Span::styled("Edit field / Send record", Style::default().fg(TEXT_SECONDARY)), - ]), - Line::from(vec![ - Span::styled(" d ", Style::default().fg(GREEN).bold()), - Span::styled("Delete last header", Style::default().fg(TEXT_SECONDARY)), - ]), - Line::from(vec![ - Span::styled(" tab ", Style::default().fg(GREEN).bold()), - Span::styled("Switch header key/value", Style::default().fg(TEXT_SECONDARY)), - ]), - Line::from(vec![ - Span::styled(" esc ", Style::default().fg(GREEN).bold()), - Span::styled("Stop editing / Back", Style::default().fg(TEXT_SECONDARY)), - ]), + section("Navigation"), + key("esc", "Back", "Return to streams list"), Line::from(""), ], + Screen::ReadView(state) => { + let mut lines = vec![ + Line::from(""), + section("Scrolling"), + key("j / k", "Scroll down / up", "Move through records"), + key("g / G", "Jump to top / bottom", ""), + key("[ / ]", "Seek backward / forward", "Jump by larger increments"), + ]; + if state.is_tailing { + lines.push(Line::from("")); + lines.push(section("Live Tailing")); + lines.push(key("space", "Pause / Resume", "Temporarily stop live updates")); + } + lines.extend(vec![ + Line::from(""), + section("Display"), + key("h", "Toggle headers", "Show/hide record headers"), + key("T", "Timeline", "Open timeline scrubber"), + key("tab", "Toggle record list", "Switch focus to record list"), + key("enter", "Record detail", "View full record content"), + Line::from(""), + section("Multi-tasking"), + key("p", "Pin to PiP", "Continue monitoring in background"), + Line::from(""), + section("Navigation"), + key("esc", "Back", "Return to stream detail"), + Line::from(""), + ]); + lines + }, + Screen::AppendView(state) => { + if state.editing { + vec![ + Line::from(""), + section("Text Input"), + key("type", "Enter text", "Type your content"), + key("enter", "Confirm", "Save the current field"), + key("esc", "Cancel", "Discard changes to field"), + Line::from(""), + section("Header Fields"), + key("tab", "Switch key/value", "Toggle between header key and value"), + Line::from(""), + ] + } else { + vec![ + Line::from(""), + section("Navigation"), + key("j / k", "Move down / up", "Navigate between fields"), + Line::from(""), + section("Editing"), + key("enter", "Edit / Send", "Edit selected field, or send record"), + key("d", "Delete header", "Remove the last header entry"), + Line::from(""), + section("Navigation"), + key("esc", "Back", "Return to stream detail"), + Line::from(""), + ] + } + }, Screen::AccessTokens(_) => vec![ Line::from(""), - Line::from(vec![ - Span::styled(" j/k ", Style::default().fg(GREEN).bold()), - Span::styled("Navigate", Style::default().fg(TEXT_SECONDARY)), - ]), - Line::from(vec![ - Span::styled(" g/G ", Style::default().fg(GREEN).bold()), - Span::styled("Top / Bottom", Style::default().fg(TEXT_SECONDARY)), - ]), - Line::from(vec![ - Span::styled(" / ", Style::default().fg(GREEN).bold()), - Span::styled("Filter", Style::default().fg(TEXT_SECONDARY)), - ]), - Line::from(vec![ - Span::styled(" c ", Style::default().fg(GREEN).bold()), - Span::styled("Issue new token", Style::default().fg(TEXT_SECONDARY)), - ]), - Line::from(vec![ - Span::styled(" d ", Style::default().fg(GREEN).bold()), - Span::styled("Revoke token", Style::default().fg(TEXT_SECONDARY)), - ]), - Line::from(vec![ - Span::styled(" r ", Style::default().fg(GREEN).bold()), - Span::styled("Refresh", Style::default().fg(TEXT_SECONDARY)), - ]), - Line::from(vec![ - Span::styled(" tab ", Style::default().fg(GREEN).bold()), - Span::styled("Switch to Basins", Style::default().fg(TEXT_SECONDARY)), - ]), - Line::from(vec![ - Span::styled(" q ", Style::default().fg(GREEN).bold()), - Span::styled("Quit", Style::default().fg(TEXT_SECONDARY)), - ]), + section("Navigation"), + key("j / k", "Move down / up", "Navigate token list"), + key("g / G", "Jump to top / bottom", ""), + key("/", "Filter", "Search tokens by ID"), + Line::from(""), + section("Token Actions"), + key("c", "Issue token", "Create a new access token"), + key("d", "Revoke", "Invalidate the selected token"), + key("r", "Refresh", "Reload token list from server"), + Line::from(""), + section("Application"), + key("tab", "Switch tab", "Go to Basins or Settings"), + key("q", "Quit", "Exit the application"), Line::from(""), ], Screen::MetricsView(state) => { let mut lines = vec![ Line::from(""), - Line::from(vec![ - Span::styled(" j/k ", Style::default().fg(GREEN).bold()), - Span::styled("Scroll", Style::default().fg(TEXT_SECONDARY)), - ]), - Line::from(vec![ - Span::styled(" r ", Style::default().fg(GREEN).bold()), - Span::styled("Refresh", Style::default().fg(TEXT_SECONDARY)), - ]), + section("Navigation"), + key("j / k", "Scroll down / up", "Navigate metrics display"), ]; - if matches!(state.metrics_type, MetricsType::Basin { .. }) { - lines.push(Line::from(vec![ - Span::styled(" ←/→ ", Style::default().fg(GREEN).bold()), - Span::styled("Change metric", Style::default().fg(TEXT_SECONDARY)), - ])); + if matches!(state.metrics_type, MetricsType::Basin { .. } | MetricsType::Account) { + lines.push(key("← / →", "Change category", "Switch between metric types")); } - lines.push(Line::from(vec![ - Span::styled(" esc ", Style::default().fg(GREEN).bold()), - Span::styled("Back", Style::default().fg(TEXT_SECONDARY)), - ])); - lines.push(Line::from(vec![ - Span::styled(" q ", Style::default().fg(GREEN).bold()), - Span::styled("Quit", Style::default().fg(TEXT_SECONDARY)), - ])); - lines.push(Line::from("")); + lines.extend(vec![ + Line::from(""), + section("Actions"), + key("r", "Refresh", "Reload metrics from server"), + key("t", "Time range", "Open time range picker"), + Line::from(""), + section("Navigation"), + key("esc", "Back", "Return to previous screen"), + key("q", "Quit", "Exit the application"), + Line::from(""), + ]); lines }, Screen::BenchView(state) => { if state.config_phase { vec![ Line::from(""), - Line::from(vec![ - Span::styled(" j/k ", Style::default().fg(GREEN).bold()), - Span::styled("Navigate", Style::default().fg(TEXT_SECONDARY)), - ]), - Line::from(vec![ - Span::styled(" h/l ", Style::default().fg(GREEN).bold()), - Span::styled("Adjust value", Style::default().fg(TEXT_SECONDARY)), - ]), - Line::from(vec![ - Span::styled("enter ", Style::default().fg(GREEN).bold()), - Span::styled("Edit / Start", Style::default().fg(TEXT_SECONDARY)), - ]), - Line::from(vec![ - Span::styled(" esc ", Style::default().fg(GREEN).bold()), - Span::styled("Back", Style::default().fg(TEXT_SECONDARY)), - ]), + section("Configuration"), + key("j / k", "Move down / up", "Navigate benchmark settings"), + key("h / l", "Decrease / Increase", "Adjust numeric values"), + key("enter", "Edit / Start", "Edit field or start benchmark"), + Line::from(""), + section("Navigation"), + key("esc", "Back", "Return to basin view"), + key("q", "Quit", "Exit the application"), Line::from(""), ] } else if state.running { vec![ Line::from(""), - Line::from(vec![ - Span::styled("space ", Style::default().fg(GREEN).bold()), - Span::styled("Pause / Resume", Style::default().fg(TEXT_SECONDARY)), - ]), - Line::from(vec![ - Span::styled(" q ", Style::default().fg(GREEN).bold()), - Span::styled("Stop benchmark", Style::default().fg(TEXT_SECONDARY)), - ]), + section("Benchmark Control"), + key("space", "Pause / Resume", "Temporarily stop/continue benchmark"), + key("q", "Stop", "End benchmark and show results"), Line::from(""), ] } else { vec![ Line::from(""), - Line::from(vec![ - Span::styled(" r ", Style::default().fg(GREEN).bold()), - Span::styled("Restart benchmark", Style::default().fg(TEXT_SECONDARY)), - ]), - Line::from(vec![ - Span::styled(" esc ", Style::default().fg(GREEN).bold()), - Span::styled("Back", Style::default().fg(TEXT_SECONDARY)), - ]), + section("Results"), + key("r", "Restart", "Run the benchmark again"), + Line::from(""), + section("Navigation"), + key("esc", "Back", "Return to basin view"), + key("q", "Quit", "Exit the application"), Line::from(""), ] } }, }; + // Add dismiss hint at the bottom + if !help_text.is_empty() { + help_text.push(Line::from(vec![ + Span::styled(" Press ", Style::default().fg(TEXT_MUTED)), + Span::styled("?", Style::default().fg(CYAN).bold()), + Span::styled(" or ", Style::default().fg(TEXT_MUTED)), + Span::styled("Esc", Style::default().fg(CYAN).bold()), + Span::styled(" to close this help", Style::default().fg(TEXT_MUTED)), + ])); + } + + let title = format!(" {} · Keyboard Shortcuts ", screen_title); let block = Block::default() - .title(Line::from(Span::styled(" Help ", Style::default().fg(TEXT_PRIMARY).bold()))) + .title(Line::from(Span::styled(title, Style::default().fg(TEXT_PRIMARY).bold()))) .borders(Borders::ALL) .border_style(Style::default().fg(ACCENT)) - .style(Style::default().bg(BG_DARK)); + .style(Style::default().bg(BG_DARK)) + .padding(Padding::horizontal(1)); let help = Paragraph::new(help_text).block(block); @@ -5021,11 +4967,39 @@ fn draw_input_dialog(f: &mut Frame, mode: &InputMode) { let dialog = Paragraph::new(content).block(block); f.render_widget(dialog, chunks[0]); - let hint_line = Line::from(Span::styled(hint, Style::default().fg(TEXT_MUTED))); + // Parse and render hint with highlighted keys for better accessibility + let hint_spans = render_hint_with_keys(hint); + let hint_line = Line::from(hint_spans); let hint_para = Paragraph::new(hint_line).alignment(Alignment::Center); f.render_widget(hint_para, chunks[1]); } +/// Parse a hint string and render keys (before descriptions) with highlighting. +/// Format expected: "key1 desc1 · key2 desc2 · ..." +fn render_hint_with_keys(hint: &str) -> Vec> { + let mut spans = Vec::new(); + let parts: Vec<&str> = hint.split(" · ").collect(); + + for (i, part) in parts.iter().enumerate() { + if i > 0 { + spans.push(Span::styled(" · ", Style::default().fg(GRAY_650))); + } + + // Split into key and description (first word is the key) + if let Some(space_idx) = part.find(' ') { + let key = &part[..space_idx]; + let desc = &part[space_idx..]; + spans.push(Span::styled(key.to_string(), Style::default().fg(CYAN).bold())); + spans.push(Span::styled(desc.to_string(), Style::default().fg(TEXT_MUTED))); + } else { + // No space, treat whole thing as key + spans.push(Span::styled(part.to_string(), Style::default().fg(CYAN).bold())); + } + } + + spans +} + fn draw_bench_view(f: &mut Frame, area: Rect, state: &BenchViewState) { let block = Block::default() .title(Line::from(vec![ From de1057263635bddbe719cd29ef47e20325bb9a03 Mon Sep 17 00:00:00 2001 From: Mehul Arora Date: Mon, 26 Jan 2026 12:35:50 -0500 Subject: [PATCH 19/31] . --- src/tui/mod.rs | 2 -- src/tui/screens/mod.rs | 2 -- src/tui/widgets/mod.rs | 2 -- 3 files changed, 6 deletions(-) delete mode 100644 src/tui/screens/mod.rs delete mode 100644 src/tui/widgets/mod.rs diff --git a/src/tui/mod.rs b/src/tui/mod.rs index 3a90470..df1ea8a 100644 --- a/src/tui/mod.rs +++ b/src/tui/mod.rs @@ -1,8 +1,6 @@ mod app; mod event; -mod screens; mod ui; -mod widgets; use std::io; diff --git a/src/tui/screens/mod.rs b/src/tui/screens/mod.rs deleted file mode 100644 index 5219e29..0000000 --- a/src/tui/screens/mod.rs +++ /dev/null @@ -1,2 +0,0 @@ -// Screen state types are defined in app.rs -// This module is reserved for future screen-specific logic diff --git a/src/tui/widgets/mod.rs b/src/tui/widgets/mod.rs deleted file mode 100644 index 1208784..0000000 --- a/src/tui/widgets/mod.rs +++ /dev/null @@ -1,2 +0,0 @@ -// Widget types are currently implemented inline in ui.rs -// This module is reserved for future reusable widgets From bc01590d9cfeb7e12378178281e336667e5d410b Mon Sep 17 00:00:00 2001 From: Mehul Arora Date: Mon, 26 Jan 2026 13:44:22 -0500 Subject: [PATCH 20/31] . --- src/tui/app.rs | 583 ++++++++++++++++++++++++++++++----------------- src/tui/event.rs | 6 + src/tui/ui.rs | 494 ++++++++++++++++++--------------------- 3 files changed, 601 insertions(+), 482 deletions(-) diff --git a/src/tui/app.rs b/src/tui/app.rs index 2536a5b..03d1988 100644 --- a/src/tui/app.rs +++ b/src/tui/app.rs @@ -150,6 +150,37 @@ pub struct AppendViewState { pub editing_header_key: bool, pub history: Vec, pub appending: bool, + // File append support + pub input_file: String, // Path to file to append from + pub input_format: InputFormat, // Format for file records (text, json, json-base64) + pub file_append_progress: Option<(usize, usize)>, // (done, total) during file append +} + +/// Input format for file append (mirrors CLI's RecordFormat) +#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)] +pub enum InputFormat { + #[default] + Text, + Json, + JsonBase64, +} + +impl InputFormat { + pub fn next(self) -> Self { + match self { + Self::Text => Self::Json, + Self::Json => Self::JsonBase64, + Self::JsonBase64 => Self::Text, + } + } + + pub fn as_str(&self) -> &'static str { + match self { + Self::Text => "text", + Self::Json => "json", + Self::JsonBase64 => "json-base64", + } + } } /// Result of an append operation @@ -694,6 +725,53 @@ impl Default for RetentionPolicyOption { } } +impl RetentionPolicyOption { + pub fn toggle(&self) -> Self { + match self { + Self::Infinite => Self::Age, + Self::Age => Self::Infinite, + } + } +} + +/// Cycle storage class forward: None -> Standard -> Express -> None +fn storage_class_next(sc: &Option) -> Option { + match sc { + None => Some(StorageClass::Standard), + Some(StorageClass::Standard) => Some(StorageClass::Express), + Some(StorageClass::Express) => None, + } +} + +/// Cycle storage class backward: None -> Express -> Standard -> None +fn storage_class_prev(sc: &Option) -> Option { + match sc { + None => Some(StorageClass::Express), + Some(StorageClass::Standard) => None, + Some(StorageClass::Express) => Some(StorageClass::Standard), + } +} + +/// Cycle timestamping mode forward: None -> ClientPrefer -> ClientRequire -> Arrival -> None +fn timestamping_mode_next(tm: &Option) -> Option { + match tm { + None => Some(TimestampingMode::ClientPrefer), + Some(TimestampingMode::ClientPrefer) => Some(TimestampingMode::ClientRequire), + Some(TimestampingMode::ClientRequire) => Some(TimestampingMode::Arrival), + Some(TimestampingMode::Arrival) => None, + } +} + +/// Cycle timestamping mode backward: None -> Arrival -> ClientRequire -> ClientPrefer -> None +fn timestamping_mode_prev(tm: &Option) -> Option { + match tm { + None => Some(TimestampingMode::Arrival), + Some(TimestampingMode::ClientPrefer) => None, + Some(TimestampingMode::ClientRequire) => Some(TimestampingMode::ClientPrefer), + Some(TimestampingMode::Arrival) => Some(TimestampingMode::ClientRequire), + } +} + /// Basin scope option for UI (cloud provider/region) #[derive(Debug, Clone, Copy, PartialEq, Default)] pub enum BasinScopeOption { @@ -1695,6 +1773,42 @@ impl App { } } + Event::FileAppendProgress { appended, total, last_seq } => { + if let Screen::AppendView(state) = &mut self.screen { + state.file_append_progress = Some((appended, total)); + if let Some(seq) = last_seq { + // Add to history as we go + state.history.push(AppendResult { + seq_num: seq, + body_preview: format!("batch #{}", appended), + header_count: 0, + }); + } + } + } + + Event::FileAppendComplete(result) => { + if let Screen::AppendView(state) = &mut self.screen { + state.appending = false; + state.file_append_progress = None; + match result { + Ok((total, first_seq, last_seq)) => { + state.input_file.clear(); + self.message = Some(StatusMessage { + text: format!("Appended {} records (seq {}..{})", total, first_seq, last_seq), + level: MessageLevel::Success, + }); + } + Err(e) => { + self.message = Some(StatusMessage { + text: format!("File append failed: {e}"), + level: MessageLevel::Error, + }); + } + } + } + } + Event::AccessTokensLoaded(result) => { if let Screen::AccessTokens(state) = &mut self.screen { state.loading = false; @@ -2186,70 +2300,20 @@ impl App { } } KeyCode::Left | KeyCode::Char('h') => { - match *selected { - 1 => { - - } - 2 => { - *storage_class = match storage_class { - None => Some(StorageClass::Express), - Some(StorageClass::Standard) => None, - Some(StorageClass::Express) => Some(StorageClass::Standard), - }; - } - 3 => { - *retention_policy = match retention_policy { - RetentionPolicyOption::Infinite => RetentionPolicyOption::Age, - RetentionPolicyOption::Age => RetentionPolicyOption::Infinite, - }; - } - 5 => { - *timestamping_mode = match timestamping_mode { - None => Some(TimestampingMode::Arrival), - Some(TimestampingMode::ClientPrefer) => None, - Some(TimestampingMode::ClientRequire) => Some(TimestampingMode::ClientPrefer), - Some(TimestampingMode::Arrival) => Some(TimestampingMode::ClientRequire), - }; - } - 7 => { - - *delete_on_empty_enabled = !*delete_on_empty_enabled; - } + 2 => *storage_class = storage_class_prev(storage_class), + 3 => *retention_policy = retention_policy.toggle(), + 5 => *timestamping_mode = timestamping_mode_prev(timestamping_mode), + 7 => *delete_on_empty_enabled = !*delete_on_empty_enabled, _ => {} } } KeyCode::Right | KeyCode::Char('l') => { - match *selected { - 1 => { - - } - 2 => { - *storage_class = match storage_class { - None => Some(StorageClass::Standard), - Some(StorageClass::Standard) => Some(StorageClass::Express), - Some(StorageClass::Express) => None, - }; - } - 3 => { - *retention_policy = match retention_policy { - RetentionPolicyOption::Infinite => RetentionPolicyOption::Age, - RetentionPolicyOption::Age => RetentionPolicyOption::Infinite, - }; - } - 5 => { - *timestamping_mode = match timestamping_mode { - None => Some(TimestampingMode::ClientPrefer), - Some(TimestampingMode::ClientPrefer) => Some(TimestampingMode::ClientRequire), - Some(TimestampingMode::ClientRequire) => Some(TimestampingMode::Arrival), - Some(TimestampingMode::Arrival) => None, - }; - } - 7 => { - - *delete_on_empty_enabled = !*delete_on_empty_enabled; - } + 2 => *storage_class = storage_class_next(storage_class), + 3 => *retention_policy = retention_policy.toggle(), + 5 => *timestamping_mode = timestamping_mode_next(timestamping_mode), + 7 => *delete_on_empty_enabled = !*delete_on_empty_enabled, _ => {} } } @@ -2377,64 +2441,20 @@ impl App { } } KeyCode::Left | KeyCode::Char('h') => { - match *selected { - 1 => { - *storage_class = match storage_class { - None => Some(StorageClass::Express), - Some(StorageClass::Standard) => None, - Some(StorageClass::Express) => Some(StorageClass::Standard), - }; - } - 2 => { - *retention_policy = match retention_policy { - RetentionPolicyOption::Infinite => RetentionPolicyOption::Age, - RetentionPolicyOption::Age => RetentionPolicyOption::Infinite, - }; - } - 4 => { - *timestamping_mode = match timestamping_mode { - None => Some(TimestampingMode::Arrival), - Some(TimestampingMode::ClientPrefer) => None, - Some(TimestampingMode::ClientRequire) => Some(TimestampingMode::ClientPrefer), - Some(TimestampingMode::Arrival) => Some(TimestampingMode::ClientRequire), - }; - } - 6 => { - - *delete_on_empty_enabled = !*delete_on_empty_enabled; - } + 1 => *storage_class = storage_class_prev(storage_class), + 2 => *retention_policy = retention_policy.toggle(), + 4 => *timestamping_mode = timestamping_mode_prev(timestamping_mode), + 6 => *delete_on_empty_enabled = !*delete_on_empty_enabled, _ => {} } } KeyCode::Right | KeyCode::Char('l') => { - match *selected { - 1 => { - *storage_class = match storage_class { - None => Some(StorageClass::Standard), - Some(StorageClass::Standard) => Some(StorageClass::Express), - Some(StorageClass::Express) => None, - }; - } - 2 => { - *retention_policy = match retention_policy { - RetentionPolicyOption::Infinite => RetentionPolicyOption::Age, - RetentionPolicyOption::Age => RetentionPolicyOption::Infinite, - }; - } - 4 => { - *timestamping_mode = match timestamping_mode { - None => Some(TimestampingMode::ClientPrefer), - Some(TimestampingMode::ClientPrefer) => Some(TimestampingMode::ClientRequire), - Some(TimestampingMode::ClientRequire) => Some(TimestampingMode::Arrival), - Some(TimestampingMode::Arrival) => None, - }; - } - 6 => { - - *delete_on_empty_enabled = !*delete_on_empty_enabled; - } + 1 => *storage_class = storage_class_next(storage_class), + 2 => *retention_policy = retention_policy.toggle(), + 4 => *timestamping_mode = timestamping_mode_next(timestamping_mode), + 6 => *delete_on_empty_enabled = !*delete_on_empty_enabled, _ => {} } } @@ -2544,56 +2564,18 @@ impl App { } } KeyCode::Left | KeyCode::Char('h') => { - match *selected { - 0 => { - *storage_class = match storage_class { - None => Some(StorageClass::Express), - Some(StorageClass::Standard) => None, - Some(StorageClass::Express) => Some(StorageClass::Standard), - }; - } - 1 => { - *retention_policy = match retention_policy { - RetentionPolicyOption::Infinite => RetentionPolicyOption::Age, - RetentionPolicyOption::Age => RetentionPolicyOption::Infinite, - }; - } - 3 => { - *timestamping_mode = match timestamping_mode { - None => Some(TimestampingMode::Arrival), - Some(TimestampingMode::ClientPrefer) => None, - Some(TimestampingMode::ClientRequire) => Some(TimestampingMode::ClientPrefer), - Some(TimestampingMode::Arrival) => Some(TimestampingMode::ClientRequire), - }; - } + 0 => *storage_class = storage_class_prev(storage_class), + 1 => *retention_policy = retention_policy.toggle(), + 3 => *timestamping_mode = timestamping_mode_prev(timestamping_mode), _ => {} } } KeyCode::Right | KeyCode::Char('l') => { - match *selected { - 0 => { - *storage_class = match storage_class { - None => Some(StorageClass::Standard), - Some(StorageClass::Standard) => Some(StorageClass::Express), - Some(StorageClass::Express) => None, - }; - } - 1 => { - *retention_policy = match retention_policy { - RetentionPolicyOption::Infinite => RetentionPolicyOption::Age, - RetentionPolicyOption::Age => RetentionPolicyOption::Infinite, - }; - } - 3 => { - *timestamping_mode = match timestamping_mode { - None => Some(TimestampingMode::ClientPrefer), - Some(TimestampingMode::ClientPrefer) => Some(TimestampingMode::ClientRequire), - Some(TimestampingMode::ClientRequire) => Some(TimestampingMode::Arrival), - Some(TimestampingMode::Arrival) => None, - }; - } + 0 => *storage_class = storage_class_next(storage_class), + 1 => *retention_policy = retention_policy.toggle(), + 3 => *timestamping_mode = timestamping_mode_next(timestamping_mode), _ => {} } } @@ -2710,64 +2692,20 @@ impl App { } } KeyCode::Left | KeyCode::Char('h') => { - match *selected { - 0 => { - *storage_class = match storage_class { - None => Some(StorageClass::Express), - Some(StorageClass::Standard) => None, - Some(StorageClass::Express) => Some(StorageClass::Standard), - }; - } - 1 => { - *retention_policy = match retention_policy { - RetentionPolicyOption::Infinite => RetentionPolicyOption::Age, - RetentionPolicyOption::Age => RetentionPolicyOption::Infinite, - }; - } - 3 => { - *timestamping_mode = match timestamping_mode { - None => Some(TimestampingMode::Arrival), - Some(TimestampingMode::ClientPrefer) => None, - Some(TimestampingMode::ClientRequire) => Some(TimestampingMode::ClientPrefer), - Some(TimestampingMode::Arrival) => Some(TimestampingMode::ClientRequire), - }; - } - 5 => { - - *delete_on_empty_enabled = !*delete_on_empty_enabled; - } + 0 => *storage_class = storage_class_prev(storage_class), + 1 => *retention_policy = retention_policy.toggle(), + 3 => *timestamping_mode = timestamping_mode_prev(timestamping_mode), + 5 => *delete_on_empty_enabled = !*delete_on_empty_enabled, _ => {} } } KeyCode::Right | KeyCode::Char('l') => { - match *selected { - 0 => { - *storage_class = match storage_class { - None => Some(StorageClass::Standard), - Some(StorageClass::Standard) => Some(StorageClass::Express), - Some(StorageClass::Express) => None, - }; - } - 1 => { - *retention_policy = match retention_policy { - RetentionPolicyOption::Infinite => RetentionPolicyOption::Age, - RetentionPolicyOption::Age => RetentionPolicyOption::Infinite, - }; - } - 3 => { - *timestamping_mode = match timestamping_mode { - None => Some(TimestampingMode::ClientPrefer), - Some(TimestampingMode::ClientPrefer) => Some(TimestampingMode::ClientRequire), - Some(TimestampingMode::ClientRequire) => Some(TimestampingMode::Arrival), - Some(TimestampingMode::Arrival) => None, - }; - } - 5 => { - - *delete_on_empty_enabled = !*delete_on_empty_enabled; - } + 0 => *storage_class = storage_class_next(storage_class), + 1 => *retention_policy = retention_policy.toggle(), + 3 => *timestamping_mode = timestamping_mode_next(timestamping_mode), + 5 => *delete_on_empty_enabled = !*delete_on_empty_enabled, _ => {} } } @@ -4637,6 +4575,9 @@ impl App { editing_header_key: true, history: Vec::new(), appending: false, + input_file: String::new(), + input_format: InputFormat::Text, + file_append_progress: None, }); } @@ -4698,6 +4639,7 @@ impl App { } 2 => { state.match_seq_num.pop(); } 3 => { state.fencing_token.pop(); } + 4 => { state.input_file.pop(); } _ => {} } } @@ -4712,12 +4654,12 @@ impl App { } } 2 => { - if c.is_ascii_digit() { state.match_seq_num.push(c); } } 3 => { state.fencing_token.push(c); } + 4 => { state.input_file.push(c); } _ => {} } } @@ -4743,19 +4685,37 @@ impl App { self.load_stream_detail(basin_name, stream_name, tx); } KeyCode::Char('j') | KeyCode::Down => { - state.selected = (state.selected + 1).min(4); + state.selected = (state.selected + 1).min(6); } KeyCode::Char('k') | KeyCode::Up => { state.selected = state.selected.saturating_sub(1); } KeyCode::Char('d') if state.selected == 1 => { - state.headers.pop(); } + // Cycle format with h/l or space when on format field + KeyCode::Char('h') | KeyCode::Char('l') | KeyCode::Char(' ') if state.selected == 5 => { + state.input_format = state.input_format.next(); + } KeyCode::Enter => { - if state.selected == 4 { - // Send button - append the record - if !state.body.is_empty() { + if state.selected == 6 { + // Send button - check if we have file input or body + if !state.input_file.is_empty() { + // Append from file + let basin_name = state.basin_name.clone(); + let stream_name = state.stream_name.clone(); + let file_path = state.input_file.clone(); + let input_format = state.input_format; + let fencing_token = if state.fencing_token.is_empty() { + None + } else { + Some(state.fencing_token.clone()) + }; + state.appending = true; + state.file_append_progress = Some((0, 0)); + self.append_from_file(basin_name, stream_name, file_path, input_format, fencing_token, tx); + } else if !state.body.is_empty() { + // Append single record let basin_name = state.basin_name.clone(); let stream_name = state.stream_name.clone(); let body = state.body.clone(); @@ -4767,7 +4727,6 @@ impl App { Some(state.fencing_token.clone()) }; state.body.clear(); - state.appending = true; self.append_record(basin_name, stream_name, body, headers, match_seq_num, fencing_token, tx); } @@ -4874,6 +4833,204 @@ impl App { }); } + /// Append records from a file (one record per line) + fn append_from_file( + &self, + basin_name: BasinName, + stream_name: StreamName, + file_path: String, + input_format: InputFormat, + fencing_token: Option, + tx: mpsc::UnboundedSender, + ) { + let s2 = self.s2.clone().expect("S2 client not initialized"); + + tokio::spawn(async move { + use s2_sdk::types::{AppendInput, AppendRecord, AppendRecordBatch, FencingToken, Header}; + use tokio::io::{AsyncBufReadExt, BufReader}; + use base64ct::{Base64, Encoding}; + + // Open and read the file + let file = match tokio::fs::File::open(&file_path).await { + Ok(f) => f, + Err(e) => { + let _ = tx.send(Event::FileAppendComplete(Err(crate::error::CliError::RecordReaderInit( + format!("Failed to open file '{}': {}", file_path, e), + )))); + return; + } + }; + + let reader = BufReader::new(file); + let mut lines = reader.lines(); + + // Collect all lines first to get total count + let mut all_lines = Vec::new(); + while let Ok(Some(line)) = lines.next_line().await { + if !line.is_empty() { + all_lines.push(line); + } + } + + let total = all_lines.len(); + if total == 0 { + let _ = tx.send(Event::FileAppendComplete(Err(crate::error::CliError::RecordReaderInit( + "File is empty or contains no valid records".to_string(), + )))); + return; + } + + let stream = s2.basin(basin_name).stream(stream_name); + + // Helper to parse a line into an AppendRecord based on format + let parse_line = |line: &str, format: InputFormat| -> Result { + match format { + InputFormat::Text => { + AppendRecord::new(line.as_bytes().to_vec()) + .map_err(|e| e.to_string()) + } + InputFormat::Json | InputFormat::JsonBase64 => { + // Parse JSON: {"body": "...", "headers": [["key", "value"], ...], "timestamp": ...} + #[derive(serde::Deserialize)] + struct JsonRecord { + #[serde(default)] + body: String, + #[serde(default)] + headers: Vec<(String, String)>, + #[serde(default)] + timestamp: Option, + } + + let parsed: JsonRecord = serde_json::from_str(line) + .map_err(|e| format!("Invalid JSON: {}", e))?; + + // Decode body (base64 if json-base64, otherwise UTF-8) + let body_bytes = if format == InputFormat::JsonBase64 { + Base64::decode_vec(&parsed.body) + .map_err(|_| format!("Invalid base64 in body: {}", parsed.body))? + } else { + parsed.body.into_bytes() + }; + + let mut record = AppendRecord::new(body_bytes) + .map_err(|e| e.to_string())?; + + // Add headers + if !parsed.headers.is_empty() { + let headers: Result, String> = parsed.headers + .into_iter() + .map(|(k, v)| { + let key_bytes = if format == InputFormat::JsonBase64 { + Base64::decode_vec(&k).map_err(|_| format!("Invalid base64 in header key: {}", k))? + } else { + k.into_bytes() + }; + let val_bytes = if format == InputFormat::JsonBase64 { + Base64::decode_vec(&v).map_err(|_| format!("Invalid base64 in header value: {}", v))? + } else { + v.into_bytes() + }; + Ok(Header::new(key_bytes, val_bytes)) + }) + .collect(); + record = record.with_headers(headers?) + .map_err(|e| e.to_string())?; + } + + // Add timestamp if provided + if let Some(ts) = parsed.timestamp { + record = record.with_timestamp(ts); + } + + Ok(record) + } + } + }; + + // Process in batches + let batch_size = 100; + let mut appended = 0; + let mut first_seq: Option = None; + let mut last_seq: u64 = 0; + + for chunk in all_lines.chunks(batch_size) { + // Create records from lines + let records: Result, String> = chunk + .iter() + .map(|line| parse_line(line, input_format)) + .collect(); + + let records = match records { + Ok(r) => r, + Err(e) => { + let _ = tx.send(Event::FileAppendComplete(Err(crate::error::CliError::RecordWrite( + format!("Invalid record: {}", e), + )))); + return; + } + }; + + let batch = match AppendRecordBatch::try_from_iter(records) { + Ok(b) => b, + Err(e) => { + let _ = tx.send(Event::FileAppendComplete(Err(crate::error::CliError::RecordWrite( + format!("Failed to create batch: {}", e), + )))); + return; + } + }; + + let mut input = AppendInput::new(batch); + + // Apply fencing token if provided + if let Some(ref token_str) = fencing_token { + match token_str.parse::() { + Ok(token) => { + input = input.with_fencing_token(token); + } + Err(e) => { + let _ = tx.send(Event::FileAppendComplete(Err(crate::error::CliError::RecordWrite( + format!("Invalid fencing token: {}", e), + )))); + return; + } + } + } + + match stream.append(input).await { + Ok(output) => { + if first_seq.is_none() { + first_seq = Some(output.start.seq_num); + } + last_seq = output.end.seq_num; + appended += chunk.len(); + + // Send progress update + let _ = tx.send(Event::FileAppendProgress { + appended, + total, + last_seq: Some(last_seq), + }); + } + Err(e) => { + let _ = tx.send(Event::FileAppendComplete(Err(crate::error::CliError::op( + crate::error::OpKind::Append, + e, + )))); + return; + } + } + } + + // Send completion + let _ = tx.send(Event::FileAppendComplete(Ok(( + total, + first_seq.unwrap_or(0), + last_seq, + )))); + }); + } + /// Open fence dialog fn open_fence_dialog(&mut self, basin: BasinName, stream: StreamName) { self.input_mode = InputMode::Fence { diff --git a/src/tui/event.rs b/src/tui/event.rs index 3a8cacb..6a96f24 100644 --- a/src/tui/event.rs +++ b/src/tui/event.rs @@ -81,6 +81,12 @@ pub enum Event { /// Record appended successfully (seq_num, body_preview, header_count) RecordAppended(Result<(u64, String, usize), CliError>), + /// File append progress update (appended_count, total_lines, last_seq_num) + FileAppendProgress { appended: usize, total: usize, last_seq: Option }, + + /// File append completed (total_records, first_seq, last_seq) + FileAppendComplete(Result<(usize, u64, u64), CliError>), + /// Stream fenced successfully (new token) StreamFenced(Result), diff --git a/src/tui/ui.rs b/src/tui/ui.rs index 80d407c..6ae8f32 100644 --- a/src/tui/ui.rs +++ b/src/tui/ui.rs @@ -90,10 +90,6 @@ fn render_logo() -> Vec> { .collect() } -// ============================================================================ -// SHARED UI COMPONENTS -// ============================================================================ - /// Render a toggle switch with consistent styling fn render_toggle(on: bool, is_selected: bool) -> Vec> { if on { @@ -217,6 +213,33 @@ fn render_radio(active: bool) -> &'static str { if active { "●" } else { "○" } } +/// Render a search/filter bar with consistent styling +fn render_search_bar(filter: &str, filter_active: bool, placeholder: &str) -> (Block<'static>, Line<'static>) { + let block = Block::default() + .borders(Borders::ALL) + .border_style(Style::default().fg(if filter_active { GREEN } else { BORDER })) + .style(Style::default().bg(BG_PANEL)); + + let line = if filter_active { + Line::from(vec![ + Span::styled(" [/] ", Style::default().fg(GREEN)), + Span::styled(filter.to_string(), Style::default().fg(TEXT_PRIMARY)), + Span::styled("_", Style::default().fg(GREEN)), + ]) + } else if filter.is_empty() { + Line::from(vec![ + Span::styled(format!(" [/] {}...", placeholder), Style::default().fg(TEXT_MUTED)), + ]) + } else { + Line::from(vec![ + Span::styled(" [/] ", Style::default().fg(TEXT_MUTED)), + Span::styled(filter.to_string(), Style::default().fg(TEXT_PRIMARY)), + ]) + }; + + (block, line) +} + pub fn draw(f: &mut Frame, app: &App) { let area = f.area(); @@ -716,32 +739,9 @@ fn draw_access_tokens(f: &mut Frame, area: Rect, state: &AccessTokensState) { .borders(Borders::BOTTOM) .border_style(Style::default().fg(Color::Rgb(40, 40, 40)))); f.render_widget(title_block, chunks[0]); - let search_block = Block::default() - .borders(Borders::ALL) - .border_style(Style::default().fg(if state.filter_active { GREEN } else { BORDER })) - .style(Style::default().bg(BG_PANEL)); - let search_text = if state.filter_active { - Line::from(vec![ - Span::styled(" [/] ", Style::default().fg(GREEN)), - Span::styled(&state.filter, Style::default().fg(TEXT_PRIMARY)), - Span::styled("_", Style::default().fg(GREEN)), - ]) - } else if !state.filter.is_empty() { - Line::from(vec![ - Span::styled(" [/] ", Style::default().fg(TEXT_MUTED)), - Span::styled(&state.filter, Style::default().fg(TEXT_PRIMARY)), - ]) - } else { - Line::from(vec![ - Span::styled(" [/] Filter by token ID...", Style::default().fg(TEXT_MUTED)), - ]) - }; - - let search_para = Paragraph::new(search_text) - .block(search_block) - .style(Style::default().bg(BG_PANEL)); - f.render_widget(search_para, chunks[1]); + let (search_block, search_text) = render_search_bar(&state.filter, state.filter_active, "Filter by token ID"); + f.render_widget(Paragraph::new(search_text).block(search_block), chunks[1]); let header = Line::from(vec![ Span::styled(" ", Style::default()), // Space for selection prefix @@ -1974,34 +1974,11 @@ fn draw_basins(f: &mut Frame, area: Rect, state: &BasinsState) { .borders(Borders::BOTTOM) .border_style(Style::default().fg(Color::Rgb(40, 40, 40)))); f.render_widget(title_block, chunks[0]); - let search_block = Block::default() - .borders(Borders::ALL) - .border_style(Style::default().fg(if state.filter_active { GREEN } else { BORDER })) - .style(Style::default().bg(BG_PANEL)); - - let search_text = if state.filter_active { - Line::from(vec![ - Span::styled(" [/] ", Style::default().fg(GREEN)), - Span::styled(&state.filter, Style::default().fg(TEXT_PRIMARY)), - Span::styled("_", Style::default().fg(GREEN)), - ]) - } else if state.filter.is_empty() { - Line::from(vec![ - Span::styled(" [/] Filter by prefix...", Style::default().fg(TEXT_MUTED)), - ]) - } else { - Line::from(vec![ - Span::styled(" [/] ", Style::default().fg(TEXT_MUTED)), - Span::styled(&state.filter, Style::default().fg(TEXT_PRIMARY)), - ]) - }; - let search = Paragraph::new(search_text).block(search_block); - f.render_widget(search, chunks[1]); + let (search_block, search_text) = render_search_bar(&state.filter, state.filter_active, "Filter by prefix"); + f.render_widget(Paragraph::new(search_text).block(search_block), chunks[1]); - // === Table Header === let header_area = chunks[2]; - // Calculate column widths: Name takes most space, State and Scope are fixed let total_width = header_area.width as usize; let state_col = 12; let scope_col = 16; @@ -2019,12 +1996,10 @@ fn draw_basins(f: &mut Frame, area: Rect, state: &BasinsState) { Rect::new(header_area.x, header_area.y + 1, header_area.width, 1), ); - // === Filter basins === let filtered: Vec<_> = state.basins.iter() .filter(|b| state.filter.is_empty() || b.name.to_string().to_lowercase().contains(&state.filter.to_lowercase())) .collect(); - // === Table Rows === let table_area = chunks[3]; if filtered.is_empty() && !state.loading { @@ -2050,14 +2025,12 @@ fn draw_basins(f: &mut Frame, area: Rect, state: &BasinsState) { let total = filtered.len(); let selected = state.selected.min(total.saturating_sub(1)); - // Scroll offset let scroll_offset = if selected >= visible_height { selected - visible_height + 1 } else { 0 }; - // Draw rows for (view_idx, basin) in filtered.iter().enumerate().skip(scroll_offset).take(visible_height) { let y = table_area.y + (view_idx - scroll_offset) as u16; if y >= table_area.y + table_area.height { @@ -2067,7 +2040,6 @@ fn draw_basins(f: &mut Frame, area: Rect, state: &BasinsState) { let is_selected = view_idx == selected; let row_area = Rect::new(table_area.x, y, table_area.width, 1); - // Selection highlight if is_selected { f.render_widget( Block::default().style(Style::default().bg(Color::Rgb(39, 39, 42))), @@ -2075,24 +2047,20 @@ fn draw_basins(f: &mut Frame, area: Rect, state: &BasinsState) { ); } - // Name column let name = basin.name.to_string(); let max_name_len = name_col.saturating_sub(2); let display_name = truncate_str(&name, max_name_len, "…"); - // State badge let (state_text, state_bg) = match basin.state { s2_sdk::types::BasinState::Active => ("Active", Color::Rgb(22, 101, 52)), s2_sdk::types::BasinState::Creating => ("Creating", Color::Rgb(113, 63, 18)), s2_sdk::types::BasinState::Deleting => ("Deleting", Color::Rgb(127, 29, 29)), }; - // Scope let scope = basin.scope.as_ref() .map(|s| match s { s2_sdk::types::BasinScope::AwsUsEast1 => "aws:us-east-1" }) .unwrap_or("—"); - // Render name let name_style = if is_selected { Style::default().fg(TEXT_PRIMARY).bold() } else { @@ -2103,7 +2071,6 @@ fn draw_basins(f: &mut Frame, area: Rect, state: &BasinsState) { Rect::new(row_area.x, y, name_col as u16, 1), ); - // Render state badge let badge_x = row_area.x + name_col as u16; f.render_widget( Paragraph::new(Span::styled( @@ -2113,7 +2080,6 @@ fn draw_basins(f: &mut Frame, area: Rect, state: &BasinsState) { Rect::new(badge_x, y, state_col as u16, 1), ); - // Render scope let scope_x = badge_x + state_col as u16; f.render_widget( Paragraph::new(Span::styled(scope, Style::default().fg(TEXT_MUTED))), @@ -2160,32 +2126,10 @@ fn draw_streams(f: &mut Frame, area: Rect, state: &StreamsState) { .borders(Borders::BOTTOM) .border_style(Style::default().fg(Color::Rgb(40, 40, 40)))); f.render_widget(title_block, chunks[0]); - let search_block = Block::default() - .borders(Borders::ALL) - .border_style(Style::default().fg(if state.filter_active { GREEN } else { BORDER })) - .style(Style::default().bg(BG_PANEL)); - - let search_text = if state.filter_active { - Line::from(vec![ - Span::styled(" [/] ", Style::default().fg(GREEN)), - Span::styled(&state.filter, Style::default().fg(TEXT_PRIMARY)), - Span::styled("_", Style::default().fg(GREEN)), - ]) - } else if state.filter.is_empty() { - Line::from(vec![ - Span::styled(" [/] Filter by prefix...", Style::default().fg(TEXT_MUTED)), - ]) - } else { - Line::from(vec![ - Span::styled(" [/] ", Style::default().fg(TEXT_MUTED)), - Span::styled(&state.filter, Style::default().fg(TEXT_PRIMARY)), - ]) - }; - let search = Paragraph::new(search_text).block(search_block); - f.render_widget(search, chunks[1]); + let (search_block, search_text) = render_search_bar(&state.filter, state.filter_active, "Filter by prefix"); + f.render_widget(Paragraph::new(search_text).block(search_block), chunks[1]); - // === Table Header === let header_area = chunks[2]; let total_width = header_area.width as usize; let created_col = 24; @@ -2202,12 +2146,10 @@ fn draw_streams(f: &mut Frame, area: Rect, state: &StreamsState) { Rect::new(header_area.x, header_area.y + 1, header_area.width, 1), ); - // === Filter streams === let filtered: Vec<_> = state.streams.iter() .filter(|s| state.filter.is_empty() || s.name.to_string().to_lowercase().contains(&state.filter.to_lowercase())) .collect(); - // === Table Rows === let table_area = chunks[3]; if filtered.is_empty() && !state.loading { @@ -2233,14 +2175,12 @@ fn draw_streams(f: &mut Frame, area: Rect, state: &StreamsState) { let total = filtered.len(); let selected = state.selected.min(total.saturating_sub(1)); - // Scroll offset let scroll_offset = if selected >= visible_height { selected - visible_height + 1 } else { 0 }; - // Draw rows for (view_idx, stream) in filtered.iter().enumerate().skip(scroll_offset).take(visible_height) { let y = table_area.y + (view_idx - scroll_offset) as u16; if y >= table_area.y + table_area.height { @@ -2250,7 +2190,6 @@ fn draw_streams(f: &mut Frame, area: Rect, state: &StreamsState) { let is_selected = view_idx == selected; let row_area = Rect::new(table_area.x, y, table_area.width, 1); - // Selection highlight if is_selected { f.render_widget( Block::default().style(Style::default().bg(Color::Rgb(39, 39, 42))), @@ -2258,15 +2197,12 @@ fn draw_streams(f: &mut Frame, area: Rect, state: &StreamsState) { ); } - // Name column let name = stream.name.to_string(); let max_name_len = name_col.saturating_sub(2); let display_name = truncate_str(&name, max_name_len, "…"); - // Created timestamp - S2DateTime implements Display let created = stream.created_at.to_string(); - // Render name let name_style = if is_selected { Style::default().fg(TEXT_PRIMARY).bold() } else { @@ -2277,7 +2213,6 @@ fn draw_streams(f: &mut Frame, area: Rect, state: &StreamsState) { Rect::new(row_area.x, y, name_col as u16, 1), ); - // Render created timestamp let created_x = row_area.x + name_col as u16; f.render_widget( Paragraph::new(Span::styled(created, Style::default().fg(TEXT_MUTED))), @@ -2293,11 +2228,10 @@ fn draw_stream_detail(f: &mut Frame, area: Rect, state: &StreamDetailState) { .constraints([ Constraint::Length(3), Constraint::Length(5), // Stats cards - Constraint::Min(12), // Actions + Constraint::Min(12), ]) .split(area); - // === HEADER === let basin_str = state.basin_name.to_string(); let stream_str = state.stream_name.to_string(); let header_lines = vec![ @@ -2315,7 +2249,6 @@ fn draw_stream_detail(f: &mut Frame, area: Rect, state: &StreamDetailState) { .border_style(Style::default().fg(Color::Rgb(40, 40, 40)))); f.render_widget(header, chunks[0]); - // === STATS ROW === let stats_area = Layout::default() .direction(Direction::Horizontal) .constraints([ @@ -2335,7 +2268,6 @@ fn draw_stream_detail(f: &mut Frame, area: Rect, state: &StreamDetailState) { ]) .split(stats_area); - // Stat card helper with icon and color fn render_stat_card_v2(f: &mut Frame, area: Rect, icon: &str, label: &str, value: &str, value_color: Color) { let lines = vec![ Line::from(vec![ @@ -2358,7 +2290,7 @@ fn draw_stream_detail(f: &mut Frame, area: Rect, state: &StreamDetailState) { // Tail Position let (tail_val, tail_color) = if let Some(pos) = &state.tail_position { if pos.seq_num > 0 { - (format!("{}", pos.seq_num), Color::Rgb(34, 211, 238)) // Cyan + (format!("{}", pos.seq_num), Color::Rgb(34, 211, 238)) } else { ("0".to_string(), Color::Rgb(100, 100, 100)) } @@ -2386,13 +2318,12 @@ fn draw_stream_detail(f: &mut Frame, area: Rect, state: &StreamDetailState) { } else { format!("{}d ago", age_secs / 86400) }; - // Color based on recency let color = if age_secs < 60 { - Color::Rgb(74, 222, 128) // Green - very recent + Color::Rgb(74, 222, 128) } else if age_secs < 3600 { - Color::Rgb(250, 204, 21) // Yellow - recent + Color::Rgb(250, 204, 21) } else { - Color::Rgb(180, 180, 180) // Gray - old + Color::Rgb(180, 180, 180) }; (val, color) } else { @@ -2410,8 +2341,8 @@ fn draw_stream_detail(f: &mut Frame, area: Rect, state: &StreamDetailState) { .map(|s| format!("{:?}", s).to_lowercase()) .unwrap_or_else(|| "default".to_string()); let color = match val.as_str() { - "express" => Color::Rgb(251, 146, 60), // Orange for express - "standard" => Color::Rgb(147, 197, 253), // Light blue for standard + "express" => Color::Rgb(251, 146, 60), + "standard" => Color::Rgb(147, 197, 253), _ => Color::Rgb(180, 180, 180), }; (val, color) @@ -2439,7 +2370,7 @@ fn draw_stream_detail(f: &mut Frame, area: Rect, state: &StreamDetailState) { }) .unwrap_or_else(|| "∞".to_string()); let color = if val == "∞" { - Color::Rgb(167, 139, 250) // Purple for infinite + Color::Rgb(167, 139, 250) } else { Color::Rgb(180, 180, 180) }; @@ -2449,7 +2380,6 @@ fn draw_stream_detail(f: &mut Frame, area: Rect, state: &StreamDetailState) { }; render_stat_card_v2(f, stats_chunks[3], "◔", "Retention", &retention_val, retention_color); - // === ACTIONS === let actions_outer = Layout::default() .direction(Direction::Horizontal) .constraints([ @@ -2459,7 +2389,6 @@ fn draw_stream_detail(f: &mut Frame, area: Rect, state: &StreamDetailState) { ]) .split(chunks[2])[1]; - // Split into two columns: Data Operations | Stream Management let action_cols = Layout::default() .direction(Direction::Horizontal) .constraints([ @@ -2467,8 +2396,6 @@ fn draw_stream_detail(f: &mut Frame, area: Rect, state: &StreamDetailState) { Constraint::Ratio(1, 2), ]) .split(actions_outer); - - // Data operations (left column) let data_ops: Vec<(&str, &str, &str, &str)> = vec![ ("t", "Tail", "Live stream, see records as they arrive", "◉"), ("r", "Read", "Read with custom start position & limits", "◎"), @@ -2560,7 +2487,6 @@ fn draw_read_view(f: &mut Frame, area: Rect, state: &ReadViewState) { ]) .split(area); - // === HEADER === let basin_str = state.basin_name.to_string(); let stream_str = state.stream_name.to_string(); let record_count = format!(" {} records", state.records.len()); @@ -2575,7 +2501,6 @@ fn draw_read_view(f: &mut Frame, area: Rect, state: &ReadViewState) { Span::styled(&record_count, Style::default().fg(Color::Rgb(100, 100, 100))), ]; - // Add throughput indicator when tailing if state.is_tailing && state.current_mibps > 0.0 { header_spans.push(Span::styled(" ", Style::default())); header_spans.push(Span::styled( @@ -2588,7 +2513,6 @@ fn draw_read_view(f: &mut Frame, area: Rect, state: &ReadViewState) { )); } - // Add output file indicator if writing to file if let Some(ref output) = state.output_file { header_spans.push(Span::styled(" → ", Style::default().fg(Color::Rgb(100, 100, 100)))); header_spans.push(Span::styled(output, Style::default().fg(YELLOW))); @@ -2604,7 +2528,6 @@ fn draw_read_view(f: &mut Frame, area: Rect, state: &ReadViewState) { .border_style(Style::default().fg(Color::Rgb(40, 40, 40)))); f.render_widget(header, main_chunks[0]); - // === SPARKLINES (when tailing) === if show_sparklines { draw_tail_sparklines(f, main_chunks[1], &state.throughput_history, &state.records_per_sec_history); } @@ -2658,7 +2581,6 @@ fn draw_read_view(f: &mut Frame, area: Rect, state: &ReadViewState) { 0 }; - // === Left pane: Record list === for (view_idx, record) in state.records.iter().enumerate().skip(scroll_offset).take(visible_height) { let y = list_area.y + (view_idx - scroll_offset) as u16; if y >= list_area.y + list_area.height { @@ -2669,7 +2591,6 @@ fn draw_read_view(f: &mut Frame, area: Rect, state: &ReadViewState) { let has_headers = !record.headers.is_empty(); let row_area = Rect::new(list_area.x, y, list_area.width, 1); - // Selection highlight if is_selected { f.render_widget( Block::default().style(Style::default().bg(Color::Rgb(39, 39, 42))), @@ -2710,7 +2631,6 @@ fn draw_read_view(f: &mut Frame, area: Rect, state: &ReadViewState) { panes[1] }; - // === Body preview of selected record === if let Some(record) = state.records.get(selected) { let body = String::from_utf8_lossy(&record.body); let body_width = body_area.width.saturating_sub(2) as usize; @@ -2926,11 +2846,10 @@ fn draw_append_view(f: &mut Frame, area: Rect, state: &AppendViewState) { .direction(Direction::Vertical) .constraints([ Constraint::Length(3), - Constraint::Min(1), // Content + Constraint::Min(1), ]) .split(area); - // === HEADER === let basin_str = state.basin_name.to_string(); let stream_str = state.stream_name.to_string(); let header_lines = vec![ @@ -2949,16 +2868,15 @@ fn draw_append_view(f: &mut Frame, area: Rect, state: &AppendViewState) { .borders(Borders::BOTTOM) .border_style(Style::default().fg(Color::Rgb(40, 40, 40)))); f.render_widget(header, outer_chunks[0]); - // Split into form (left) and history (right) + let main_chunks = Layout::default() .direction(Direction::Horizontal) .constraints([ - Constraint::Percentage(50), // Form - Constraint::Percentage(50), // History + Constraint::Percentage(50), + Constraint::Percentage(50), ]) .split(outer_chunks[1]); - // === Form pane === let form_block = Block::default() .borders(Borders::ALL) .border_style(Style::default().fg(Color::Rgb(50, 50, 50))) @@ -2968,7 +2886,6 @@ fn draw_append_view(f: &mut Frame, area: Rect, state: &AppendViewState) { let form_inner = form_block.inner(main_chunks[0]); f.render_widget(form_block, main_chunks[0]); - // Helper functions let cursor = |editing: bool| if editing { "▎" } else { "" }; let selected_marker = |sel: bool| if sel { "▸ " } else { " " }; @@ -3071,8 +2988,61 @@ fn draw_append_view(f: &mut Frame, area: Rect, state: &AppendViewState) { ])); lines.push(Line::from("")); - let send_selected = state.selected == 4; - let can_send = !state.body.is_empty() && !state.appending; + // Separator between single record and batch mode + lines.push(Line::from(vec![ + Span::styled(" ─── ", Style::default().fg(GRAY_650)), + Span::styled("or batch from file", Style::default().fg(TEXT_MUTED)), + Span::styled(" ───────────────", Style::default().fg(GRAY_650)), + ])); + lines.push(Line::from("")); + + // Input file field + let file_selected = state.selected == 4; + let file_editing = file_selected && state.editing; + lines.push(Line::from(vec![ + Span::styled(selected_marker(file_selected), Style::default().fg(GREEN)), + Span::styled("Input File", Style::default().fg(if file_selected { TEXT_PRIMARY } else { TEXT_MUTED })), + Span::styled(" ", Style::default()), + Span::styled( + if state.input_file.is_empty() && !file_editing { + "(none)".to_string() + } else { + format!("{}{}", &state.input_file, cursor(file_editing)) + }, + Style::default().fg(if file_editing { GREEN } else if state.input_file.is_empty() { TEXT_MUTED } else { CYAN }) + ), + ])); + + // Format selector (only shown when file is set) + let format_selected = state.selected == 5; + let format_opts = [ + ("Text", state.input_format == super::app::InputFormat::Text), + ("JSON", state.input_format == super::app::InputFormat::Json), + ("JSON+Base64", state.input_format == super::app::InputFormat::JsonBase64), + ]; + lines.push(Line::from(vec![ + Span::styled(selected_marker(format_selected), Style::default().fg(GREEN)), + Span::styled("Format", Style::default().fg(if format_selected { TEXT_PRIMARY } else { TEXT_MUTED })), + Span::styled(" ", Style::default()), + render_pill(format_opts[0].0, format_selected, format_opts[0].1), + Span::raw(" "), + render_pill(format_opts[1].0, format_selected, format_opts[1].1), + Span::raw(" "), + render_pill(format_opts[2].0, format_selected, format_opts[2].1), + ])); + + // Show progress if appending from file + if let Some((done, total)) = state.file_append_progress { + let pct = if total > 0 { (done * 100) / total } else { 0 }; + lines.push(Line::from(vec![ + Span::styled(" ", Style::default()), + Span::styled(format!("Progress: {}/{} records ({}%)", done, total, pct), Style::default().fg(YELLOW)), + ])); + } + lines.push(Line::from("")); + + let send_selected = state.selected == 6; + let can_send = (!state.body.is_empty() || !state.input_file.is_empty()) && !state.appending; let (btn_fg, btn_bg) = if state.appending { (BG_DARK, YELLOW) } else if send_selected && can_send { @@ -3080,18 +3050,25 @@ fn draw_append_view(f: &mut Frame, area: Rect, state: &AppendViewState) { } else { (if can_send { GREEN } else { TEXT_MUTED }, BG_PANEL) }; + let btn_text = if state.appending { + if state.file_append_progress.is_some() { + " ◌ APPENDING FILE... " + } else { + " ◌ SENDING... " + } + } else if !state.input_file.is_empty() { + " ▶ APPEND FILE " + } else { + " ▶ SEND " + }; lines.push(Line::from(vec![ Span::styled(selected_marker(send_selected), Style::default().fg(GREEN)), - Span::styled( - if state.appending { " ◌ SENDING... " } else { " ▶ SEND " }, - Style::default().fg(btn_fg).bg(btn_bg).bold() - ), + Span::styled(btn_text, Style::default().fg(btn_fg).bg(btn_bg).bold()), ])); let form_para = Paragraph::new(lines); f.render_widget(form_para, form_inner); - // === History pane === let history_block = Block::default() .title(Line::from(vec![ Span::styled(" History ", Style::default().fg(TEXT_PRIMARY)), @@ -3513,10 +3490,14 @@ fn draw_help_overlay(f: &mut Frame, screen: &Screen) { section("Navigation"), key("j / k", "Move down / up", "Navigate between fields"), Line::from(""), - section("Editing"), - key("enter", "Edit / Send", "Edit selected field, or send record"), + section("Single Record"), + key("enter", "Edit / Send", "Edit field or send the record"), key("d", "Delete header", "Remove the last header entry"), Line::from(""), + section("Batch from File"), + key("enter", "Edit file path", "Enter path to file with records"), + key("", "(one record per line)", "Each line becomes a record body"), + Line::from(""), section("Navigation"), key("esc", "Back", "Return to stream detail"), Line::from(""), @@ -4287,25 +4268,7 @@ fn draw_input_dialog(f: &mut Frame, mode: &InputMode) { selected, editing, } => { - // Stylish indicators - let radio = |active: bool| if active { "●" } else { "○" }; - let check = |on: bool| if on { "x" } else { " " }; - - // Value display - clean with ∞ for unlimited - let show_val = |value: &str, is_editing: bool, placeholder: &str| -> String { - if is_editing { - if value.is_empty() { - "▎".to_string() - } else { - format!("{}▎", value) - } - } else if value.is_empty() { - placeholder.to_string() - } else { - value.to_string() - } - }; - + // Unit options for "time ago" let unit_str = match ago_unit { AgoUnit::Seconds => "sec", AgoUnit::Minutes => "min", @@ -4313,145 +4276,138 @@ fn draw_input_dialog(f: &mut Frame, mode: &InputMode) { AgoUnit::Days => "day", }; - let mut lines = vec![ - Line::from(vec![ - Span::styled(" ", Style::default()), - Span::styled(format!("s2://{}/{}", basin, stream), Style::default().fg(GREEN)), - ]), - Line::from(""), - Line::from(Span::styled(" START POSITION", Style::default().fg(TEXT_MUTED))), + // Format options + let format_opts = [ + ("Text", format.as_str() == "text"), + ("JSON", format.as_str() == "json"), + ("JSON+Base64", format.as_str() == "json-base64"), ]; - // Row 0: Sequence number - let is_seq = *start_from == ReadStartFrom::SeqNum; + let mut lines = vec![]; + + // Stream info header + lines.push(Line::from("")); lines.push(Line::from(vec![ - Span::styled(if *selected == 0 { " ▸ " } else { " " }, Style::default().fg(GREEN)), - Span::styled(format!("{} ", radio(is_seq)), Style::default().fg(if is_seq { GREEN } else { BORDER })), - Span::styled("Sequence # ", Style::default().fg(if *selected == 0 { TEXT_PRIMARY } else { TEXT_MUTED })), - Span::styled( - show_val(seq_num_value, *editing && *selected == 0, "0"), - Style::default().fg(if *editing && *selected == 0 { GREEN } else if is_seq { TEXT_PRIMARY } else { TEXT_MUTED }) - ), + Span::styled(" Reading from: ", Style::default().fg(TEXT_MUTED)), + Span::styled(format!("s2://{}/{}", basin, stream), Style::default().fg(GREEN).bold()), ])); - // Row 1: Timestamp + // Start position section + lines.push(Line::from("")); + lines.push(render_section_header("Start position", 48)); + lines.push(Line::from("")); + + // Row 0: Sequence number option + let is_seq = *start_from == ReadStartFrom::SeqNum; + let (ind, lbl) = render_field_row(0, "Sequence #", *selected); + let mut seq_spans = vec![ind]; + seq_spans.push(Span::styled(if is_seq { "● " } else { "○ " }, Style::default().fg(if is_seq { GREEN } else { GRAY_650 }))); + seq_spans.push(lbl); + seq_spans.push(Span::raw(" ")); + seq_spans.extend(render_text_input(seq_num_value, *selected == 0 && *editing, "0", if is_seq { GREEN } else { TEXT_MUTED })); + lines.push(Line::from(seq_spans)); + + // Row 1: Timestamp option let is_ts = *start_from == ReadStartFrom::Timestamp; - lines.push(Line::from(vec![ - Span::styled(if *selected == 1 { " ▸ " } else { " " }, Style::default().fg(GREEN)), - Span::styled(format!("{} ", radio(is_ts)), Style::default().fg(if is_ts { GREEN } else { BORDER })), - Span::styled("Timestamp ", Style::default().fg(if *selected == 1 { TEXT_PRIMARY } else { TEXT_MUTED })), - Span::styled( - show_val(timestamp_value, *editing && *selected == 1, "0"), - Style::default().fg(if *editing && *selected == 1 { GREEN } else if is_ts { TEXT_PRIMARY } else { TEXT_MUTED }) - ), - Span::styled(" ms", Style::default().fg(TEXT_MUTED)), - ])); + let (ind, lbl) = render_field_row(1, "Timestamp", *selected); + let mut ts_spans = vec![ind]; + ts_spans.push(Span::styled(if is_ts { "● " } else { "○ " }, Style::default().fg(if is_ts { GREEN } else { GRAY_650 }))); + ts_spans.push(lbl); + ts_spans.push(Span::raw(" ")); + ts_spans.extend(render_text_input(timestamp_value, *selected == 1 && *editing, "0", if is_ts { GREEN } else { TEXT_MUTED })); + ts_spans.push(Span::styled(" ms", Style::default().fg(TEXT_MUTED))); + lines.push(Line::from(ts_spans)); - // Row 2: Time ago + // Row 2: Time ago option let is_ago = *start_from == ReadStartFrom::Ago; - lines.push(Line::from(vec![ - Span::styled(if *selected == 2 { " ▸ " } else { " " }, Style::default().fg(GREEN)), - Span::styled(format!("{} ", radio(is_ago)), Style::default().fg(if is_ago { GREEN } else { BORDER })), - Span::styled("Time ago ", Style::default().fg(if *selected == 2 { TEXT_PRIMARY } else { TEXT_MUTED })), - Span::styled( - show_val(ago_value, *editing && *selected == 2, "5"), - Style::default().fg(if *editing && *selected == 2 { GREEN } else if is_ago { TEXT_PRIMARY } else { TEXT_MUTED }) - ), - Span::styled(format!(" {} ", unit_str), Style::default().fg(if is_ago { TEXT_SECONDARY } else { TEXT_MUTED })), - Span::styled("‹tab›", Style::default().fg(BORDER)), - ])); - - // Row 3: Tail offset + let (ind, lbl) = render_field_row(2, "Time ago", *selected); + let mut ago_spans = vec![ind]; + ago_spans.push(Span::styled(if is_ago { "● " } else { "○ " }, Style::default().fg(if is_ago { GREEN } else { GRAY_650 }))); + ago_spans.push(lbl); + ago_spans.push(Span::raw(" ")); + ago_spans.extend(render_text_input(ago_value, *selected == 2 && *editing, "5", if is_ago { GREEN } else { TEXT_MUTED })); + ago_spans.push(Span::styled(format!(" {}", unit_str), Style::default().fg(if is_ago { TEXT_SECONDARY } else { TEXT_MUTED }))); + ago_spans.push(Span::styled(" ‹tab› cycle", Style::default().fg(GRAY_600).italic())); + lines.push(Line::from(ago_spans)); + + // Row 3: Tail offset option let is_off = *start_from == ReadStartFrom::TailOffset; - lines.push(Line::from(vec![ - Span::styled(if *selected == 3 { " ▸ " } else { " " }, Style::default().fg(GREEN)), - Span::styled(format!("{} ", radio(is_off)), Style::default().fg(if is_off { GREEN } else { BORDER })), - Span::styled("Tail offset ", Style::default().fg(if *selected == 3 { TEXT_PRIMARY } else { TEXT_MUTED })), - Span::styled( - show_val(tail_offset_value, *editing && *selected == 3, "10"), - Style::default().fg(if *editing && *selected == 3 { GREEN } else if is_off { TEXT_PRIMARY } else { TEXT_MUTED }) - ), - Span::styled(" back", Style::default().fg(TEXT_MUTED)), - ])); - + let (ind, lbl) = render_field_row(3, "Tail offset", *selected); + let mut off_spans = vec![ind]; + off_spans.push(Span::styled(if is_off { "● " } else { "○ " }, Style::default().fg(if is_off { GREEN } else { GRAY_650 }))); + off_spans.push(lbl); + off_spans.push(Span::raw(" ")); + off_spans.extend(render_text_input(tail_offset_value, *selected == 3 && *editing, "10", if is_off { GREEN } else { TEXT_MUTED })); + off_spans.push(Span::styled(" back", Style::default().fg(TEXT_MUTED))); + lines.push(Line::from(off_spans)); + + // Limits section + lines.push(Line::from("")); + lines.push(render_section_header("Limits", 48)); lines.push(Line::from("")); - lines.push(Line::from(Span::styled(" LIMITS", Style::default().fg(TEXT_MUTED)))); - - // Row 4: Count - lines.push(Line::from(vec![ - Span::styled(if *selected == 4 { " ▸ " } else { " " }, Style::default().fg(GREEN)), - Span::styled("Max records ", Style::default().fg(if *selected == 4 { TEXT_PRIMARY } else { TEXT_MUTED })), - Span::styled( - show_val(count_limit, *editing && *selected == 4, "∞"), - Style::default().fg(if *editing && *selected == 4 { GREEN } else { TEXT_SECONDARY }) - ), - ])); - - // Row 5: Bytes - lines.push(Line::from(vec![ - Span::styled(if *selected == 5 { " ▸ " } else { " " }, Style::default().fg(GREEN)), - Span::styled("Max bytes ", Style::default().fg(if *selected == 5 { TEXT_PRIMARY } else { TEXT_MUTED })), - Span::styled( - show_val(byte_limit, *editing && *selected == 5, "∞"), - Style::default().fg(if *editing && *selected == 5 { GREEN } else { TEXT_SECONDARY }) - ), - ])); - // Row 6: Until - lines.push(Line::from(vec![ - Span::styled(if *selected == 6 { " ▸ " } else { " " }, Style::default().fg(GREEN)), - Span::styled("Until ", Style::default().fg(if *selected == 6 { TEXT_PRIMARY } else { TEXT_MUTED })), - Span::styled( - show_val(until_timestamp, *editing && *selected == 6, "∞"), - Style::default().fg(if *editing && *selected == 6 { GREEN } else { TEXT_SECONDARY }) - ), - Span::styled(" ms", Style::default().fg(TEXT_MUTED)), - ])); + // Row 4: Max records + let (ind, lbl) = render_field_row(4, "Max records", *selected); + let mut count_spans = vec![ind, lbl, Span::raw(" ")]; + count_spans.extend(render_text_input(count_limit, *selected == 4 && *editing, "∞ unlimited", YELLOW)); + lines.push(Line::from(count_spans)); + + // Row 5: Max bytes + let (ind, lbl) = render_field_row(5, "Max bytes", *selected); + let mut bytes_spans = vec![ind, lbl, Span::raw(" ")]; + bytes_spans.extend(render_text_input(byte_limit, *selected == 5 && *editing, "∞ unlimited", YELLOW)); + lines.push(Line::from(bytes_spans)); + + // Row 6: Until timestamp + let (ind, lbl) = render_field_row(6, "Until", *selected); + let mut until_spans = vec![ind, lbl, Span::raw(" ")]; + until_spans.extend(render_text_input(until_timestamp, *selected == 6 && *editing, "∞ unlimited", YELLOW)); + until_spans.push(Span::styled(" ms", Style::default().fg(TEXT_MUTED))); + lines.push(Line::from(until_spans)); + // Options section + lines.push(Line::from("")); + lines.push(render_section_header("Options", 48)); lines.push(Line::from("")); - lines.push(Line::from(Span::styled(" OPTIONS", Style::default().fg(TEXT_MUTED)))); - // Row 7: Clamp - lines.push(Line::from(vec![ - Span::styled(if *selected == 7 { " ▸ " } else { " " }, Style::default().fg(GREEN)), - Span::styled(format!("[{}] ", check(*clamp)), Style::default().fg(if *clamp { GREEN } else { BORDER })), - Span::styled("Clamp to tail", Style::default().fg(if *selected == 7 { TEXT_PRIMARY } else { TEXT_MUTED })), - ])); + // Row 7: Clamp toggle + let (ind, lbl) = render_field_row(7, "Clamp to tail", *selected); + let mut clamp_spans = vec![ind, lbl, Span::raw(" ")]; + clamp_spans.extend(render_toggle(*clamp, *selected == 7)); + lines.push(Line::from(clamp_spans)); // Row 8: Format - lines.push(Line::from(vec![ - Span::styled(if *selected == 8 { " ▸ " } else { " " }, Style::default().fg(GREEN)), - Span::styled("Format ", Style::default().fg(if *selected == 8 { TEXT_PRIMARY } else { TEXT_MUTED })), - Span::styled(format!("‹ {} ›", format.as_str()), Style::default().fg(if *selected == 8 { GREEN } else { TEXT_SECONDARY })), - ])); + let (ind, lbl) = render_field_row(8, "Format", *selected); + let mut format_spans = vec![ind, lbl, Span::raw(" ")]; + for (label, active) in &format_opts { + format_spans.push(render_pill(label, *selected == 8, *active)); + format_spans.push(Span::raw(" ")); + } + lines.push(Line::from(format_spans)); // Row 9: Output file + lines.push(Line::from("")); + let (ind, lbl) = render_field_row(9, "Output file", *selected); + let mut output_spans = vec![ind, lbl, Span::raw(" ")]; + output_spans.extend(render_text_input(output_file, *selected == 9 && *editing, "display only", TEXT_SECONDARY)); + lines.push(Line::from(output_spans)); + + // Divider and button + lines.push(Line::from("")); lines.push(Line::from(vec![ - Span::styled(if *selected == 9 { " ▸ " } else { " " }, Style::default().fg(GREEN)), - Span::styled("Output ", Style::default().fg(if *selected == 9 { TEXT_PRIMARY } else { TEXT_MUTED })), - Span::styled( - show_val(output_file, *editing && *selected == 9, "(display only)"), - Style::default().fg(if *editing && *selected == 9 { GREEN } else if output_file.is_empty() { TEXT_MUTED } else { TEXT_SECONDARY }) - ), + Span::styled("─".repeat(52), Style::default().fg(GRAY_750)), ])); - lines.push(Line::from("")); // Row 10: Start button - let (btn_fg, btn_bg) = if *selected == 10 { - (BG_DARK, GREEN) - } else { - (GREEN, BG_PANEL) - }; - lines.push(Line::from(vec![ - Span::styled(if *selected == 10 { " ▸ " } else { " " }, Style::default().fg(GREEN)), - Span::styled(" ▶ START ", Style::default().fg(btn_fg).bg(btn_bg).bold()), - ])); + lines.push(render_button("START READING", *selected == 10, true, GREEN)); + + lines.push(Line::from("")); ( - " Read ", + " Read Stream ", lines, - "↑↓ nav ⏎ edit ␣ toggle ⇥ unit esc", + "j/k navigate · Enter edit/select · Space toggle · Tab unit · Esc cancel", ) } From deaf9965001d6bce8e441a17e5f9c3d670f67426 Mon Sep 17 00:00:00 2001 From: Mehul Arora Date: Mon, 26 Jan 2026 13:55:01 -0500 Subject: [PATCH 21/31] . --- src/tui/app.rs | 8 ------- src/tui/ui.rs | 62 ++++++++++++++++++++++++++------------------------ 2 files changed, 32 insertions(+), 38 deletions(-) diff --git a/src/tui/app.rs b/src/tui/app.rs index 03d1988..2e9676a 100644 --- a/src/tui/app.rs +++ b/src/tui/app.rs @@ -173,14 +173,6 @@ impl InputFormat { Self::JsonBase64 => Self::Text, } } - - pub fn as_str(&self) -> &'static str { - match self { - Self::Text => "text", - Self::Json => "json", - Self::JsonBase64 => "json-base64", - } - } } /// Result of an append operation diff --git a/src/tui/ui.rs b/src/tui/ui.rs index 6ae8f32..ed4bcf8 100644 --- a/src/tui/ui.rs +++ b/src/tui/ui.rs @@ -33,9 +33,11 @@ const TEXT_SECONDARY: Color = GRAY_100; const TEXT_MUTED: Color = GRAY_500; // Additional gray shades for consistent styling -const GRAY_600: Color = Color::Rgb(80, 80, 80); // Medium gray for hints/placeholders -const GRAY_650: Color = Color::Rgb(60, 60, 60); // For toggle off state -const GRAY_750: Color = Color::Rgb(50, 50, 50); // For inactive pills +const GRAY_600: Color = Color::Rgb(80, 80, 80); +const GRAY_650: Color = Color::Rgb(60, 60, 60); +const GRAY_700: Color = Color::Rgb(100, 100, 100); +const GRAY_750: Color = Color::Rgb(50, 50, 50); +const GRAY_800: Color = Color::Rgb(40, 40, 40); // Consistent cursor character const CURSOR: &str = "▎"; @@ -731,13 +733,13 @@ fn draw_access_tokens(f: &mut Frame, area: Rect, state: &AccessTokensState) { Line::from(""), Line::from(vec![ Span::styled(" Access Tokens", Style::default().fg(GREEN).bold()), - Span::styled(&count_text, Style::default().fg(Color::Rgb(100, 100, 100))), + Span::styled(&count_text, Style::default().fg(GRAY_700)), ]), ]; let title_block = Paragraph::new(title_lines) .block(Block::default() .borders(Borders::BOTTOM) - .border_style(Style::default().fg(Color::Rgb(40, 40, 40)))); + .border_style(Style::default().fg(GRAY_800))); f.render_widget(title_block, chunks[0]); let (search_block, search_text) = render_search_bar(&state.filter, state.filter_active, "Filter by token ID"); @@ -1584,7 +1586,7 @@ fn render_area_chart( } else { // Value is below this row - empty or grid if col % 10 == 0 { - ('·', Color::Rgb(50, 50, 50)) + ('·', GRAY_750) } else { (' ', BG_DARK) } @@ -1966,13 +1968,13 @@ fn draw_basins(f: &mut Frame, area: Rect, state: &BasinsState) { Line::from(""), Line::from(vec![ Span::styled(" Basins", Style::default().fg(GREEN).bold()), - Span::styled(&count_text, Style::default().fg(Color::Rgb(100, 100, 100))), + Span::styled(&count_text, Style::default().fg(GRAY_700)), ]), ]; let title_block = Paragraph::new(title_lines) .block(Block::default() .borders(Borders::BOTTOM) - .border_style(Style::default().fg(Color::Rgb(40, 40, 40)))); + .border_style(Style::default().fg(GRAY_800))); f.render_widget(title_block, chunks[0]); let (search_block, search_text) = render_search_bar(&state.filter, state.filter_active, "Filter by prefix"); @@ -2116,15 +2118,15 @@ fn draw_streams(f: &mut Frame, area: Rect, state: &StreamsState) { let title_lines = vec![ Line::from(""), Line::from(vec![ - Span::styled(" ← ", Style::default().fg(Color::Rgb(100, 100, 100))), + Span::styled(" ← ", Style::default().fg(GRAY_700)), Span::styled(&basin_name_str, Style::default().fg(GREEN).bold()), - Span::styled(&count_text, Style::default().fg(Color::Rgb(100, 100, 100))), + Span::styled(&count_text, Style::default().fg(GRAY_700)), ]), ]; let title_block = Paragraph::new(title_lines) .block(Block::default() .borders(Borders::BOTTOM) - .border_style(Style::default().fg(Color::Rgb(40, 40, 40)))); + .border_style(Style::default().fg(GRAY_800))); f.render_widget(title_block, chunks[0]); let (search_block, search_text) = render_search_bar(&state.filter, state.filter_active, "Filter by prefix"); @@ -2237,7 +2239,7 @@ fn draw_stream_detail(f: &mut Frame, area: Rect, state: &StreamDetailState) { let header_lines = vec![ Line::from(""), Line::from(vec![ - Span::styled(" ← ", Style::default().fg(Color::Rgb(100, 100, 100))), + Span::styled(" ← ", Style::default().fg(GRAY_700)), Span::styled(&basin_str, Style::default().fg(Color::Rgb(150, 150, 150))), Span::styled(" / ", Style::default().fg(Color::Rgb(80, 80, 80))), Span::styled(&stream_str, Style::default().fg(GREEN).bold()), @@ -2246,7 +2248,7 @@ fn draw_stream_detail(f: &mut Frame, area: Rect, state: &StreamDetailState) { let header = Paragraph::new(header_lines) .block(Block::default() .borders(Borders::BOTTOM) - .border_style(Style::default().fg(Color::Rgb(40, 40, 40)))); + .border_style(Style::default().fg(GRAY_800))); f.render_widget(header, chunks[0]); let stats_area = Layout::default() @@ -2282,7 +2284,7 @@ fn draw_stream_detail(f: &mut Frame, area: Rect, state: &StreamDetailState) { let widget = Paragraph::new(lines) .block(Block::default() .borders(Borders::ALL) - .border_style(Style::default().fg(Color::Rgb(50, 50, 50))) + .border_style(Style::default().fg(GRAY_750)) .border_type(ratatui::widgets::BorderType::Rounded)); f.render_widget(widget, area); } @@ -2292,12 +2294,12 @@ fn draw_stream_detail(f: &mut Frame, area: Rect, state: &StreamDetailState) { if pos.seq_num > 0 { (format!("{}", pos.seq_num), Color::Rgb(34, 211, 238)) } else { - ("0".to_string(), Color::Rgb(100, 100, 100)) + ("0".to_string(), GRAY_700) } } else if state.loading { - ("...".to_string(), Color::Rgb(100, 100, 100)) + ("...".to_string(), GRAY_700) } else { - ("--".to_string(), Color::Rgb(100, 100, 100)) + ("--".to_string(), GRAY_700) }; render_stat_card_v2(f, stats_chunks[0], "▌", "Records", &tail_val, tail_color); @@ -2327,10 +2329,10 @@ fn draw_stream_detail(f: &mut Frame, area: Rect, state: &StreamDetailState) { }; (val, color) } else { - ("never".to_string(), Color::Rgb(100, 100, 100)) + ("never".to_string(), GRAY_700) } } else { - ("--".to_string(), Color::Rgb(100, 100, 100)) + ("--".to_string(), GRAY_700) }; render_stat_card_v2(f, stats_chunks[1], "◷", "Last Write", &ts_val, ts_color); @@ -2347,7 +2349,7 @@ fn draw_stream_detail(f: &mut Frame, area: Rect, state: &StreamDetailState) { }; (val, color) } else { - ("--".to_string(), Color::Rgb(100, 100, 100)) + ("--".to_string(), GRAY_700) }; render_stat_card_v2(f, stats_chunks[2], "◈", "Storage", &storage_val, storage_color); let (retention_val, retention_color) = if let Some(config) = &state.config { @@ -2376,7 +2378,7 @@ fn draw_stream_detail(f: &mut Frame, area: Rect, state: &StreamDetailState) { }; (val, color) } else { - ("--".to_string(), Color::Rgb(100, 100, 100)) + ("--".to_string(), GRAY_700) }; render_stat_card_v2(f, stats_chunks[3], "◔", "Retention", &retention_val, retention_color); @@ -2415,7 +2417,7 @@ fn draw_stream_detail(f: &mut Frame, area: Rect, state: &StreamDetailState) { let mut lines = vec![ Line::from(vec![ Span::styled(format!(" {} ", title), Style::default().fg(Color::Rgb(34, 211, 238)).bold()), - Span::styled("─".repeat(line_width), Style::default().fg(Color::Rgb(40, 40, 40))), + Span::styled("─".repeat(line_width), Style::default().fg(GRAY_800)), ]), Line::from(""), ]; @@ -2451,7 +2453,7 @@ fn draw_stream_detail(f: &mut Frame, area: Rect, state: &StreamDetailState) { let widget = Paragraph::new(lines) .block(Block::default() .borders(Borders::ALL) - .border_style(Style::default().fg(Color::Rgb(50, 50, 50))) + .border_style(Style::default().fg(GRAY_750)) .border_type(ratatui::widgets::BorderType::Rounded)); f.render_widget(widget, area); } @@ -2492,13 +2494,13 @@ fn draw_read_view(f: &mut Frame, area: Rect, state: &ReadViewState) { let record_count = format!(" {} records", state.records.len()); let mut header_spans = vec![ - Span::styled(" ← ", Style::default().fg(Color::Rgb(100, 100, 100))), + Span::styled(" ← ", Style::default().fg(GRAY_700)), Span::styled(&basin_str, Style::default().fg(Color::Rgb(150, 150, 150))), Span::styled(" / ", Style::default().fg(Color::Rgb(80, 80, 80))), Span::styled(&stream_str, Style::default().fg(Color::Rgb(180, 180, 180))), Span::styled(" ", Style::default()), Span::styled(format!(" {} ", mode_text), Style::default().fg(BG_DARK).bg(mode_color).bold()), - Span::styled(&record_count, Style::default().fg(Color::Rgb(100, 100, 100))), + Span::styled(&record_count, Style::default().fg(GRAY_700)), ]; if state.is_tailing && state.current_mibps > 0.0 { @@ -2514,7 +2516,7 @@ fn draw_read_view(f: &mut Frame, area: Rect, state: &ReadViewState) { } if let Some(ref output) = state.output_file { - header_spans.push(Span::styled(" → ", Style::default().fg(Color::Rgb(100, 100, 100)))); + header_spans.push(Span::styled(" → ", Style::default().fg(GRAY_700))); header_spans.push(Span::styled(output, Style::default().fg(YELLOW))); } @@ -2525,7 +2527,7 @@ fn draw_read_view(f: &mut Frame, area: Rect, state: &ReadViewState) { let header = Paragraph::new(header_lines) .block(Block::default() .borders(Borders::BOTTOM) - .border_style(Style::default().fg(Color::Rgb(40, 40, 40)))); + .border_style(Style::default().fg(GRAY_800))); f.render_widget(header, main_chunks[0]); if show_sparklines { @@ -2855,7 +2857,7 @@ fn draw_append_view(f: &mut Frame, area: Rect, state: &AppendViewState) { let header_lines = vec![ Line::from(""), Line::from(vec![ - Span::styled(" ← ", Style::default().fg(Color::Rgb(100, 100, 100))), + Span::styled(" ← ", Style::default().fg(GRAY_700)), Span::styled(&basin_str, Style::default().fg(Color::Rgb(150, 150, 150))), Span::styled(" / ", Style::default().fg(Color::Rgb(80, 80, 80))), Span::styled(&stream_str, Style::default().fg(Color::Rgb(180, 180, 180))), @@ -2866,7 +2868,7 @@ fn draw_append_view(f: &mut Frame, area: Rect, state: &AppendViewState) { let header = Paragraph::new(header_lines) .block(Block::default() .borders(Borders::BOTTOM) - .border_style(Style::default().fg(Color::Rgb(40, 40, 40)))); + .border_style(Style::default().fg(GRAY_800))); f.render_widget(header, outer_chunks[0]); let main_chunks = Layout::default() @@ -2879,7 +2881,7 @@ fn draw_append_view(f: &mut Frame, area: Rect, state: &AppendViewState) { let form_block = Block::default() .borders(Borders::ALL) - .border_style(Style::default().fg(Color::Rgb(50, 50, 50))) + .border_style(Style::default().fg(GRAY_750)) .border_type(ratatui::widgets::BorderType::Rounded) .padding(Padding::new(2, 2, 1, 1)); From 00cd845c5d4ecc7119c169b2e73c002cb8e349ab Mon Sep 17 00:00:00 2001 From: Mehul Arora Date: Mon, 26 Jan 2026 14:14:39 -0500 Subject: [PATCH 22/31] .. --- src/tui/app.rs | 1776 ++++++++++++++++++++------------- src/tui/event.rs | 16 +- src/tui/mod.rs | 10 +- src/tui/ui.rs | 2482 ++++++++++++++++++++++++++++++++++------------ src/types.rs | 4 +- 5 files changed, 2911 insertions(+), 1377 deletions(-) diff --git a/src/tui/app.rs b/src/tui/app.rs index 2e9676a..1c8844f 100644 --- a/src/tui/app.rs +++ b/src/tui/app.rs @@ -1,6 +1,6 @@ use std::collections::VecDeque; -use std::sync::atomic::{AtomicBool, Ordering}; use std::sync::Arc; +use std::sync::atomic::{AtomicBool, Ordering}; use std::time::Duration; use chrono::{Datelike, NaiveDate}; @@ -9,17 +9,29 @@ use base64ct::Encoding; use crossterm::event::{self, Event as CrosstermEvent, KeyCode, KeyEvent, KeyModifiers}; use futures::StreamExt; use ratatui::{Terminal, prelude::Backend}; -use s2_sdk::types::{AccessTokenId, AccessTokenInfo, BasinInfo, BasinMetricSet, BasinName, StreamInfo, StreamMetricSet, StreamName, StreamPosition, TimeRange}; +use s2_sdk::types::{ + AccessTokenId, AccessTokenInfo, BasinInfo, BasinMetricSet, BasinName, StreamInfo, + StreamMetricSet, StreamName, StreamPosition, TimeRange, +}; use tokio::sync::mpsc; -use crate::cli::{CreateStreamArgs, IssueAccessTokenArgs, ListAccessTokensArgs, ListBasinsArgs, ListStreamsArgs, ReadArgs, ReconfigureBasinArgs, ReconfigureStreamArgs}; -use crate::config::{self, ConfigKey, Compression}; +use crate::cli::{ + CreateStreamArgs, IssueAccessTokenArgs, ListAccessTokensArgs, ListBasinsArgs, ListStreamsArgs, + ReadArgs, ReconfigureBasinArgs, ReconfigureStreamArgs, +}; +use crate::config::{self, Compression, ConfigKey}; use crate::error::CliError; use crate::ops; use crate::record_format::{RecordFormat, RecordsOut}; -use crate::types::{BasinConfig, DeleteOnEmptyConfig, Operation, RetentionPolicy, S2BasinAndMaybeStreamUri, S2BasinAndStreamUri, S2BasinUri, StorageClass, StreamConfig, TimestampingConfig, TimestampingMode}; - -use super::event::{BasinConfigInfo, BenchFinalStats, BenchPhase, BenchSample, Event, StreamConfigInfo}; +use crate::types::{ + BasinConfig, DeleteOnEmptyConfig, Operation, RetentionPolicy, S2BasinAndMaybeStreamUri, + S2BasinAndStreamUri, S2BasinUri, StorageClass, StreamConfig, TimestampingConfig, + TimestampingMode, +}; + +use super::event::{ + BasinConfigInfo, BenchFinalStats, BenchPhase, BenchSample, Event, StreamConfigInfo, +}; use super::ui; /// Maximum records to keep in read view buffer @@ -140,20 +152,20 @@ pub struct AppendViewState { pub basin_name: BasinName, pub stream_name: StreamName, pub body: String, - pub headers: Vec<(String, String)>, // List of (key, value) pairs - pub match_seq_num: String, // Empty = none + pub headers: Vec<(String, String)>, // List of (key, value) pairs + pub match_seq_num: String, // Empty = none pub fencing_token: String, pub selected: usize, pub editing: bool, - pub header_key_input: String, // For adding new header + pub header_key_input: String, // For adding new header pub header_value_input: String, pub editing_header_key: bool, pub history: Vec, pub appending: bool, // File append support - pub input_file: String, // Path to file to append from - pub input_format: InputFormat, // Format for file records (text, json, json-base64) - pub file_append_progress: Option<(usize, usize)>, // (done, total) during file append + pub input_file: String, // Path to file to append from + pub input_format: InputFormat, // Format for file records (text, json, json-base64) + pub file_append_progress: Option<(usize, usize)>, // (done, total) during file append } /// Input format for file append (mirrors CLI's RecordFormat) @@ -232,11 +244,11 @@ impl CompressionOption { #[derive(Debug, Clone)] pub struct SettingsState { pub access_token: String, - pub access_token_masked: bool, // Whether to show masked or plaintext + pub access_token_masked: bool, // Whether to show masked or plaintext pub account_endpoint: String, pub basin_endpoint: String, pub compression: CompressionOption, - pub selected: usize, // 0=token, 1=account_endpoint, 2=basin_endpoint, 3=compression + pub selected: usize, // 0=token, 1=account_endpoint, 2=basin_endpoint, 3=compression pub editing: bool, pub has_changes: bool, pub message: Option, @@ -262,8 +274,13 @@ impl Default for SettingsState { #[derive(Debug, Clone)] pub enum MetricsType { Account, - Basin { basin_name: BasinName }, - Stream { basin_name: BasinName, stream_name: StreamName }, + Basin { + basin_name: BasinName, + }, + Stream { + basin_name: BasinName, + stream_name: StreamName, + }, } /// Which metric is currently selected (for basin/stream) @@ -332,7 +349,10 @@ pub enum TimeRangeOption { ThreeDays, SevenDays, ThirtyDays, - Custom { start: u32, end: u32 }, // Unix timestamps + Custom { + start: u32, + end: u32, + }, // Unix timestamps } impl TimeRangeOption { @@ -440,10 +460,10 @@ pub struct MetricsViewState { pub calendar_open: bool, pub calendar_year: i32, pub calendar_month: u32, - pub calendar_day: u32, // Currently highlighted day + pub calendar_day: u32, // Currently highlighted day pub calendar_start: Option<(i32, u32, u32)>, // Selected start date (year, month, day) pub calendar_end: Option<(i32, u32, u32)>, // Selected end date - pub calendar_selecting_end: bool, // true if selecting end date + pub calendar_selecting_end: bool, // true if selecting end date } /// Benchmark configuration phase @@ -485,10 +505,10 @@ pub struct BenchViewState { pub basin_name: BasinName, pub config_phase: bool, pub config_field: BenchConfigField, - pub record_size: u32, // bytes (default 8KB) - pub target_mibps: u64, // MiB/s (default 1) - pub duration_secs: u64, // seconds (default 60) - pub catchup_delay_secs: u64, // seconds (default 20) + pub record_size: u32, // bytes (default 8KB) + pub target_mibps: u64, // MiB/s (default 1) + pub duration_secs: u64, // seconds (default 60) + pub catchup_delay_secs: u64, // seconds (default 20) pub editing: bool, pub edit_buffer: String, pub stream_name: Option, @@ -522,7 +542,7 @@ impl BenchViewState { basin_name, config_phase: true, config_field: BenchConfigField::default(), - record_size: 8 * 1024, // 8 KB + record_size: 8 * 1024, // 8 KB target_mibps: 1, duration_secs: 60, catchup_delay_secs: 20, @@ -571,9 +591,10 @@ pub struct StatusMessage { } /// Input mode for text input dialogs -#[derive(Debug, Clone)] +#[derive(Debug, Clone, Default)] pub enum InputMode { /// Not in input mode + #[default] Normal, /// Creating a new basin CreateBasin { @@ -608,7 +629,10 @@ pub enum InputMode { /// Confirming basin deletion ConfirmDeleteBasin { basin: BasinName }, /// Confirming stream deletion - ConfirmDeleteStream { basin: BasinName, stream: StreamName }, + ConfirmDeleteStream { + basin: BasinName, + stream: StreamName, + }, /// Reconfiguring a basin ReconfigureBasin { basin: BasinName, @@ -662,8 +686,8 @@ pub enum InputMode { basin: BasinName, stream: StreamName, new_token: String, - current_token: String, // Empty = no current token - selected: usize, // 0=new_token, 1=current_token, 2=submit + current_token: String, // Empty = no current token + selected: usize, // 0=new_token, 1=current_token, 2=submit editing: bool, }, /// Trim a stream (delete records before seq num) @@ -671,8 +695,8 @@ pub enum InputMode { basin: BasinName, stream: StreamName, trim_point: String, - fencing_token: String, // Empty = no fencing token - selected: usize, // 0=trim_point, 1=fencing_token, 2=submit + fencing_token: String, // Empty = no fencing token + selected: usize, // 0=trim_point, 1=fencing_token, 2=submit editing: bool, }, /// Issue a new access token @@ -705,18 +729,13 @@ pub enum InputMode { } /// Retention policy option for UI -#[derive(Debug, Clone, Copy, PartialEq)] +#[derive(Debug, Clone, Copy, PartialEq, Default)] pub enum RetentionPolicyOption { + #[default] Infinite, Age, } -impl Default for RetentionPolicyOption { - fn default() -> Self { - Self::Infinite - } -} - impl RetentionPolicyOption { pub fn toggle(&self) -> Self { match self { @@ -821,7 +840,7 @@ impl ExpiryOption { } } - pub fn to_duration_str(&self) -> Option<&'static str> { + pub fn duration_str(self) -> Option<&'static str> { match self { ExpiryOption::Never => None, ExpiryOption::OneDay => Some("1d"), @@ -829,7 +848,7 @@ impl ExpiryOption { ExpiryOption::ThirtyDays => Some("30d"), ExpiryOption::NinetyDays => Some("90d"), ExpiryOption::OneYear => Some("365d"), - ExpiryOption::Custom => None, // Use custom value + ExpiryOption::Custom => None, } } } @@ -874,9 +893,10 @@ impl ScopeOption { } /// Start position for read operation -#[derive(Debug, Clone, Copy, PartialEq)] +#[derive(Debug, Clone, Copy, PartialEq, Default)] pub enum ReadStartFrom { /// From current tail (live follow, no historical) + #[default] Tail, /// From specific sequence number SeqNum, @@ -888,23 +908,18 @@ pub enum ReadStartFrom { TailOffset, } -impl Default for ReadStartFrom { - fn default() -> Self { - Self::Tail - } -} - /// Time unit for "ago" option -#[derive(Debug, Clone, Copy, PartialEq)] +#[derive(Debug, Clone, Copy, PartialEq, Default)] pub enum AgoUnit { Seconds, + #[default] Minutes, Hours, Days, } impl AgoUnit { - pub fn to_seconds(&self, value: u64) -> u64 { + pub fn as_seconds(self, value: u64) -> u64 { match self { AgoUnit::Seconds => value, AgoUnit::Minutes => value * 60, @@ -913,7 +928,7 @@ impl AgoUnit { } } - pub fn next(&self) -> Self { + pub fn next(self) -> Self { match self { AgoUnit::Seconds => AgoUnit::Minutes, AgoUnit::Minutes => AgoUnit::Hours, @@ -923,12 +938,6 @@ impl AgoUnit { } } -impl Default for AgoUnit { - fn default() -> Self { - Self::Minutes - } -} - /// Output format for read operation #[derive(Debug, Clone, Copy, PartialEq, Default)] pub enum ReadFormat { @@ -979,12 +988,6 @@ pub struct StreamReconfigureConfig { pub delete_on_empty_min_age: String, } -impl Default for InputMode { - fn default() -> Self { - Self::Normal - } -} - /// Main application state pub struct App { pub screen: Screen, @@ -1000,6 +1003,7 @@ pub struct App { } /// Build a basin config from form values +#[allow(clippy::too_many_arguments)] fn build_basin_config( create_stream_on_append: bool, create_stream_on_read: bool, @@ -1011,19 +1015,20 @@ fn build_basin_config( delete_on_empty_enabled: bool, delete_on_empty_min_age: String, ) -> BasinConfig { - let retention = match retention_policy { RetentionPolicyOption::Infinite => None, - RetentionPolicyOption::Age => { - humantime::parse_duration(&retention_age_input) - .ok() - .map(RetentionPolicy::Age) - } + RetentionPolicyOption::Age => humantime::parse_duration(&retention_age_input) + .ok() + .map(RetentionPolicy::Age), }; let timestamping = if timestamping_mode.is_some() || timestamping_uncapped { Some(TimestampingConfig { timestamping_mode, - timestamping_uncapped: if timestamping_uncapped { Some(true) } else { None }, + timestamping_uncapped: if timestamping_uncapped { + Some(true) + } else { + None + }, }) } else { None @@ -1031,7 +1036,9 @@ fn build_basin_config( let delete_on_empty = if delete_on_empty_enabled { humantime::parse_duration(&delete_on_empty_min_age) .ok() - .map(|d| DeleteOnEmptyConfig { delete_on_empty_min_age: d }) + .map(|d| DeleteOnEmptyConfig { + delete_on_empty_min_age: d, + }) } else { None }; @@ -1057,19 +1064,20 @@ fn build_stream_config( delete_on_empty_enabled: bool, delete_on_empty_min_age: String, ) -> StreamConfig { - let retention = match retention_policy { RetentionPolicyOption::Infinite => None, - RetentionPolicyOption::Age => { - humantime::parse_duration(&retention_age_input) - .ok() - .map(RetentionPolicy::Age) - } + RetentionPolicyOption::Age => humantime::parse_duration(&retention_age_input) + .ok() + .map(RetentionPolicy::Age), }; let timestamping = if timestamping_mode.is_some() || timestamping_uncapped { Some(TimestampingConfig { timestamping_mode, - timestamping_uncapped: if timestamping_uncapped { Some(true) } else { None }, + timestamping_uncapped: if timestamping_uncapped { + Some(true) + } else { + None + }, }) } else { None @@ -1077,7 +1085,9 @@ fn build_stream_config( let delete_on_empty = if delete_on_empty_enabled { humantime::parse_duration(&delete_on_empty_min_age) .ok() - .map(|d| DeleteOnEmptyConfig { delete_on_empty_min_age: d }) + .map(|d| DeleteOnEmptyConfig { + delete_on_empty_min_age: d, + }) } else { None }; @@ -1121,14 +1131,16 @@ impl App { /// Load settings from config file fn load_settings_state() -> SettingsState { - let file_config = config::load_config_file().unwrap_or_default(); let env_config = config::load_cli_config().unwrap_or_default(); - let access_token = file_config.access_token.clone() + let access_token = file_config + .access_token + .clone() .or_else(|| env_config.access_token.clone()) .unwrap_or_default(); - let token_from_env = file_config.access_token.is_none() && env_config.access_token.is_some(); + let token_from_env = + file_config.access_token.is_none() && env_config.access_token.is_some(); SettingsState { access_token, @@ -1157,35 +1169,39 @@ impl App { if state.access_token.is_empty() { cli_config.unset(ConfigKey::AccessToken); } else { - cli_config.set(ConfigKey::AccessToken, state.access_token.clone()) - .map_err(|e| CliError::Config(e))?; + cli_config + .set(ConfigKey::AccessToken, state.access_token.clone()) + .map_err(CliError::Config)?; } if state.account_endpoint.is_empty() { cli_config.unset(ConfigKey::AccountEndpoint); } else { - cli_config.set(ConfigKey::AccountEndpoint, state.account_endpoint.clone()) - .map_err(|e| CliError::Config(e))?; + cli_config + .set(ConfigKey::AccountEndpoint, state.account_endpoint.clone()) + .map_err(CliError::Config)?; } if state.basin_endpoint.is_empty() { cli_config.unset(ConfigKey::BasinEndpoint); } else { - cli_config.set(ConfigKey::BasinEndpoint, state.basin_endpoint.clone()) - .map_err(|e| CliError::Config(e))?; + cli_config + .set(ConfigKey::BasinEndpoint, state.basin_endpoint.clone()) + .map_err(CliError::Config)?; } match state.compression { CompressionOption::None => cli_config.unset(ConfigKey::Compression), CompressionOption::Gzip => { - cli_config.set(ConfigKey::Compression, "gzip".to_string()) - .map_err(|e| CliError::Config(e))?; + cli_config + .set(ConfigKey::Compression, "gzip".to_string()) + .map_err(CliError::Config)?; } CompressionOption::Zstd => { - cli_config.set(ConfigKey::Compression, "zstd".to_string()) - .map_err(|e| CliError::Config(e))?; + cli_config + .set(ConfigKey::Compression, "zstd".to_string()) + .map_err(CliError::Config)?; } } - config::save_cli_config(&cli_config) - .map_err(|e| CliError::Config(e))?; + config::save_cli_config(&cli_config).map_err(CliError::Config)?; Ok(()) } @@ -1199,12 +1215,10 @@ impl App { let mut pending_basins: Option, CliError>> = None; loop { - terminal .draw(|f| ui::draw(f, &self)) .map_err(|e| CliError::RecordWrite(format!("Failed to draw: {e}")))?; if matches!(self.screen, Screen::Splash) && splash_start.elapsed() >= splash_duration { - let mut basins_state = BasinsState { loading: pending_basins.is_none(), ..Default::default() @@ -1231,36 +1245,33 @@ impl App { // even when async events are flooding in if event::poll(Duration::from_millis(0)) .map_err(|e| CliError::RecordWrite(format!("Failed to poll events: {e}")))? - { - if let CrosstermEvent::Key(key) = event::read() + && let CrosstermEvent::Key(key) = event::read() .map_err(|e| CliError::RecordWrite(format!("Failed to read event: {e}")))? - { - - if matches!(self.screen, Screen::Splash) { - let mut basins_state = BasinsState { - loading: pending_basins.is_none(), - ..Default::default() - }; - if let Some(result) = pending_basins.take() { - match result { - Ok(basins) => { - basins_state.basins = basins; - basins_state.loading = false; - } - Err(e) => { - basins_state.loading = false; - self.message = Some(StatusMessage { - text: format!("Failed to load basins: {e}"), - level: MessageLevel::Error, - }); - } + { + if matches!(self.screen, Screen::Splash) { + let mut basins_state = BasinsState { + loading: pending_basins.is_none(), + ..Default::default() + }; + if let Some(result) = pending_basins.take() { + match result { + Ok(basins) => { + basins_state.basins = basins; + basins_state.loading = false; + } + Err(e) => { + basins_state.loading = false; + self.message = Some(StatusMessage { + text: format!("Failed to load basins: {e}"), + level: MessageLevel::Error, + }); } } - self.screen = Screen::Basins(basins_state); - continue; } - self.handle_key(key, tx.clone()); + self.screen = Screen::Basins(basins_state); + continue; } + self.handle_key(key, tx.clone()); } if self.should_quit { break; @@ -1270,12 +1281,11 @@ impl App { tokio::select! { Some(event) = rx.recv() => { - if matches!(self.screen, Screen::Splash) { - if let Event::BasinsLoaded(result) = event { + if matches!(self.screen, Screen::Splash) + && let Event::BasinsLoaded(result) = event { pending_basins = Some(result); continue; } - } self.handle_event(event); } _ = tokio::time::sleep(Duration::from_millis(16)) => {} @@ -1373,7 +1383,9 @@ impl App { Ok(record) => { if !state.paused { // Deduplicate by seq_num - skip if we already have this or a later record - let dominated = state.records.back() + let dominated = state + .records + .back() .map(|last| record.seq_num <= last.seq_num) .unwrap_or(false); if dominated { @@ -1390,7 +1402,9 @@ impl App { let elapsed = last_tick.elapsed(); if elapsed >= std::time::Duration::from_secs(1) { let secs = elapsed.as_secs_f64(); - let mibps = (state.bytes_this_second as f64) / (1024.0 * 1024.0) / secs; + let mibps = (state.bytes_this_second as f64) + / (1024.0 * 1024.0) + / secs; let recps = (state.records_this_second as f64) / secs; state.current_mibps = mibps; @@ -1457,7 +1471,9 @@ impl App { match result { Ok(record) => { // Deduplicate by seq_num - let dominated = pip.records.back() + let dominated = pip + .records + .back() .map(|last| record.seq_num <= last.seq_num) .unwrap_or(false); if dominated { @@ -1474,7 +1490,8 @@ impl App { let elapsed = last_tick.elapsed(); if elapsed >= std::time::Duration::from_secs(1) { let secs = elapsed.as_secs_f64(); - pip.current_mibps = (pip.bytes_this_second as f64) / (1024.0 * 1024.0) / secs; + pip.current_mibps = + (pip.bytes_this_second as f64) / (1024.0 * 1024.0) / secs; pip.current_recps = (pip.records_this_second as f64) / secs; pip.bytes_this_second = 0; pip.records_this_second = 0; @@ -1606,7 +1623,8 @@ impl App { timestamping_uncapped, age_input, .. - } = &mut self.input_mode { + } = &mut self.input_mode + { match result { Ok(info) => { *create_stream_on_append = Some(info.create_stream_on_append); @@ -1644,7 +1662,8 @@ impl App { delete_on_empty_min_age, age_input, .. - } = &mut self.input_mode { + } = &mut self.input_mode + { match result { Ok(info) => { *storage_class = info.storage_class; @@ -1753,7 +1772,11 @@ impl App { state.appending = false; match result { Ok((seq_num, body_preview, header_count)) => { - state.history.push(AppendResult { seq_num, body_preview, header_count }); + state.history.push(AppendResult { + seq_num, + body_preview, + header_count, + }); } Err(e) => { self.message = Some(StatusMessage { @@ -1765,7 +1788,11 @@ impl App { } } - Event::FileAppendProgress { appended, total, last_seq } => { + Event::FileAppendProgress { + appended, + total, + last_seq, + } => { if let Screen::AppendView(state) = &mut self.screen { state.file_append_progress = Some((appended, total)); if let Some(seq) = last_seq { @@ -1787,7 +1814,10 @@ impl App { Ok((total, first_seq, last_seq)) => { state.input_file.clear(); self.message = Some(StatusMessage { - text: format!("Appended {} records (seq {}..{})", total, first_seq, last_seq), + text: format!( + "Appended {} records (seq {}..{})", + total, first_seq, last_seq + ), level: MessageLevel::Success, }); } @@ -1826,10 +1856,12 @@ impl App { self.input_mode = InputMode::Normal; match result { Ok(token) => { - - self.input_mode = InputMode::ShowIssuedToken { token: token.clone() }; + self.input_mode = InputMode::ShowIssuedToken { + token: token.clone(), + }; self.message = Some(StatusMessage { - text: "Access token issued - copy it now, it won't be shown again!".to_string(), + text: "Access token issued - copy it now, it won't be shown again!" + .to_string(), level: MessageLevel::Success, }); @@ -1952,7 +1984,8 @@ impl App { state.write_bytes = sample.bytes; state.write_records = sample.records; state.elapsed_secs = sample.elapsed.as_secs_f64(); - state.progress_pct = ((state.elapsed_secs / state.duration_secs as f64) * 100.0).min(100.0); + state.progress_pct = + ((state.elapsed_secs / state.duration_secs as f64) * 100.0).min(100.0); state.write_history.push(sample.mib_per_sec); if state.write_history.len() > 60 { @@ -2019,7 +2052,6 @@ impl App { } fn handle_key(&mut self, key: KeyEvent, tx: mpsc::UnboundedSender) { - self.message = None; if !matches!(self.input_mode, InputMode::Normal) { self.handle_input_key(key, tx); @@ -2055,9 +2087,7 @@ impl App { self.should_quit = true; return; } - KeyCode::Char('q') if !matches!(self.screen, Screen::Basins(_)) => { - - } + KeyCode::Char('q') if !matches!(self.screen, Screen::Basins(_)) => {} KeyCode::Char('q') => { self.should_quit = true; return; @@ -2096,8 +2126,8 @@ impl App { // Handle IssueAccessToken submit separately to avoid borrow issues. // We need to extract values before calling the method since the match arm // holds borrows that conflict with the method call. - if matches!(key.code, KeyCode::Char(' ') | KeyCode::Enter) { - if let InputMode::IssueAccessToken { + if matches!(key.code, KeyCode::Char(' ') | KeyCode::Enter) + && let InputMode::IssueAccessToken { id, expiry, expiry_custom, @@ -2117,47 +2147,46 @@ impl App { selected, editing, } = &self.input_mode - { - if *selected == 16 && !*editing && !id.is_empty() { - - let id = id.clone(); - let expiry = *expiry; - let expiry_custom = expiry_custom.clone(); - let basins_scope = *basins_scope; - let basins_value = basins_value.clone(); - let streams_scope = *streams_scope; - let streams_value = streams_value.clone(); - let tokens_scope = *tokens_scope; - let tokens_value = tokens_value.clone(); - let account_read = *account_read; - let account_write = *account_write; - let basin_read = *basin_read; - let basin_write = *basin_write; - let stream_read = *stream_read; - let stream_write = *stream_write; - let auto_prefix_streams = *auto_prefix_streams; - self.issue_access_token_v2( - id, - expiry, - expiry_custom, - basins_scope, - basins_value, - streams_scope, - streams_value, - tokens_scope, - tokens_value, - account_read, - account_write, - basin_read, - basin_write, - stream_read, - stream_write, - auto_prefix_streams, - tx, - ); - return; - } - } + && *selected == 16 + && !*editing + && !id.is_empty() + { + let id = id.clone(); + let expiry = *expiry; + let expiry_custom = expiry_custom.clone(); + let basins_scope = *basins_scope; + let basins_value = basins_value.clone(); + let streams_scope = *streams_scope; + let streams_value = streams_value.clone(); + let tokens_scope = *tokens_scope; + let tokens_value = tokens_value.clone(); + let account_read = *account_read; + let account_write = *account_write; + let basin_read = *basin_read; + let basin_write = *basin_write; + let stream_read = *stream_read; + let stream_write = *stream_write; + let auto_prefix_streams = *auto_prefix_streams; + self.issue_access_token_v2( + id, + expiry, + expiry_custom, + basins_scope, + basins_value, + streams_scope, + streams_value, + tokens_scope, + tokens_value, + account_read, + account_write, + basin_read, + basin_write, + stream_read, + stream_write, + auto_prefix_streams, + tx, + ); + return; } match &mut self.input_mode { @@ -2181,7 +2210,6 @@ impl App { const FIELD_COUNT: usize = 12; if *editing { - match key.code { KeyCode::Esc | KeyCode::Enter => { *editing = false; @@ -2197,20 +2225,15 @@ impl App { } KeyCode::Char(c) => { if *selected == 0 { - if c.is_ascii_lowercase() || c.is_ascii_digit() || c == '-' { name.push(c); } } else if *selected == 4 { - if c.is_ascii_alphanumeric() { retention_age_input.push(c); } - } else if *selected == 8 { - - if c.is_ascii_alphanumeric() { - delete_on_empty_min_age.push(c); - } + } else if *selected == 8 && c.is_ascii_alphanumeric() { + delete_on_empty_min_age.push(c); } } _ => {} @@ -2228,7 +2251,8 @@ impl App { *selected = 7; } - if *selected == 4 && *retention_policy != RetentionPolicyOption::Age { + if *selected == 4 && *retention_policy != RetentionPolicyOption::Age + { *selected = 3; } } @@ -2237,7 +2261,8 @@ impl App { if *selected < FIELD_COUNT - 1 { *selected += 1; - if *selected == 4 && *retention_policy != RetentionPolicyOption::Age { + if *selected == 4 && *retention_policy != RetentionPolicyOption::Age + { *selected = 5; } @@ -2260,55 +2285,53 @@ impl App { } } 11 => { - if name.len() >= 8 { - let basin_name = name.clone(); let basin_scope = *scope; let csoa = *create_stream_on_append; let csor = *create_stream_on_read; let sc = storage_class.clone(); - let rp = retention_policy.clone(); + let rp = *retention_policy; let rai = retention_age_input.clone(); let tm = timestamping_mode.clone(); let tu = *timestamping_uncapped; let doe = *delete_on_empty_enabled; let doema = delete_on_empty_min_age.clone(); - let config = build_basin_config(csoa, csor, sc, rp, rai, tm, tu, doe, doema); - self.create_basin_with_config(basin_name, basin_scope, config, tx.clone()); + let config = build_basin_config( + csoa, csor, sc, rp, rai, tm, tu, doe, doema, + ); + self.create_basin_with_config( + basin_name, + basin_scope, + config, + tx.clone(), + ); } } _ => {} } } - KeyCode::Char(' ') => { - - match *selected { - 6 => *timestamping_uncapped = !*timestamping_uncapped, - 9 => *create_stream_on_append = !*create_stream_on_append, - 10 => *create_stream_on_read = !*create_stream_on_read, - _ => {} - } - } - KeyCode::Left | KeyCode::Char('h') => { - match *selected { - 2 => *storage_class = storage_class_prev(storage_class), - 3 => *retention_policy = retention_policy.toggle(), - 5 => *timestamping_mode = timestamping_mode_prev(timestamping_mode), - 7 => *delete_on_empty_enabled = !*delete_on_empty_enabled, - _ => {} - } - } - KeyCode::Right | KeyCode::Char('l') => { - match *selected { - 2 => *storage_class = storage_class_next(storage_class), - 3 => *retention_policy = retention_policy.toggle(), - 5 => *timestamping_mode = timestamping_mode_next(timestamping_mode), - 7 => *delete_on_empty_enabled = !*delete_on_empty_enabled, - _ => {} - } - } + KeyCode::Char(' ') => match *selected { + 6 => *timestamping_uncapped = !*timestamping_uncapped, + 9 => *create_stream_on_append = !*create_stream_on_append, + 10 => *create_stream_on_read = !*create_stream_on_read, + _ => {} + }, + KeyCode::Left | KeyCode::Char('h') => match *selected { + 2 => *storage_class = storage_class_prev(storage_class), + 3 => *retention_policy = retention_policy.toggle(), + 5 => *timestamping_mode = timestamping_mode_prev(timestamping_mode), + 7 => *delete_on_empty_enabled = !*delete_on_empty_enabled, + _ => {} + }, + KeyCode::Right | KeyCode::Char('l') => match *selected { + 2 => *storage_class = storage_class_next(storage_class), + 3 => *retention_policy = retention_policy.toggle(), + 5 => *timestamping_mode = timestamping_mode_next(timestamping_mode), + 7 => *delete_on_empty_enabled = !*delete_on_empty_enabled, + _ => {} + }, _ => {} } } @@ -2330,7 +2353,6 @@ impl App { const FIELD_COUNT: usize = 9; if *editing { - match key.code { KeyCode::Esc | KeyCode::Enter => { *editing = false; @@ -2346,18 +2368,13 @@ impl App { } KeyCode::Char(c) => { if *selected == 0 { - name.push(c); } else if *selected == 3 { - if c.is_ascii_alphanumeric() { retention_age_input.push(c); } - } else if *selected == 7 { - - if c.is_ascii_alphanumeric() { - delete_on_empty_min_age.push(c); - } + } else if *selected == 7 && c.is_ascii_alphanumeric() { + delete_on_empty_min_age.push(c); } } _ => {} @@ -2375,7 +2392,8 @@ impl App { *selected = 6; } - if *selected == 3 && *retention_policy != RetentionPolicyOption::Age { + if *selected == 3 && *retention_policy != RetentionPolicyOption::Age + { *selected = 2; } } @@ -2384,7 +2402,8 @@ impl App { if *selected < FIELD_COUNT - 1 { *selected += 1; - if *selected == 3 && *retention_policy != RetentionPolicyOption::Age { + if *selected == 3 && *retention_policy != RetentionPolicyOption::Age + { *selected = 4; } @@ -2407,80 +2426,76 @@ impl App { } } 8 => { - if !name.is_empty() { let basin_name = basin.clone(); let stream_name = name.clone(); let sc = storage_class.clone(); - let rp = retention_policy.clone(); + let rp = *retention_policy; let rai = retention_age_input.clone(); let tm = timestamping_mode.clone(); let tu = *timestamping_uncapped; let doe = *delete_on_empty_enabled; let doema = delete_on_empty_min_age.clone(); - let config = build_stream_config(sc, rp, rai, tm, tu, doe, doema); - self.create_stream_with_config(basin_name, stream_name, config, tx.clone()); + let config = + build_stream_config(sc, rp, rai, tm, tu, doe, doema); + self.create_stream_with_config( + basin_name, + stream_name, + config, + tx.clone(), + ); } } _ => {} } } KeyCode::Char(' ') => { - if *selected == 5 { *timestamping_uncapped = !*timestamping_uncapped; } } - KeyCode::Left | KeyCode::Char('h') => { - match *selected { - 1 => *storage_class = storage_class_prev(storage_class), - 2 => *retention_policy = retention_policy.toggle(), - 4 => *timestamping_mode = timestamping_mode_prev(timestamping_mode), - 6 => *delete_on_empty_enabled = !*delete_on_empty_enabled, - _ => {} - } - } - KeyCode::Right | KeyCode::Char('l') => { - match *selected { - 1 => *storage_class = storage_class_next(storage_class), - 2 => *retention_policy = retention_policy.toggle(), - 4 => *timestamping_mode = timestamping_mode_next(timestamping_mode), - 6 => *delete_on_empty_enabled = !*delete_on_empty_enabled, - _ => {} - } - } + KeyCode::Left | KeyCode::Char('h') => match *selected { + 1 => *storage_class = storage_class_prev(storage_class), + 2 => *retention_policy = retention_policy.toggle(), + 4 => *timestamping_mode = timestamping_mode_prev(timestamping_mode), + 6 => *delete_on_empty_enabled = !*delete_on_empty_enabled, + _ => {} + }, + KeyCode::Right | KeyCode::Char('l') => match *selected { + 1 => *storage_class = storage_class_next(storage_class), + 2 => *retention_policy = retention_policy.toggle(), + 4 => *timestamping_mode = timestamping_mode_next(timestamping_mode), + 6 => *delete_on_empty_enabled = !*delete_on_empty_enabled, + _ => {} + }, _ => {} } } } - InputMode::ConfirmDeleteBasin { basin } => { - match key.code { - KeyCode::Esc | KeyCode::Char('n') | KeyCode::Char('N') => { - self.input_mode = InputMode::Normal; - } - KeyCode::Char('y') | KeyCode::Char('Y') | KeyCode::Enter => { - let basin = basin.clone(); - self.delete_basin(basin, tx.clone()); - } - _ => {} + InputMode::ConfirmDeleteBasin { basin } => match key.code { + KeyCode::Esc | KeyCode::Char('n') | KeyCode::Char('N') => { + self.input_mode = InputMode::Normal; } - } + KeyCode::Char('y') | KeyCode::Char('Y') | KeyCode::Enter => { + let basin = basin.clone(); + self.delete_basin(basin, tx.clone()); + } + _ => {} + }, - InputMode::ConfirmDeleteStream { basin, stream } => { - match key.code { - KeyCode::Esc | KeyCode::Char('n') | KeyCode::Char('N') => { - self.input_mode = InputMode::Normal; - } - KeyCode::Char('y') | KeyCode::Char('Y') | KeyCode::Enter => { - let basin = basin.clone(); - let stream = stream.clone(); - self.delete_stream(basin, stream, tx.clone()); - } - _ => {} + InputMode::ConfirmDeleteStream { basin, stream } => match key.code { + KeyCode::Esc | KeyCode::Char('n') | KeyCode::Char('N') => { + self.input_mode = InputMode::Normal; } - } + KeyCode::Char('y') | KeyCode::Char('Y') | KeyCode::Enter => { + let basin = basin.clone(); + let stream = stream.clone(); + self.delete_stream(basin, stream, tx.clone()); + } + _ => {} + }, InputMode::ReconfigureBasin { basin, @@ -2495,12 +2510,10 @@ impl App { editing_age, age_input, } => { - const BASIN_MAX_ROW: usize = 6; if *editing_age { match key.code { KeyCode::Esc | KeyCode::Enter => { - if let Ok(secs) = age_input.parse::() { *retention_age_secs = secs; } @@ -2539,38 +2552,33 @@ impl App { } } } - KeyCode::Char(' ') => { - - match *selected { - 4 => *timestamping_uncapped = Some(!timestamping_uncapped.unwrap_or(false)), - 5 => *create_stream_on_append = Some(!create_stream_on_append.unwrap_or(false)), - 6 => *create_stream_on_read = Some(!create_stream_on_read.unwrap_or(false)), - _ => {} + KeyCode::Char(' ') => match *selected { + 4 => *timestamping_uncapped = Some(!timestamping_uncapped.unwrap_or(false)), + 5 => { + *create_stream_on_append = + Some(!create_stream_on_append.unwrap_or(false)) } - } + 6 => *create_stream_on_read = Some(!create_stream_on_read.unwrap_or(false)), + _ => {} + }, KeyCode::Enter => { - if *selected == 2 && *retention_policy == RetentionPolicyOption::Age { *editing_age = true; *age_input = retention_age_secs.to_string(); } } - KeyCode::Left | KeyCode::Char('h') => { - match *selected { - 0 => *storage_class = storage_class_prev(storage_class), - 1 => *retention_policy = retention_policy.toggle(), - 3 => *timestamping_mode = timestamping_mode_prev(timestamping_mode), - _ => {} - } - } - KeyCode::Right | KeyCode::Char('l') => { - match *selected { - 0 => *storage_class = storage_class_next(storage_class), - 1 => *retention_policy = retention_policy.toggle(), - 3 => *timestamping_mode = timestamping_mode_next(timestamping_mode), - _ => {} - } - } + KeyCode::Left | KeyCode::Char('h') => match *selected { + 0 => *storage_class = storage_class_prev(storage_class), + 1 => *retention_policy = retention_policy.toggle(), + 3 => *timestamping_mode = timestamping_mode_prev(timestamping_mode), + _ => {} + }, + KeyCode::Right | KeyCode::Char('l') => match *selected { + 0 => *storage_class = storage_class_next(storage_class), + 1 => *retention_policy = retention_policy.toggle(), + 3 => *timestamping_mode = timestamping_mode_next(timestamping_mode), + _ => {} + }, KeyCode::Char('s') => { let b = basin.clone(); let config = BasinReconfigureConfig { @@ -2602,18 +2610,14 @@ impl App { editing_age, age_input, } => { - if *editing_age { match key.code { KeyCode::Esc | KeyCode::Enter => { - if *selected == 2 { - if let Ok(secs) = age_input.parse::() { *retention_age_secs = secs; } } else if *selected == 6 { - } *editing_age = false; } @@ -2663,19 +2667,16 @@ impl App { } if *selected == 6 && !*delete_on_empty_enabled { - *selected = 5; } } } KeyCode::Char(' ') => { - if *selected == 4 { *timestamping_uncapped = Some(!timestamping_uncapped.unwrap_or(false)); } } KeyCode::Enter => { - if *selected == 2 && *retention_policy == RetentionPolicyOption::Age { *editing_age = true; *age_input = retention_age_secs.to_string(); @@ -2683,24 +2684,20 @@ impl App { *editing_age = true; } } - KeyCode::Left | KeyCode::Char('h') => { - match *selected { - 0 => *storage_class = storage_class_prev(storage_class), - 1 => *retention_policy = retention_policy.toggle(), - 3 => *timestamping_mode = timestamping_mode_prev(timestamping_mode), - 5 => *delete_on_empty_enabled = !*delete_on_empty_enabled, - _ => {} - } - } - KeyCode::Right | KeyCode::Char('l') => { - match *selected { - 0 => *storage_class = storage_class_next(storage_class), - 1 => *retention_policy = retention_policy.toggle(), - 3 => *timestamping_mode = timestamping_mode_next(timestamping_mode), - 5 => *delete_on_empty_enabled = !*delete_on_empty_enabled, - _ => {} - } - } + KeyCode::Left | KeyCode::Char('h') => match *selected { + 0 => *storage_class = storage_class_prev(storage_class), + 1 => *retention_policy = retention_policy.toggle(), + 3 => *timestamping_mode = timestamping_mode_prev(timestamping_mode), + 5 => *delete_on_empty_enabled = !*delete_on_empty_enabled, + _ => {} + }, + KeyCode::Right | KeyCode::Char('l') => match *selected { + 0 => *storage_class = storage_class_next(storage_class), + 1 => *retention_policy = retention_policy.toggle(), + 3 => *timestamping_mode = timestamping_mode_next(timestamping_mode), + 5 => *delete_on_empty_enabled = !*delete_on_empty_enabled, + _ => {} + }, KeyCode::Char('s') => { let b = basin.clone(); let s = stream.clone(); @@ -2737,41 +2734,51 @@ impl App { selected, editing, } => { - if *editing { match key.code { KeyCode::Esc | KeyCode::Enter => { *editing = false; } KeyCode::Tab if *selected == 2 => { - *ago_unit = ago_unit.next(); } - KeyCode::Backspace => { - match *selected { - 0 => { seq_num_value.pop(); } - 1 => { timestamp_value.pop(); } - 2 => { ago_value.pop(); } - 3 => { tail_offset_value.pop(); } - 4 => { count_limit.pop(); } - 5 => { byte_limit.pop(); } - 6 => { until_timestamp.pop(); } - 9 => { output_file.pop(); } - _ => {} + KeyCode::Backspace => match *selected { + 0 => { + seq_num_value.pop(); } - } - KeyCode::Char(c) if c.is_ascii_digit() => { - match *selected { - 0 => seq_num_value.push(c), - 1 => timestamp_value.push(c), - 2 => ago_value.push(c), - 3 => tail_offset_value.push(c), - 4 => count_limit.push(c), - 5 => byte_limit.push(c), - 6 => until_timestamp.push(c), - _ => {} + 1 => { + timestamp_value.pop(); } - } + 2 => { + ago_value.pop(); + } + 3 => { + tail_offset_value.pop(); + } + 4 => { + count_limit.pop(); + } + 5 => { + byte_limit.pop(); + } + 6 => { + until_timestamp.pop(); + } + 9 => { + output_file.pop(); + } + _ => {} + }, + KeyCode::Char(c) if c.is_ascii_digit() => match *selected { + 0 => seq_num_value.push(c), + 1 => timestamp_value.push(c), + 2 => ago_value.push(c), + 3 => tail_offset_value.push(c), + 4 => count_limit.push(c), + 5 => byte_limit.push(c), + 6 => until_timestamp.push(c), + _ => {} + }, KeyCode::Char(c) if *selected == 9 => { // Output file accepts any printable char output_file.push(c); @@ -2863,7 +2870,23 @@ impl App { level: MessageLevel::Info, }); } - self.start_custom_read(b, s, sf, snv, tsv, agv, agu, tov, cl, bl, ut, clp, fmt, of, tx.clone()); + self.start_custom_read( + b, + s, + sf, + snv, + tsv, + agv, + agu, + tov, + cl, + bl, + ut, + clp, + fmt, + of, + tx.clone(), + ); } _ => {} } @@ -2885,20 +2908,20 @@ impl App { KeyCode::Esc | KeyCode::Enter => { *editing = false; } - KeyCode::Backspace => { - match *selected { - 0 => { new_token.pop(); } - 1 => { current_token.pop(); } - _ => {} + KeyCode::Backspace => match *selected { + 0 => { + new_token.pop(); } - } - KeyCode::Char(c) => { - match *selected { - 0 => new_token.push(c), - 1 => current_token.push(c), - _ => {} + 1 => { + current_token.pop(); } - } + _ => {} + }, + KeyCode::Char(c) => match *selected { + 0 => new_token.push(c), + 1 => current_token.push(c), + _ => {} + }, _ => {} } return; @@ -2956,20 +2979,20 @@ impl App { KeyCode::Esc | KeyCode::Enter => { *editing = false; } - KeyCode::Backspace => { - match *selected { - 0 => { trim_point.pop(); } - 1 => { fencing_token.pop(); } - _ => {} + KeyCode::Backspace => match *selected { + 0 => { + trim_point.pop(); } - } - KeyCode::Char(c) => { - match *selected { - 0 if c.is_ascii_digit() => trim_point.push(c), - 1 => fencing_token.push(c), - _ => {} + 1 => { + fencing_token.pop(); } - } + _ => {} + }, + KeyCode::Char(c) => match *selected { + 0 if c.is_ascii_digit() => trim_point.push(c), + 1 => fencing_token.push(c), + _ => {} + }, _ => {} } return; @@ -3044,16 +3067,24 @@ impl App { KeyCode::Esc | KeyCode::Enter => { *editing = false; } - KeyCode::Backspace => { - match *selected { - 0 => { id.pop(); } - 2 => { expiry_custom.pop(); } - 4 => { basins_value.pop(); } - 6 => { streams_value.pop(); } - 8 => { tokens_value.pop(); } - _ => {} + KeyCode::Backspace => match *selected { + 0 => { + id.pop(); } - } + 2 => { + expiry_custom.pop(); + } + 4 => { + basins_value.pop(); + } + 6 => { + streams_value.pop(); + } + 8 => { + tokens_value.pop(); + } + _ => {} + }, KeyCode::Char(c) => { match *selected { 0 => { @@ -3090,13 +3121,22 @@ impl App { if *selected == 2 && *expiry != ExpiryOption::Custom { *selected = 1; } - if *selected == 4 && !matches!(basins_scope, ScopeOption::Prefix | ScopeOption::Exact) { + if *selected == 4 + && !matches!(basins_scope, ScopeOption::Prefix | ScopeOption::Exact) + { *selected = 3; } - if *selected == 6 && !matches!(streams_scope, ScopeOption::Prefix | ScopeOption::Exact) { + if *selected == 6 + && !matches!( + streams_scope, + ScopeOption::Prefix | ScopeOption::Exact + ) + { *selected = 5; } - if *selected == 8 && !matches!(tokens_scope, ScopeOption::Prefix | ScopeOption::Exact) { + if *selected == 8 + && !matches!(tokens_scope, ScopeOption::Prefix | ScopeOption::Exact) + { *selected = 7; } } @@ -3108,13 +3148,22 @@ impl App { if *selected == 2 && *expiry != ExpiryOption::Custom { *selected = 3; } - if *selected == 4 && !matches!(basins_scope, ScopeOption::Prefix | ScopeOption::Exact) { + if *selected == 4 + && !matches!(basins_scope, ScopeOption::Prefix | ScopeOption::Exact) + { *selected = 5; } - if *selected == 6 && !matches!(streams_scope, ScopeOption::Prefix | ScopeOption::Exact) { + if *selected == 6 + && !matches!( + streams_scope, + ScopeOption::Prefix | ScopeOption::Exact + ) + { *selected = 7; } - if *selected == 8 && !matches!(tokens_scope, ScopeOption::Prefix | ScopeOption::Exact) { + if *selected == 8 + && !matches!(tokens_scope, ScopeOption::Prefix | ScopeOption::Exact) + { *selected = 9; } } @@ -3122,10 +3171,34 @@ impl App { KeyCode::Left | KeyCode::Right => { let forward = key.code == KeyCode::Right; match *selected { - 1 => *expiry = if forward { expiry.next() } else { expiry.prev() }, - 3 => *basins_scope = if forward { basins_scope.next() } else { basins_scope.prev() }, - 5 => *streams_scope = if forward { streams_scope.next() } else { streams_scope.prev() }, - 7 => *tokens_scope = if forward { tokens_scope.next() } else { tokens_scope.prev() }, + 1 => { + *expiry = if forward { + expiry.next() + } else { + expiry.prev() + } + } + 3 => { + *basins_scope = if forward { + basins_scope.next() + } else { + basins_scope.prev() + } + } + 5 => { + *streams_scope = if forward { + streams_scope.next() + } else { + streams_scope.prev() + } + } + 7 => { + *tokens_scope = if forward { + tokens_scope.next() + } else { + tokens_scope.prev() + } + } _ => {} } } @@ -3154,18 +3227,16 @@ impl App { } } - InputMode::ConfirmRevokeToken { token_id } => { - match key.code { - KeyCode::Esc | KeyCode::Char('n') | KeyCode::Char('N') => { - self.input_mode = InputMode::Normal; - } - KeyCode::Char('y') | KeyCode::Char('Y') | KeyCode::Enter => { - let id = token_id.clone(); - self.revoke_access_token(id, tx.clone()); - } - _ => {} + InputMode::ConfirmRevokeToken { token_id } => match key.code { + KeyCode::Esc | KeyCode::Char('n') | KeyCode::Char('N') => { + self.input_mode = InputMode::Normal; } - } + KeyCode::Char('y') | KeyCode::Char('Y') | KeyCode::Enter => { + let id = token_id.clone(); + self.revoke_access_token(id, tx.clone()); + } + _ => {} + }, InputMode::ShowIssuedToken { .. } => { // Any key dismisses the token display @@ -3219,7 +3290,9 @@ impl App { } // Get filtered list length for bounds checking - let filtered: Vec<_> = state.basins.iter() + let filtered: Vec<_> = state + .basins + .iter() .filter(|b| state.filter.is_empty() || b.name.to_string().contains(&state.filter)) .collect(); let filtered_len = filtered.len(); @@ -3368,7 +3441,9 @@ impl App { } // Get filtered list length for bounds checking - let filtered: Vec<_> = state.streams.iter() + let filtered: Vec<_> = state + .streams + .iter() .filter(|s| state.filter.is_empty() || s.name.to_string().contains(&state.filter)) .collect(); let filtered_len = filtered.len(); @@ -3664,7 +3739,6 @@ impl App { state.hide_list = !state.hide_list; } KeyCode::Enter | KeyCode::Char('h') => { - if !state.records.is_empty() { state.show_detail = true; } @@ -3707,7 +3781,9 @@ impl App { fn load_basins(&self, tx: mpsc::UnboundedSender) { let Some(s2) = self.s2.clone() else { - let _ = tx.send(Event::BasinsLoaded(Err(CliError::Config(crate::error::CliConfigError::MissingAccessToken)))); + let _ = tx.send(Event::BasinsLoaded(Err(CliError::Config( + crate::error::CliConfigError::MissingAccessToken, + )))); return; }; tokio::spawn(async move { @@ -3735,7 +3811,9 @@ impl App { fn load_streams(&self, basin_name: BasinName, tx: mpsc::UnboundedSender) { let Some(s2) = self.s2.clone() else { - let _ = tx.send(Event::StreamsLoaded(Err(CliError::Config(crate::error::CliConfigError::MissingAccessToken)))); + let _ = tx.send(Event::StreamsLoaded(Err(CliError::Config( + crate::error::CliConfigError::MissingAccessToken, + )))); return; }; tokio::spawn(async move { @@ -3806,16 +3884,23 @@ impl App { }); } - fn create_basin_with_config(&mut self, name: String, scope: BasinScopeOption, config: BasinConfig, tx: mpsc::UnboundedSender) { + fn create_basin_with_config( + &mut self, + name: String, + scope: BasinScopeOption, + config: BasinConfig, + tx: mpsc::UnboundedSender, + ) { self.input_mode = InputMode::Normal; let s2 = self.s2.clone().expect("S2 client not initialized"); let tx_refresh = tx.clone(); tokio::spawn(async move { - let basin_name: BasinName = match name.parse() { Ok(n) => n, Err(e) => { - let _ = tx.send(Event::BasinCreated(Err(CliError::RecordWrite(format!("Invalid basin name: {e}"))))); + let _ = tx.send(Event::BasinCreated(Err(CliError::RecordWrite(format!( + "Invalid basin name: {e}" + ))))); return; } }; @@ -3826,7 +3911,11 @@ impl App { .with_config(config.into()) .with_scope(sdk_scope); - match s2.create_basin(input).await.map_err(|e| CliError::op(crate::error::OpKind::CreateBasin, e)) { + match s2 + .create_basin(input) + .await + .map_err(|e| CliError::op(crate::error::OpKind::CreateBasin, e)) + { Ok(info) => { let _ = tx.send(Event::BasinCreated(Ok(info))); // Trigger refresh @@ -3883,17 +3972,24 @@ impl App { }); } - fn create_stream_with_config(&mut self, basin: BasinName, name: String, config: StreamConfig, tx: mpsc::UnboundedSender) { + fn create_stream_with_config( + &mut self, + basin: BasinName, + name: String, + config: StreamConfig, + tx: mpsc::UnboundedSender, + ) { self.input_mode = InputMode::Normal; let s2 = self.s2.clone().expect("S2 client not initialized"); let tx_refresh = tx.clone(); let basin_clone = basin.clone(); tokio::spawn(async move { - let stream_name: StreamName = match name.parse() { Ok(n) => n, Err(e) => { - let _ = tx.send(Event::StreamCreated(Err(CliError::RecordWrite(format!("Invalid stream name: {e}"))))); + let _ = tx.send(Event::StreamCreated(Err(CliError::RecordWrite(format!( + "Invalid stream name: {e}" + ))))); return; } }; @@ -3934,7 +4030,12 @@ impl App { }); } - fn delete_stream(&mut self, basin: BasinName, stream: StreamName, tx: mpsc::UnboundedSender) { + fn delete_stream( + &mut self, + basin: BasinName, + stream: StreamName, + tx: mpsc::UnboundedSender, + ) { let s2 = self.s2.clone().expect("S2 client not initialized"); let tx_refresh = tx.clone(); let name = stream.to_string(); @@ -4037,10 +4138,9 @@ impl App { } } Err(e) => { - let _ = tx.send(Event::RecordReceived(Err(crate::error::CliError::op( - crate::error::OpKind::Read, - e, - )))); + let _ = tx.send(Event::RecordReceived(Err( + crate::error::CliError::op(crate::error::OpKind::Read, e), + ))); return; } } @@ -4110,10 +4210,9 @@ impl App { } } Err(e) => { - let _ = tx.send(Event::PipRecordReceived(Err(crate::error::CliError::op( - crate::error::OpKind::Read, - e, - )))); + let _ = tx.send(Event::PipRecordReceived(Err( + crate::error::CliError::op(crate::error::OpKind::Read, e), + ))); return; } } @@ -4150,6 +4249,7 @@ impl App { } /// Start reading with custom configuration + #[allow(clippy::too_many_arguments)] fn start_custom_read( &mut self, basin_name: BasinName, @@ -4179,7 +4279,11 @@ impl App { loading: true, show_detail: false, hide_list: false, - output_file: if has_output { Some(output_file.clone()) } else { None }, + output_file: if has_output { + Some(output_file.clone()) + } else { + None + }, throughput_history: Vec::new(), records_per_sec_history: Vec::new(), current_mibps: 0.0, @@ -4197,7 +4301,6 @@ impl App { }; tokio::spawn(async move { - let seq_num = if start_from == ReadStartFrom::SeqNum { seq_num_value.parse().ok() } else { @@ -4212,7 +4315,7 @@ impl App { let ago = if start_from == ReadStartFrom::Ago { ago_value.parse::().ok().map(|v| { - let secs = ago_unit.to_seconds(v); + let secs = ago_unit.as_seconds(v); humantime::Duration::from(std::time::Duration::from_secs(secs)) }) } else { @@ -4261,7 +4364,9 @@ impl App { match tokio::fs::File::create(&output_file).await { Ok(f) => Some(f), Err(e) => { - let _ = tx.send(Event::Error(crate::error::CliError::RecordWrite(e.to_string()))); + let _ = tx.send(Event::Error(crate::error::CliError::RecordWrite( + e.to_string(), + ))); return; } } @@ -4281,33 +4386,42 @@ impl App { if let Some(ref mut writer) = file_writer { let line = match record_format { RecordFormat::Text => { - format!("{}\n", String::from_utf8_lossy(&record.body)) + format!( + "{}\n", + String::from_utf8_lossy(&record.body) + ) } RecordFormat::Json => { - format!("{}\n", serde_json::json!({ - "seq_num": record.seq_num, - "timestamp": record.timestamp, - "headers": record.headers.iter().map(|h| { - serde_json::json!({ - "name": String::from_utf8_lossy(&h.name), - "value": String::from_utf8_lossy(&h.value) - }) - }).collect::>(), - "body": String::from_utf8_lossy(&record.body).to_string() - })) + format!( + "{}\n", + serde_json::json!({ + "seq_num": record.seq_num, + "timestamp": record.timestamp, + "headers": record.headers.iter().map(|h| { + serde_json::json!({ + "name": String::from_utf8_lossy(&h.name), + "value": String::from_utf8_lossy(&h.value) + }) + }).collect::>(), + "body": String::from_utf8_lossy(&record.body).to_string() + }) + ) } RecordFormat::JsonBase64 => { - format!("{}\n", serde_json::json!({ - "seq_num": record.seq_num, - "timestamp": record.timestamp, - "headers": record.headers.iter().map(|h| { - serde_json::json!({ - "name": String::from_utf8_lossy(&h.name), - "value": String::from_utf8_lossy(&h.value) - }) - }).collect::>(), - "body": base64ct::Base64::encode_string(&record.body) - })) + format!( + "{}\n", + serde_json::json!({ + "seq_num": record.seq_num, + "timestamp": record.timestamp, + "headers": record.headers.iter().map(|h| { + serde_json::json!({ + "name": String::from_utf8_lossy(&h.name), + "value": String::from_utf8_lossy(&h.value) + }) + }).collect::>(), + "body": base64ct::Base64::encode_string(&record.body) + }) + ) } }; let _ = writer.write_all(line.as_bytes()).await; @@ -4319,10 +4433,9 @@ impl App { } } Err(e) => { - let _ = tx.send(Event::RecordReceived(Err(crate::error::CliError::op( - crate::error::OpKind::Read, - e, - )))); + let _ = tx.send(Event::RecordReceived(Err( + crate::error::CliError::op(crate::error::OpKind::Read, e), + ))); return; } } @@ -4342,22 +4455,30 @@ impl App { match ops::get_basin_config(&s2, &basin).await { Ok(config) => { // Extract default stream config info - let (storage_class, retention_age_secs, timestamping_mode, timestamping_uncapped) = - if let Some(default_config) = &config.default_stream_config { - let sc = default_config.storage_class.map(StorageClass::from); - let age = match default_config.retention_policy { - Some(s2_sdk::types::RetentionPolicy::Age(secs)) => Some(secs), - _ => None, - }; - let ts_mode = default_config.timestamping.as_ref() - .and_then(|t| t.mode.map(TimestampingMode::from)); - let ts_uncapped = default_config.timestamping.as_ref() - .map(|t| t.uncapped) - .unwrap_or(false); - (sc, age, ts_mode, ts_uncapped) - } else { - (None, None, None, false) + let ( + storage_class, + retention_age_secs, + timestamping_mode, + timestamping_uncapped, + ) = if let Some(default_config) = &config.default_stream_config { + let sc = default_config.storage_class.map(StorageClass::from); + let age = match default_config.retention_policy { + Some(s2_sdk::types::RetentionPolicy::Age(secs)) => Some(secs), + _ => None, }; + let ts_mode = default_config + .timestamping + .as_ref() + .and_then(|t| t.mode.map(TimestampingMode::from)); + let ts_uncapped = default_config + .timestamping + .as_ref() + .map(|t| t.uncapped) + .unwrap_or(false); + (sc, age, ts_mode, ts_uncapped) + } else { + (None, None, None, false) + }; let info = BasinConfigInfo { create_stream_on_append: config.create_stream_on_append, @@ -4392,13 +4513,17 @@ impl App { Some(s2_sdk::types::RetentionPolicy::Age(secs)) => Some(secs), _ => None, }; - let timestamping_mode = config.timestamping.as_ref() + let timestamping_mode = config + .timestamping + .as_ref() .and_then(|t| t.mode.map(TimestampingMode::from)); - let timestamping_uncapped = config.timestamping.as_ref() + let timestamping_uncapped = config + .timestamping + .as_ref() .map(|t| t.uncapped) .unwrap_or(false); - let delete_on_empty_min_age_secs = config.delete_on_empty - .map(|d| d.min_age_secs); + let delete_on_empty_min_age_secs = + config.delete_on_empty.map(|d| d.min_age_secs); let info = StreamConfigInfo { storage_class, @@ -4425,20 +4550,22 @@ impl App { let s2 = self.s2.clone().expect("S2 client not initialized"); let tx_refresh = tx.clone(); tokio::spawn(async move { - let retention_policy = match config.retention_policy { RetentionPolicyOption::Infinite => Some(crate::types::RetentionPolicy::Infinite), - RetentionPolicyOption::Age => Some(crate::types::RetentionPolicy::Age(Duration::from_secs(config.retention_age_secs))), + RetentionPolicyOption::Age => Some(crate::types::RetentionPolicy::Age( + Duration::from_secs(config.retention_age_secs), + )), }; - let timestamping = if config.timestamping_mode.is_some() || config.timestamping_uncapped.is_some() { - Some(crate::types::TimestampingConfig { - timestamping_mode: config.timestamping_mode, - timestamping_uncapped: config.timestamping_uncapped, - }) - } else { - None - }; + let timestamping = + if config.timestamping_mode.is_some() || config.timestamping_uncapped.is_some() { + Some(crate::types::TimestampingConfig { + timestamping_mode: config.timestamping_mode, + timestamping_uncapped: config.timestamping_uncapped, + }) + } else { + None + }; let default_stream_config = StreamConfig { storage_class: config.storage_class, @@ -4492,22 +4619,27 @@ impl App { tokio::spawn(async move { let retention_policy = match config.retention_policy { RetentionPolicyOption::Infinite => Some(crate::types::RetentionPolicy::Infinite), - RetentionPolicyOption::Age => Some(crate::types::RetentionPolicy::Age(Duration::from_secs(config.retention_age_secs))), + RetentionPolicyOption::Age => Some(crate::types::RetentionPolicy::Age( + Duration::from_secs(config.retention_age_secs), + )), }; - let timestamping = if config.timestamping_mode.is_some() || config.timestamping_uncapped.is_some() { - Some(crate::types::TimestampingConfig { - timestamping_mode: config.timestamping_mode, - timestamping_uncapped: config.timestamping_uncapped, - }) - } else { - None - }; + let timestamping = + if config.timestamping_mode.is_some() || config.timestamping_uncapped.is_some() { + Some(crate::types::TimestampingConfig { + timestamping_mode: config.timestamping_mode, + timestamping_uncapped: config.timestamping_uncapped, + }) + } else { + None + }; let delete_on_empty = if config.delete_on_empty_enabled { humantime::parse_duration(&config.delete_on_empty_min_age) .ok() - .map(|d| crate::types::DeleteOnEmptyConfig { delete_on_empty_min_age: d }) + .map(|d| crate::types::DeleteOnEmptyConfig { + delete_on_empty_min_age: d, + }) } else { None }; @@ -4599,7 +4731,6 @@ impl App { state.editing_header_key = false; } } else { - if !state.header_key_input.is_empty() { state.headers.push(( state.header_key_input.clone(), @@ -4619,42 +4750,52 @@ impl App { // Toggle between key and value in headers state.editing_header_key = !state.editing_header_key; } - KeyCode::Backspace => { - match state.selected { - 0 => { state.body.pop(); } - 1 => { - if state.editing_header_key { - state.header_key_input.pop(); - } else { - state.header_value_input.pop(); - } + KeyCode::Backspace => match state.selected { + 0 => { + state.body.pop(); + } + 1 => { + if state.editing_header_key { + state.header_key_input.pop(); + } else { + state.header_value_input.pop(); } - 2 => { state.match_seq_num.pop(); } - 3 => { state.fencing_token.pop(); } - 4 => { state.input_file.pop(); } - _ => {} } - } - KeyCode::Char(c) => { - match state.selected { - 0 => { state.body.push(c); } - 1 => { - if state.editing_header_key { - state.header_key_input.push(c); - } else { - state.header_value_input.push(c); - } + 2 => { + state.match_seq_num.pop(); + } + 3 => { + state.fencing_token.pop(); + } + 4 => { + state.input_file.pop(); + } + _ => {} + }, + KeyCode::Char(c) => match state.selected { + 0 => { + state.body.push(c); + } + 1 => { + if state.editing_header_key { + state.header_key_input.push(c); + } else { + state.header_value_input.push(c); } - 2 => { - if c.is_ascii_digit() { - state.match_seq_num.push(c); - } + } + 2 => { + if c.is_ascii_digit() { + state.match_seq_num.push(c); } - 3 => { state.fencing_token.push(c); } - 4 => { state.input_file.push(c); } - _ => {} } - } + 3 => { + state.fencing_token.push(c); + } + 4 => { + state.input_file.push(c); + } + _ => {} + }, _ => {} } return; @@ -4705,7 +4846,14 @@ impl App { }; state.appending = true; state.file_append_progress = Some((0, 0)); - self.append_from_file(basin_name, stream_name, file_path, input_format, fencing_token, tx); + self.append_from_file( + basin_name, + stream_name, + file_path, + input_format, + fencing_token, + tx, + ); } else if !state.body.is_empty() { // Append single record let basin_name = state.basin_name.clone(); @@ -4720,7 +4868,15 @@ impl App { }; state.body.clear(); state.appending = true; - self.append_record(basin_name, stream_name, body, headers, match_seq_num, fencing_token, tx); + self.append_record( + basin_name, + stream_name, + body, + headers, + match_seq_num, + fencing_token, + tx, + ); } } else { // Start editing the selected field @@ -4735,6 +4891,7 @@ impl App { } /// Append a single record to the stream + #[allow(clippy::too_many_arguments)] fn append_record( &self, basin_name: BasinName, @@ -4754,16 +4911,18 @@ impl App { let header_count = headers.len(); tokio::spawn(async move { - use s2_sdk::types::{AppendInput, AppendRecord, AppendRecordBatch, FencingToken, Header}; + use s2_sdk::types::{ + AppendInput, AppendRecord, AppendRecordBatch, FencingToken, Header, + }; let stream = s2.basin(basin_name).stream(stream_name); let mut record = match AppendRecord::new(body.into_bytes()) { Ok(r) => r, Err(e) => { - let _ = tx.send(Event::RecordAppended(Err(crate::error::CliError::RecordWrite( - e.to_string(), - )))); + let _ = tx.send(Event::RecordAppended(Err( + crate::error::CliError::RecordWrite(e.to_string()), + ))); return; } }; @@ -4775,9 +4934,9 @@ impl App { record = match record.with_headers(parsed_headers) { Ok(r) => r, Err(e) => { - let _ = tx.send(Event::RecordAppended(Err(crate::error::CliError::RecordWrite( - e.to_string(), - )))); + let _ = tx.send(Event::RecordAppended(Err( + crate::error::CliError::RecordWrite(e.to_string()), + ))); return; } }; @@ -4786,9 +4945,9 @@ impl App { let records = match AppendRecordBatch::try_from_iter([record]) { Ok(batch) => batch, Err(e) => { - let _ = tx.send(Event::RecordAppended(Err(crate::error::CliError::RecordWrite( - e.to_string(), - )))); + let _ = tx.send(Event::RecordAppended(Err( + crate::error::CliError::RecordWrite(e.to_string()), + ))); return; } }; @@ -4803,9 +4962,12 @@ impl App { input = input.with_fencing_token(token); } Err(e) => { - let _ = tx.send(Event::RecordAppended(Err(crate::error::CliError::RecordWrite( - format!("Invalid fencing token: {}", e), - )))); + let _ = tx.send(Event::RecordAppended(Err( + crate::error::CliError::RecordWrite(format!( + "Invalid fencing token: {}", + e + )), + ))); return; } } @@ -4813,7 +4975,11 @@ impl App { match stream.append(input).await { Ok(output) => { - let _ = tx.send(Event::RecordAppended(Ok((output.start.seq_num, body_preview, header_count)))); + let _ = tx.send(Event::RecordAppended(Ok(( + output.start.seq_num, + body_preview, + header_count, + )))); } Err(e) => { let _ = tx.send(Event::RecordAppended(Err(crate::error::CliError::op( @@ -4838,17 +5004,22 @@ impl App { let s2 = self.s2.clone().expect("S2 client not initialized"); tokio::spawn(async move { - use s2_sdk::types::{AppendInput, AppendRecord, AppendRecordBatch, FencingToken, Header}; - use tokio::io::{AsyncBufReadExt, BufReader}; use base64ct::{Base64, Encoding}; + use s2_sdk::types::{ + AppendInput, AppendRecord, AppendRecordBatch, FencingToken, Header, + }; + use tokio::io::{AsyncBufReadExt, BufReader}; // Open and read the file let file = match tokio::fs::File::open(&file_path).await { Ok(f) => f, Err(e) => { - let _ = tx.send(Event::FileAppendComplete(Err(crate::error::CliError::RecordReaderInit( - format!("Failed to open file '{}': {}", file_path, e), - )))); + let _ = tx.send(Event::FileAppendComplete(Err( + crate::error::CliError::RecordReaderInit(format!( + "Failed to open file '{}': {}", + file_path, e + )), + ))); return; } }; @@ -4866,9 +5037,11 @@ impl App { let total = all_lines.len(); if total == 0 { - let _ = tx.send(Event::FileAppendComplete(Err(crate::error::CliError::RecordReaderInit( - "File is empty or contains no valid records".to_string(), - )))); + let _ = tx.send(Event::FileAppendComplete(Err( + crate::error::CliError::RecordReaderInit( + "File is empty or contains no valid records".to_string(), + ), + ))); return; } @@ -4878,8 +5051,7 @@ impl App { let parse_line = |line: &str, format: InputFormat| -> Result { match format { InputFormat::Text => { - AppendRecord::new(line.as_bytes().to_vec()) - .map_err(|e| e.to_string()) + AppendRecord::new(line.as_bytes().to_vec()).map_err(|e| e.to_string()) } InputFormat::Json | InputFormat::JsonBase64 => { // Parse JSON: {"body": "...", "headers": [["key", "value"], ...], "timestamp": ...} @@ -4904,29 +5076,33 @@ impl App { parsed.body.into_bytes() }; - let mut record = AppendRecord::new(body_bytes) - .map_err(|e| e.to_string())?; + let mut record = + AppendRecord::new(body_bytes).map_err(|e| e.to_string())?; // Add headers if !parsed.headers.is_empty() { - let headers: Result, String> = parsed.headers + let headers: Result, String> = parsed + .headers .into_iter() .map(|(k, v)| { let key_bytes = if format == InputFormat::JsonBase64 { - Base64::decode_vec(&k).map_err(|_| format!("Invalid base64 in header key: {}", k))? + Base64::decode_vec(&k).map_err(|_| { + format!("Invalid base64 in header key: {}", k) + })? } else { k.into_bytes() }; let val_bytes = if format == InputFormat::JsonBase64 { - Base64::decode_vec(&v).map_err(|_| format!("Invalid base64 in header value: {}", v))? + Base64::decode_vec(&v).map_err(|_| { + format!("Invalid base64 in header value: {}", v) + })? } else { v.into_bytes() }; Ok(Header::new(key_bytes, val_bytes)) }) .collect(); - record = record.with_headers(headers?) - .map_err(|e| e.to_string())?; + record = record.with_headers(headers?).map_err(|e| e.to_string())?; } // Add timestamp if provided @@ -4955,9 +5131,9 @@ impl App { let records = match records { Ok(r) => r, Err(e) => { - let _ = tx.send(Event::FileAppendComplete(Err(crate::error::CliError::RecordWrite( - format!("Invalid record: {}", e), - )))); + let _ = tx.send(Event::FileAppendComplete(Err( + crate::error::CliError::RecordWrite(format!("Invalid record: {}", e)), + ))); return; } }; @@ -4965,9 +5141,12 @@ impl App { let batch = match AppendRecordBatch::try_from_iter(records) { Ok(b) => b, Err(e) => { - let _ = tx.send(Event::FileAppendComplete(Err(crate::error::CliError::RecordWrite( - format!("Failed to create batch: {}", e), - )))); + let _ = tx.send(Event::FileAppendComplete(Err( + crate::error::CliError::RecordWrite(format!( + "Failed to create batch: {}", + e + )), + ))); return; } }; @@ -4981,9 +5160,12 @@ impl App { input = input.with_fencing_token(token); } Err(e) => { - let _ = tx.send(Event::FileAppendComplete(Err(crate::error::CliError::RecordWrite( - format!("Invalid fencing token: {}", e), - )))); + let _ = tx.send(Event::FileAppendComplete(Err( + crate::error::CliError::RecordWrite(format!( + "Invalid fencing token: {}", + e + )), + ))); return; } } @@ -5005,10 +5187,9 @@ impl App { }); } Err(e) => { - let _ = tx.send(Event::FileAppendComplete(Err(crate::error::CliError::op( - crate::error::OpKind::Append, - e, - )))); + let _ = tx.send(Event::FileAppendComplete(Err( + crate::error::CliError::op(crate::error::OpKind::Append, e), + ))); return; } } @@ -5066,9 +5247,12 @@ impl App { let new_fencing_token = match new_token.parse::() { Ok(token) => token, Err(e) => { - let _ = tx.send(Event::StreamFenced(Err(crate::error::CliError::RecordWrite( - format!("Invalid new fencing token: {}", e), - )))); + let _ = tx.send(Event::StreamFenced(Err( + crate::error::CliError::RecordWrite(format!( + "Invalid new fencing token: {}", + e + )), + ))); return; } }; @@ -5077,26 +5261,29 @@ impl App { let records = match AppendRecordBatch::try_from_iter([record]) { Ok(batch) => batch, Err(e) => { - let _ = tx.send(Event::StreamFenced(Err(crate::error::CliError::RecordWrite( - e.to_string(), - )))); + let _ = tx.send(Event::StreamFenced(Err( + crate::error::CliError::RecordWrite(e.to_string()), + ))); return; } }; let mut input = AppendInput::new(records); - if let Some(token_str) = current_token { - if !token_str.is_empty() { - match token_str.parse::() { - Ok(token) => { - input = input.with_fencing_token(token); - } - Err(e) => { - let _ = tx.send(Event::StreamFenced(Err(crate::error::CliError::RecordWrite( - format!("Invalid current fencing token: {}", e), - )))); - return; - } + if let Some(token_str) = current_token + && !token_str.is_empty() + { + match token_str.parse::() { + Ok(token) => { + input = input.with_fencing_token(token); + } + Err(e) => { + let _ = tx.send(Event::StreamFenced(Err( + crate::error::CliError::RecordWrite(format!( + "Invalid current fencing token: {}", + e + )), + ))); + return; } } } @@ -5135,26 +5322,29 @@ impl App { let records = match AppendRecordBatch::try_from_iter([record]) { Ok(batch) => batch, Err(e) => { - let _ = tx.send(Event::StreamTrimmed(Err(crate::error::CliError::RecordWrite( - e.to_string(), - )))); + let _ = tx.send(Event::StreamTrimmed(Err( + crate::error::CliError::RecordWrite(e.to_string()), + ))); return; } }; let mut input = AppendInput::new(records); - if let Some(token_str) = fencing_token { - if !token_str.is_empty() { - match token_str.parse::() { - Ok(token) => { - input = input.with_fencing_token(token); - } - Err(e) => { - let _ = tx.send(Event::StreamTrimmed(Err(crate::error::CliError::RecordWrite( - format!("Invalid fencing token: {}", e), - )))); - return; - } + if let Some(token_str) = fencing_token + && !token_str.is_empty() + { + match token_str.parse::() { + Ok(token) => { + input = input.with_fencing_token(token); + } + Err(e) => { + let _ = tx.send(Event::StreamTrimmed(Err( + crate::error::CliError::RecordWrite(format!( + "Invalid fencing token: {}", + e + )), + ))); + return; } } } @@ -5235,7 +5425,10 @@ impl App { .iter() .filter(|t| { state.filter.is_empty() - || t.id.to_string().to_lowercase().contains(&state.filter.to_lowercase()) + || t.id + .to_string() + .to_lowercase() + .contains(&state.filter.to_lowercase()) }) .collect(); @@ -5265,7 +5458,6 @@ impl App { state.filter_active = true; } KeyCode::Char('c') => { - self.input_mode = InputMode::IssueAccessToken { id: String::new(), expiry: ExpiryOption::ThirtyDays, @@ -5288,7 +5480,6 @@ impl App { }; } KeyCode::Char('d') => { - if let Some(token) = filtered_tokens.get(state.selected) { self.input_mode = InputMode::ConfirmRevokeToken { token_id: token.id.to_string(), @@ -5296,7 +5487,6 @@ impl App { } } KeyCode::Char('r') => { - state.loading = true; self.load_access_tokens(tx); } @@ -5338,7 +5528,10 @@ impl App { match Self::create_s2_client(&state.access_token) { Ok(s2) => { // Save the token to config - if let Err(e) = config::set_config_value(ConfigKey::AccessToken, state.access_token.clone()) { + if let Err(e) = config::set_config_value( + ConfigKey::AccessToken, + state.access_token.clone(), + ) { state.error = Some(format!("Failed to save config: {}", e)); state.validating = false; return; @@ -5392,9 +5585,15 @@ impl App { } KeyCode::Backspace => { match state.selected { - 0 => { state.access_token.pop(); } - 1 => { state.account_endpoint.pop(); } - 2 => { state.basin_endpoint.pop(); } + 0 => { + state.access_token.pop(); + } + 1 => { + state.account_endpoint.pop(); + } + 2 => { + state.basin_endpoint.pop(); + } _ => {} } state.has_changes = true; @@ -5418,7 +5617,8 @@ impl App { self.should_quit = true; } KeyCode::Char('j') | KeyCode::Down => { - if state.selected < 4 { // 0=token, 1=account, 2=basin, 3=compression, 4=save + if state.selected < 4 { + // 0=token, 1=account, 2=basin, 3=compression, 4=save state.selected += 1; } } @@ -5428,7 +5628,6 @@ impl App { } } KeyCode::Char('e') | KeyCode::Enter if state.selected < 3 => { - state.editing = true; } KeyCode::Char('h') | KeyCode::Left if state.selected == 3 => { @@ -5463,7 +5662,8 @@ impl App { self.s2 = Some(s2); } Err(e) => { - state.message = Some(format!("Token saved but client error: {}", e)); + state.message = + Some(format!("Token saved but client error: {}", e)); } } } @@ -5531,7 +5731,6 @@ impl App { let tx_refresh = tx.clone(); tokio::spawn(async move { - let token_id: AccessTokenId = match id.parse() { Ok(id) => id, Err(e) => { @@ -5591,9 +5790,13 @@ impl App { let expires_in_str = match expiry { ExpiryOption::Never => None, ExpiryOption::Custom => { - if expiry_custom.is_empty() { None } else { Some(expiry_custom.clone()) } + if expiry_custom.is_empty() { + None + } else { + Some(expiry_custom.clone()) + } } - _ => expiry.to_duration_str().map(|s| s.to_string()), + _ => expiry.duration_str().map(|s| s.to_string()), }; let basins_matcher = match basins_scope { ScopeOption::All => None, @@ -5620,17 +5823,21 @@ impl App { expires_in: expires_in_str.and_then(|s| s.parse().ok()), expires_at: None, auto_prefix_streams, - basins: basins_matcher.and_then(|s| if s.is_empty() && matches!(basins_scope, ScopeOption::None) { - // For "None" scope, we don't pass anything (API default is all) - // Actually, to restrict to none, we need special handling - None - } else if s.is_empty() { - None - } else { - s.parse().ok() + basins: basins_matcher.and_then(|s| { + if s.is_empty() && matches!(basins_scope, ScopeOption::None) { + // For "None" scope, we don't pass anything (API default is all) + // Actually, to restrict to none, we need special handling + None + } else if s.is_empty() { + None + } else { + s.parse().ok() + } }), - streams: streams_matcher.and_then(|s| if s.is_empty() { None } else { s.parse().ok() }), - access_tokens: tokens_matcher.and_then(|s| if s.is_empty() { None } else { s.parse().ok() }), + streams: streams_matcher + .and_then(|s| if s.is_empty() { None } else { s.parse().ok() }), + access_tokens: tokens_matcher + .and_then(|s| if s.is_empty() { None } else { s.parse().ok() }), op_group_perms: None, ops: operations, }; @@ -5667,7 +5874,6 @@ impl App { let tx_refresh = tx.clone(); tokio::spawn(async move { - let token_id: AccessTokenId = match id.parse() { Ok(id) => id, Err(e) => { @@ -5731,7 +5937,9 @@ impl App { fn open_basin_metrics(&mut self, basin_name: BasinName, tx: mpsc::UnboundedSender) { let (year, month, day) = Self::today(); self.screen = Screen::MetricsView(MetricsViewState { - metrics_type: MetricsType::Basin { basin_name: basin_name.clone() }, + metrics_type: MetricsType::Basin { + basin_name: basin_name.clone(), + }, metrics: Vec::new(), selected_category: MetricCategory::Storage, time_range: TimeRangeOption::default(), @@ -5747,14 +5955,27 @@ impl App { calendar_end: None, calendar_selecting_end: false, }); - self.load_basin_metrics(basin_name, MetricCategory::Storage, TimeRangeOption::default(), tx); + self.load_basin_metrics( + basin_name, + MetricCategory::Storage, + TimeRangeOption::default(), + tx, + ); } /// Open stream metrics view - fn open_stream_metrics(&mut self, basin_name: BasinName, stream_name: StreamName, tx: mpsc::UnboundedSender) { + fn open_stream_metrics( + &mut self, + basin_name: BasinName, + stream_name: StreamName, + tx: mpsc::UnboundedSender, + ) { let (year, month, day) = Self::today(); self.screen = Screen::MetricsView(MetricsViewState { - metrics_type: MetricsType::Stream { basin_name: basin_name.clone(), stream_name: stream_name.clone() }, + metrics_type: MetricsType::Stream { + basin_name: basin_name.clone(), + stream_name: stream_name.clone(), + }, metrics: Vec::new(), selected_category: MetricCategory::Storage, time_range: TimeRangeOption::default(), @@ -5782,7 +6003,12 @@ impl App { /// Load basin metrics /// Load account metrics - fn load_account_metrics(&self, category: MetricCategory, time_range: TimeRangeOption, tx: mpsc::UnboundedSender) { + fn load_account_metrics( + &self, + category: MetricCategory, + time_range: TimeRangeOption, + tx: mpsc::UnboundedSender, + ) { use s2_sdk::types::AccountMetricSet; let s2 = self.s2.clone().expect("S2 client not initialized"); @@ -5790,9 +6016,11 @@ impl App { tokio::spawn(async move { let set = match category { - MetricCategory::ActiveBasins => AccountMetricSet::ActiveBasins(TimeRange::new(start, end)), + MetricCategory::ActiveBasins => { + AccountMetricSet::ActiveBasins(TimeRange::new(start, end)) + } MetricCategory::AccountOps => AccountMetricSet::AccountOps( - s2_sdk::types::TimeRangeAndInterval::new(start, end) + s2_sdk::types::TimeRangeAndInterval::new(start, end), ), _ => return, // Other categories not valid for account }; @@ -5812,28 +6040,34 @@ impl App { }); } - fn load_basin_metrics(&self, basin_name: BasinName, category: MetricCategory, time_range: TimeRangeOption, tx: mpsc::UnboundedSender) { + fn load_basin_metrics( + &self, + basin_name: BasinName, + category: MetricCategory, + time_range: TimeRangeOption, + tx: mpsc::UnboundedSender, + ) { let s2 = self.s2.clone().expect("S2 client not initialized"); let (start, end) = time_range.get_range(); tokio::spawn(async move { let set = match category { MetricCategory::Storage => BasinMetricSet::Storage(TimeRange::new(start, end)), - MetricCategory::AppendOps => BasinMetricSet::AppendOps( - s2_sdk::types::TimeRangeAndInterval::new(start, end) - ), - MetricCategory::ReadOps => BasinMetricSet::ReadOps( - s2_sdk::types::TimeRangeAndInterval::new(start, end) - ), + MetricCategory::AppendOps => { + BasinMetricSet::AppendOps(s2_sdk::types::TimeRangeAndInterval::new(start, end)) + } + MetricCategory::ReadOps => { + BasinMetricSet::ReadOps(s2_sdk::types::TimeRangeAndInterval::new(start, end)) + } MetricCategory::AppendThroughput => BasinMetricSet::AppendThroughput( - s2_sdk::types::TimeRangeAndInterval::new(start, end) + s2_sdk::types::TimeRangeAndInterval::new(start, end), ), MetricCategory::ReadThroughput => BasinMetricSet::ReadThroughput( - s2_sdk::types::TimeRangeAndInterval::new(start, end) - ), - MetricCategory::BasinOps => BasinMetricSet::BasinOps( - s2_sdk::types::TimeRangeAndInterval::new(start, end) + s2_sdk::types::TimeRangeAndInterval::new(start, end), ), + MetricCategory::BasinOps => { + BasinMetricSet::BasinOps(s2_sdk::types::TimeRangeAndInterval::new(start, end)) + } _ => return, }; @@ -5853,7 +6087,13 @@ impl App { } /// Load stream metrics - fn load_stream_metrics(&self, basin_name: BasinName, stream_name: StreamName, time_range: TimeRangeOption, tx: mpsc::UnboundedSender) { + fn load_stream_metrics( + &self, + basin_name: BasinName, + stream_name: StreamName, + time_range: TimeRangeOption, + tx: mpsc::UnboundedSender, + ) { let s2 = self.s2.clone().expect("S2 client not initialized"); let (start, end) = time_range.get_range(); @@ -5900,7 +6140,11 @@ impl App { let Screen::MetricsView(state) = &self.screen else { return; }; - (state.metrics_type.clone(), state.selected_category, state.time_range.clone()) + ( + state.metrics_type.clone(), + state.selected_category, + state.time_range, + ) }; match key.code { @@ -5930,7 +6174,10 @@ impl App { }); self.load_streams(basin_name, tx); } - MetricsType::Stream { basin_name, stream_name } => { + MetricsType::Stream { + basin_name, + stream_name, + } => { let basin_name = basin_name.clone(); let stream_name = stream_name.clone(); self.screen = Screen::StreamDetail(StreamDetailState { @@ -5952,7 +6199,9 @@ impl App { // Set picker selection to current time range state.time_picker_selected = TimeRangeOption::PRESETS .iter() - .position(|p| std::mem::discriminant(p) == std::mem::discriminant(&state.time_range)) + .position(|p| { + std::mem::discriminant(p) == std::mem::discriminant(&state.time_range) + }) .unwrap_or(3); } } @@ -6007,10 +6256,10 @@ impl App { } } KeyCode::Up | KeyCode::Char('k') => { - if let Screen::MetricsView(state) = &mut self.screen { - if state.scroll > 0 { - state.scroll -= 1; - } + if let Screen::MetricsView(state) = &mut self.screen + && state.scroll > 0 + { + state.scroll -= 1; } } KeyCode::Down | KeyCode::Char('j') => { @@ -6019,7 +6268,6 @@ impl App { } } KeyCode::Char('r') => { - if let Screen::MetricsView(state) = &mut self.screen { state.loading = true; state.metrics.clear(); @@ -6029,10 +6277,23 @@ impl App { self.load_account_metrics(selected_category, time_range, tx); } MetricsType::Basin { basin_name } => { - self.load_basin_metrics(basin_name.clone(), selected_category, time_range, tx); - } - MetricsType::Stream { basin_name, stream_name } => { - self.load_stream_metrics(basin_name.clone(), stream_name.clone(), time_range, tx); + self.load_basin_metrics( + basin_name.clone(), + selected_category, + time_range, + tx, + ); + } + MetricsType::Stream { + basin_name, + stream_name, + } => { + self.load_stream_metrics( + basin_name.clone(), + stream_name.clone(), + time_range, + tx, + ); } } } @@ -6040,7 +6301,7 @@ impl App { // Previous time range let new_time_range = time_range.prev(); if let Screen::MetricsView(state) = &mut self.screen { - state.time_range = new_time_range.clone(); + state.time_range = new_time_range; state.loading = true; state.metrics.clear(); } @@ -6049,10 +6310,23 @@ impl App { self.load_account_metrics(selected_category, new_time_range, tx); } MetricsType::Basin { basin_name } => { - self.load_basin_metrics(basin_name.clone(), selected_category, new_time_range, tx); - } - MetricsType::Stream { basin_name, stream_name } => { - self.load_stream_metrics(basin_name.clone(), stream_name.clone(), new_time_range, tx); + self.load_basin_metrics( + basin_name.clone(), + selected_category, + new_time_range, + tx, + ); + } + MetricsType::Stream { + basin_name, + stream_name, + } => { + self.load_stream_metrics( + basin_name.clone(), + stream_name.clone(), + new_time_range, + tx, + ); } } } @@ -6060,7 +6334,7 @@ impl App { // Next time range let new_time_range = time_range.next(); if let Screen::MetricsView(state) = &mut self.screen { - state.time_range = new_time_range.clone(); + state.time_range = new_time_range; state.loading = true; state.metrics.clear(); } @@ -6069,10 +6343,23 @@ impl App { self.load_account_metrics(selected_category, new_time_range, tx); } MetricsType::Basin { basin_name } => { - self.load_basin_metrics(basin_name.clone(), selected_category, new_time_range, tx); - } - MetricsType::Stream { basin_name, stream_name } => { - self.load_stream_metrics(basin_name.clone(), stream_name.clone(), new_time_range, tx); + self.load_basin_metrics( + basin_name.clone(), + selected_category, + new_time_range, + tx, + ); + } + MetricsType::Stream { + basin_name, + stream_name, + } => { + self.load_stream_metrics( + basin_name.clone(), + stream_name.clone(), + new_time_range, + tx, + ); } } } @@ -6100,17 +6387,17 @@ impl App { } } KeyCode::Up | KeyCode::Char('k') => { - if let Screen::MetricsView(state) = &mut self.screen { - if state.time_picker_selected > 0 { - state.time_picker_selected -= 1; - } + if let Screen::MetricsView(state) = &mut self.screen + && state.time_picker_selected > 0 + { + state.time_picker_selected -= 1; } } KeyCode::Down | KeyCode::Char('j') => { - if let Screen::MetricsView(state) = &mut self.screen { - if state.time_picker_selected < CUSTOM_INDEX { - state.time_picker_selected += 1; - } + if let Screen::MetricsView(state) = &mut self.screen + && state.time_picker_selected < CUSTOM_INDEX + { + state.time_picker_selected += 1; } } KeyCode::Enter => { @@ -6140,7 +6427,7 @@ impl App { .get(state.time_picker_selected) .cloned() .unwrap_or_default(); - state.time_range = selected.clone(); + state.time_range = selected; state.time_picker_open = false; state.loading = true; state.metrics.clear(); @@ -6153,10 +6440,23 @@ impl App { self.load_account_metrics(selected_category, new_time_range, tx); } MetricsType::Basin { basin_name } => { - self.load_basin_metrics(basin_name.clone(), selected_category, new_time_range, tx); + self.load_basin_metrics( + basin_name.clone(), + selected_category, + new_time_range, + tx, + ); } - MetricsType::Stream { basin_name, stream_name } => { - self.load_stream_metrics(basin_name.clone(), stream_name.clone(), new_time_range, tx); + MetricsType::Stream { + basin_name, + stream_name, + } => { + self.load_stream_metrics( + basin_name.clone(), + stream_name.clone(), + new_time_range, + tx, + ); } } } @@ -6196,7 +6496,8 @@ impl App { state.calendar_month = 12; state.calendar_year -= 1; } - state.calendar_day = Self::days_in_month(state.calendar_year, state.calendar_month); + state.calendar_day = + Self::days_in_month(state.calendar_year, state.calendar_month); } } } @@ -6230,7 +6531,8 @@ impl App { state.calendar_month = 12; state.calendar_year -= 1; } - let prev_month_days = Self::days_in_month(state.calendar_year, state.calendar_month); + let prev_month_days = + Self::days_in_month(state.calendar_year, state.calendar_month); state.calendar_day = prev_month_days.saturating_sub(7 - state.calendar_day); } } @@ -6249,7 +6551,10 @@ impl App { state.calendar_month = 1; state.calendar_year += 1; } - state.calendar_day = overflow.min(Self::days_in_month(state.calendar_year, state.calendar_month)); + state.calendar_day = overflow.min(Self::days_in_month( + state.calendar_year, + state.calendar_month, + )); } } } @@ -6285,7 +6590,11 @@ impl App { let Screen::MetricsView(state) = &mut self.screen else { return; }; - let selected_date = (state.calendar_year, state.calendar_month, state.calendar_day); + let selected_date = ( + state.calendar_year, + state.calendar_month, + state.calendar_day, + ); if state.calendar_start.is_none() { // First selection: set start date @@ -6311,8 +6620,12 @@ impl App { return; }; - let start_date = state.calendar_start.unwrap(); - let end_date = state.calendar_end.unwrap(); + let start_date = state + .calendar_start + .expect("calendar_start set before should_apply"); + let end_date = state + .calendar_end + .expect("calendar_end set before should_apply"); // Ensure start <= end let (start, end) = if start_date <= end_date { @@ -6325,8 +6638,11 @@ impl App { let start_ts = Self::date_to_timestamp(start.0, start.1, start.2, true); let end_ts = Self::date_to_timestamp(end.0, end.1, end.2, false); - let time_range = TimeRangeOption::Custom { start: start_ts, end: end_ts }; - state.time_range = time_range.clone(); + let time_range = TimeRangeOption::Custom { + start: start_ts, + end: end_ts, + }; + state.time_range = time_range; state.calendar_open = false; state.loading = true; state.metrics.clear(); @@ -6339,10 +6655,23 @@ impl App { self.load_account_metrics(selected_category, new_time_range, tx); } MetricsType::Basin { basin_name } => { - self.load_basin_metrics(basin_name.clone(), selected_category, new_time_range, tx); + self.load_basin_metrics( + basin_name.clone(), + selected_category, + new_time_range, + tx, + ); } - MetricsType::Stream { basin_name, stream_name } => { - self.load_stream_metrics(basin_name.clone(), stream_name.clone(), new_time_range, tx); + MetricsType::Stream { + basin_name, + stream_name, + } => { + self.load_stream_metrics( + basin_name.clone(), + stream_name.clone(), + new_time_range, + tx, + ); } } } @@ -6364,9 +6693,13 @@ impl App { fn date_to_timestamp(year: i32, month: u32, day: u32, start_of_day: bool) -> u32 { use chrono::{TimeZone, Utc}; let dt = if start_of_day { - Utc.with_ymd_and_hms(year, month, day, 0, 0, 0).unwrap() + Utc.with_ymd_and_hms(year, month, day, 0, 0, 0) + .single() + .expect("invalid date selected in calendar") } else { - Utc.with_ymd_and_hms(year, month, day, 23, 59, 59).unwrap() + Utc.with_ymd_and_hms(year, month, day, 23, 59, 59) + .single() + .expect("invalid date selected in calendar") }; dt.timestamp() as u32 } @@ -6491,7 +6824,14 @@ impl App { let duration_secs = state.duration_secs; let catchup_delay_secs = state.catchup_delay_secs; state.config_phase = false; - self.start_benchmark(basin_name, record_size, target_mibps, duration_secs, catchup_delay_secs, tx); + self.start_benchmark( + basin_name, + record_size, + target_mibps, + duration_secs, + catchup_delay_secs, + tx, + ); } else { // Edit the field state.editing = true; @@ -6535,7 +6875,8 @@ impl App { state.duration_secs = state.duration_secs.saturating_add(10).min(600); } BenchConfigField::CatchupDelay => { - state.catchup_delay_secs = state.catchup_delay_secs.saturating_add(5).min(120); + state.catchup_delay_secs = + state.catchup_delay_secs.saturating_add(5).min(120); } BenchConfigField::Start => {} } @@ -6565,7 +6906,10 @@ impl App { self.bench_stop_signal = Some(user_stop.clone()); tokio::spawn(async move { - use s2_sdk::types::{CreateStreamInput, DeleteStreamInput, StreamName, StreamConfig as SdkStreamConfig, RetentionPolicy, TimestampingConfig, TimestampingMode, DeleteOnEmptyConfig}; + use s2_sdk::types::{ + CreateStreamInput, DeleteOnEmptyConfig, DeleteStreamInput, RetentionPolicy, + StreamConfig as SdkStreamConfig, StreamName, TimestampingConfig, TimestampingMode, + }; use std::num::NonZeroU64; use std::time::Duration; let stream_name: StreamName = format!("_bench_{}", uuid::Uuid::new_v4()) @@ -6586,7 +6930,9 @@ impl App { let basin = s2.basin(basin_name.clone()); if let Err(e) = basin - .create_stream(CreateStreamInput::new(stream_name.clone()).with_config(stream_config)) + .create_stream( + CreateStreamInput::new(stream_name.clone()).with_config(stream_config), + ) .await { let _ = tx.send(Event::BenchStreamCreated(Err(CliError::op( @@ -6614,7 +6960,9 @@ impl App { .await; // Clean up the stream - let _ = basin.delete_stream(DeleteStreamInput::new(stream_name)).await; + let _ = basin + .delete_stream(DeleteStreamInput::new(stream_name)) + .await; let _ = tx.send(Event::BenchComplete(result)); }); @@ -6815,7 +7163,9 @@ async fn run_bench_with_events( } match result { Ok(sample) => { - let mibps = sample.bytes as f64 / (1024.0 * 1024.0) / sample.elapsed.as_secs_f64().max(0.001); + let mibps = sample.bytes as f64 + / (1024.0 * 1024.0) + / sample.elapsed.as_secs_f64().max(0.001); let recps = sample.records as f64 / sample.elapsed.as_secs_f64().max(0.001); let _ = tx.send(Event::BenchCatchupSample(BenchSample { bytes: sample.bytes, diff --git a/src/tui/event.rs b/src/tui/event.rs index 6a96f24..2106412 100644 --- a/src/tui/event.rs +++ b/src/tui/event.rs @@ -1,6 +1,8 @@ use std::time::Duration; -use s2_sdk::types::{AccessTokenInfo, BasinInfo, Metric, SequencedRecord, StreamInfo, StreamPosition}; +use s2_sdk::types::{ + AccessTokenInfo, BasinInfo, Metric, SequencedRecord, StreamInfo, StreamPosition, +}; use crate::error::CliError; use crate::types::{LatencyStats, StorageClass, StreamConfig, TimestampingMode}; @@ -12,7 +14,7 @@ pub struct BasinConfigInfo { pub create_stream_on_read: bool, // Default stream config pub storage_class: Option, - pub retention_age_secs: Option, // None = infinite + pub retention_age_secs: Option, // None = infinite pub timestamping_mode: Option, pub timestamping_uncapped: bool, } @@ -21,10 +23,10 @@ pub struct BasinConfigInfo { #[derive(Debug, Clone)] pub struct StreamConfigInfo { pub storage_class: Option, - pub retention_age_secs: Option, // None = infinite + pub retention_age_secs: Option, // None = infinite pub timestamping_mode: Option, pub timestamping_uncapped: bool, - pub delete_on_empty_min_age_secs: Option, // None = disabled + pub delete_on_empty_min_age_secs: Option, // None = disabled } /// Events that can occur in the TUI @@ -82,7 +84,11 @@ pub enum Event { RecordAppended(Result<(u64, String, usize), CliError>), /// File append progress update (appended_count, total_lines, last_seq_num) - FileAppendProgress { appended: usize, total: usize, last_seq: Option }, + FileAppendProgress { + appended: usize, + total: usize, + last_seq: Option, + }, /// File append completed (total_records, first_seq, last_seq) FileAppendComplete(Result<(usize, u64, u64), CliError>), diff --git a/src/tui/mod.rs b/src/tui/mod.rs index df1ea8a..3c6ab5a 100644 --- a/src/tui/mod.rs +++ b/src/tui/mod.rs @@ -44,10 +44,7 @@ pub async fn run() -> Result<(), CliError> { cleanup_errors.push(format!("disable_raw_mode: {e}")); } - if let Err(e) = execute!( - terminal.backend_mut(), - LeaveAlternateScreen - ) { + if let Err(e) = execute!(terminal.backend_mut(), LeaveAlternateScreen) { cleanup_errors.push(format!("leave_alternate_screen: {e}")); } @@ -57,7 +54,10 @@ pub async fn run() -> Result<(), CliError> { // Log cleanup errors to stderr (won't be visible in alternate screen anyway) if !cleanup_errors.is_empty() { - eprintln!("Warning: terminal cleanup errors: {}", cleanup_errors.join(", ")); + eprintln!( + "Warning: terminal cleanup errors: {}", + cleanup_errors.join(", ") + ); } result diff --git a/src/tui/ui.rs b/src/tui/ui.rs index ed4bcf8..08271b3 100644 --- a/src/tui/ui.rs +++ b/src/tui/ui.rs @@ -8,21 +8,24 @@ use ratatui::{ use crate::types::{StorageClass, TimestampingMode}; -use super::app::{AccessTokensState, App, AgoUnit, AppendViewState, BasinsState, BenchViewState, CompressionOption, ExpiryOption, InputMode, MessageLevel, MetricCategory, MetricsType, MetricsViewState, PipState, ReadStartFrom, ReadViewState, RetentionPolicyOption, ScopeOption, Screen, SettingsState, SetupState, StreamDetailState, StreamsState, Tab}; - - -const GREEN: Color = Color::Rgb(34, 197, 94); // Active green -const YELLOW: Color = Color::Rgb(250, 204, 21); // Warning yellow -const RED: Color = Color::Rgb(239, 68, 68); // Error red -const CYAN: Color = Color::Rgb(34, 211, 238); // Cyan accent -const BLUE: Color = Color::Rgb(59, 130, 246); // Blue accent -const WHITE: Color = Color::Rgb(255, 255, 255); // Pure white -const GRAY_100: Color = Color::Rgb(243, 244, 246); // Near white -const GRAY_500: Color = Color::Rgb(107, 114, 128); // Muted gray -const BG_DARK: Color = Color::Rgb(17, 17, 17); // Main background -const BG_PANEL: Color = Color::Rgb(24, 24, 27); // Panel background -const BORDER: Color = Color::Rgb(63, 63, 70); // Border gray +use super::app::{ + AccessTokensState, AgoUnit, App, AppendViewState, BasinsState, BenchViewState, + CompressionOption, ExpiryOption, InputMode, MessageLevel, MetricCategory, MetricsType, + MetricsViewState, PipState, ReadStartFrom, ReadViewState, RetentionPolicyOption, ScopeOption, + Screen, SettingsState, SetupState, StreamDetailState, StreamsState, Tab, +}; +const GREEN: Color = Color::Rgb(34, 197, 94); // Active green +const YELLOW: Color = Color::Rgb(250, 204, 21); // Warning yellow +const RED: Color = Color::Rgb(239, 68, 68); // Error red +const CYAN: Color = Color::Rgb(34, 211, 238); // Cyan accent +const BLUE: Color = Color::Rgb(59, 130, 246); // Blue accent +const WHITE: Color = Color::Rgb(255, 255, 255); // Pure white +const GRAY_100: Color = Color::Rgb(243, 244, 246); // Near white +const GRAY_500: Color = Color::Rgb(107, 114, 128); // Muted gray +const BG_DARK: Color = Color::Rgb(17, 17, 17); // Main background +const BG_PANEL: Color = Color::Rgb(24, 24, 27); // Panel background +const BORDER: Color = Color::Rgb(63, 63, 70); // Border gray const ACCENT: Color = WHITE; const SUCCESS: Color = GREEN; @@ -87,7 +90,8 @@ const S2_LOGO: &[&str] = &[ /// Render the S2 logo as styled lines fn render_logo() -> Vec> { - S2_LOGO.iter() + S2_LOGO + .iter() .map(|&line| Line::from(Span::styled(line, Style::default().fg(WHITE)))) .collect() } @@ -96,13 +100,19 @@ fn render_logo() -> Vec> { fn render_toggle(on: bool, is_selected: bool) -> Vec> { if on { vec![ - Span::styled("", Style::default().fg(if is_selected { GREEN } else { GRAY_650 })), + Span::styled( + "", + Style::default().fg(if is_selected { GREEN } else { GRAY_650 }), + ), Span::styled(" ON ", Style::default().fg(BG_DARK).bg(GREEN).bold()), Span::styled("", Style::default().fg(GREEN)), ] } else { vec![ - Span::styled("", Style::default().fg(if is_selected { TEXT_MUTED } else { GRAY_650 })), + Span::styled( + "", + Style::default().fg(if is_selected { TEXT_MUTED } else { GRAY_650 }), + ), Span::styled(" OFF ", Style::default().fg(TEXT_MUTED).bg(GRAY_650)), Span::styled("", Style::default().fg(GRAY_650)), ] @@ -115,12 +125,12 @@ fn render_pill(label: &str, is_row_selected: bool, is_active: bool) -> Span<'sta if is_active { Span::styled( format!(" {} ", label), - Style::default().fg(BG_DARK).bg(GREEN).bold() + Style::default().fg(BG_DARK).bg(GREEN).bold(), ) } else if is_row_selected { Span::styled( format!(" {} ", label), - Style::default().fg(TEXT_PRIMARY).bg(GRAY_750) + Style::default().fg(TEXT_PRIMARY).bg(GRAY_750), ) } else { Span::styled(format!(" {} ", label), Style::default().fg(TEXT_MUTED)) @@ -128,7 +138,11 @@ fn render_pill(label: &str, is_row_selected: bool, is_active: bool) -> Span<'sta } /// Render a form field row with selection indicator and label -fn render_field_row(field_idx: usize, label: &str, current_selected: usize) -> (Span<'static>, Span<'static>) { +fn render_field_row( + field_idx: usize, + label: &str, + current_selected: usize, +) -> (Span<'static>, Span<'static>) { let is_selected = field_idx == current_selected; let indicator = if is_selected { Span::styled(SELECTED_INDICATOR, Style::default().fg(GREEN).bold()) @@ -137,13 +151,21 @@ fn render_field_row(field_idx: usize, label: &str, current_selected: usize) -> ( }; let label_span = Span::styled( format!("{:<15}", label), - Style::default().fg(if is_selected { TEXT_PRIMARY } else { TEXT_MUTED }) + Style::default().fg(if is_selected { + TEXT_PRIMARY + } else { + TEXT_MUTED + }), ); (indicator, label_span) } /// Render a form field row with bold label when selected -fn render_field_row_bold(field_idx: usize, label: &str, current_selected: usize) -> (Span<'static>, Span<'static>) { +fn render_field_row_bold( + field_idx: usize, + label: &str, + current_selected: usize, +) -> (Span<'static>, Span<'static>) { let is_selected = field_idx == current_selected; let indicator = if is_selected { Span::styled(SELECTED_INDICATOR, Style::default().fg(GREEN).bold()) @@ -176,7 +198,10 @@ fn render_button(label: &str, is_selected: bool, is_enabled: bool, color: Color) Line::from(vec![ indicator, - Span::styled(format!(" ▶ {} ", label), Style::default().fg(btn_fg).bg(btn_bg).bold()), + Span::styled( + format!(" ▶ {} ", label), + Style::default().fg(btn_fg).bg(btn_bg).bold(), + ), ]) } @@ -191,9 +216,17 @@ fn render_section_header(title: &str, width: usize) -> Line<'static> { } /// Render text input with cursor -fn render_text_input(value: &str, is_editing: bool, placeholder: &str, color: Color) -> Vec> { +fn render_text_input( + value: &str, + is_editing: bool, + placeholder: &str, + color: Color, +) -> Vec> { if value.is_empty() && !is_editing { - vec![Span::styled(placeholder.to_string(), Style::default().fg(GRAY_600).italic())] + vec![Span::styled( + placeholder.to_string(), + Style::default().fg(GRAY_600).italic(), + )] } else { let mut spans = vec![Span::styled(value.to_string(), Style::default().fg(color))]; if is_editing { @@ -216,7 +249,11 @@ fn render_radio(active: bool) -> &'static str { } /// Render a search/filter bar with consistent styling -fn render_search_bar(filter: &str, filter_active: bool, placeholder: &str) -> (Block<'static>, Line<'static>) { +fn render_search_bar( + filter: &str, + filter_active: bool, + placeholder: &str, +) -> (Block<'static>, Line<'static>) { let block = Block::default() .borders(Borders::ALL) .border_style(Style::default().fg(if filter_active { GREEN } else { BORDER })) @@ -229,9 +266,10 @@ fn render_search_bar(filter: &str, filter_active: bool, placeholder: &str) -> (B Span::styled("_", Style::default().fg(GREEN)), ]) } else if filter.is_empty() { - Line::from(vec![ - Span::styled(format!(" [/] {}...", placeholder), Style::default().fg(TEXT_MUTED)), - ]) + Line::from(vec![Span::styled( + format!(" [/] {}...", placeholder), + Style::default().fg(TEXT_MUTED), + )]) } else { Line::from(vec![ Span::styled(" [/] ", Style::default().fg(TEXT_MUTED)), @@ -243,7 +281,6 @@ fn render_search_bar(filter: &str, filter_active: bool, placeholder: &str) -> (B } pub fn draw(f: &mut Frame, app: &App) { - let area = f.area(); f.render_widget(Block::default().style(Style::default().bg(BG_DARK)), area); if matches!(app.screen, Screen::Splash) { @@ -256,7 +293,10 @@ pub fn draw(f: &mut Frame, app: &App) { } return; } - let show_tabs = matches!(app.screen, Screen::Basins(_) | Screen::AccessTokens(_) | Screen::Settings(_)); + let show_tabs = matches!( + app.screen, + Screen::Basins(_) | Screen::AccessTokens(_) | Screen::Settings(_) + ); let chunks = if show_tabs { Layout::default() @@ -433,7 +473,12 @@ fn draw_settings(f: &mut Frame, area: Rect, state: &SettingsState) { let content_area = chunks[1]; let panel_width = 70.min(content_area.width.saturating_sub(4)); let panel_x = content_area.x + (content_area.width.saturating_sub(panel_width)) / 2; - let panel_area = Rect::new(panel_x, content_area.y + 1, panel_width, content_area.height.saturating_sub(2)); + let panel_area = Rect::new( + panel_x, + content_area.y + 1, + panel_width, + content_area.height.saturating_sub(2), + ); let settings_block = Block::default() .borders(Borders::ALL) @@ -458,7 +503,6 @@ fn draw_settings(f: &mut Frame, area: Rect, state: &SettingsState) { let is_editing_token = state.editing && state.selected == 0; let token_display = if is_editing_token { - state.access_token.clone() } else if state.access_token_masked && !state.access_token.is_empty() { format!("{}...", "*".repeat(20.min(state.access_token.len()))) @@ -503,21 +547,37 @@ fn draw_settings(f: &mut Frame, area: Rect, state: &SettingsState) { state.editing && state.selected == 2, None, ); - let compression_label = Line::from(vec![ - Span::styled("Compression", Style::default().fg(TEXT_SECONDARY)), - ]); - f.render_widget(Paragraph::new(compression_label), Rect::new(field_chunks[3].x, field_chunks[3].y, field_chunks[3].width, 1)); + let compression_label = Line::from(vec![Span::styled( + "Compression", + Style::default().fg(TEXT_SECONDARY), + )]); + f.render_widget( + Paragraph::new(compression_label), + Rect::new( + field_chunks[3].x, + field_chunks[3].y, + field_chunks[3].width, + 1, + ), + ); - let options = [CompressionOption::None, CompressionOption::Gzip, CompressionOption::Zstd]; - let pills: Vec = options.iter().map(|opt| { - let is_selected = *opt == state.compression; - let style = if is_selected { - Style::default().fg(BG_DARK).bg(GREEN).bold() - } else { - Style::default().fg(TEXT_MUTED).bg(BG_DARK) - }; - Span::styled(format!(" {} ", opt.as_str()), style) - }).collect(); + let options = [ + CompressionOption::None, + CompressionOption::Gzip, + CompressionOption::Zstd, + ]; + let pills: Vec = options + .iter() + .map(|opt| { + let is_selected = *opt == state.compression; + let style = if is_selected { + Style::default().fg(BG_DARK).bg(GREEN).bold() + } else { + Style::default().fg(TEXT_MUTED).bg(BG_DARK) + }; + Span::styled(format!(" {} ", opt.as_str()), style) + }) + .collect(); let mut pill_line = vec![Span::styled(" ", Style::default())]; for (i, pill) in pills.into_iter().enumerate() { @@ -530,10 +590,14 @@ fn draw_settings(f: &mut Frame, area: Rect, state: &SettingsState) { pill_line.push(Span::styled(" ← h/l →", Style::default().fg(TEXT_MUTED))); } - let compression_row = Rect::new(field_chunks[3].x, field_chunks[3].y + 1, field_chunks[3].width, 1); + let compression_row = Rect::new( + field_chunks[3].x, + field_chunks[3].y + 1, + field_chunks[3].width, + 1, + ); f.render_widget( - Paragraph::new(Line::from(pill_line)) - .style(Style::default().bg(BG_DARK)), + Paragraph::new(Line::from(pill_line)).style(Style::default().bg(BG_DARK)), compression_row, ); let save_style = if state.selected == 4 { @@ -553,7 +617,9 @@ fn draw_settings(f: &mut Frame, area: Rect, state: &SettingsState) { f.render_widget(save_button, field_chunks[5]); if let Some(msg) = &state.message { let msg_lower = msg.to_lowercase(); - let is_error = msg_lower.contains("error") || msg_lower.contains("fail") || msg_lower.contains("invalid"); + let is_error = msg_lower.contains("error") + || msg_lower.contains("fail") + || msg_lower.contains("invalid"); let msg_style = if is_error { Style::default().fg(ERROR) } else { @@ -590,7 +656,10 @@ fn draw_settings_field( Span::raw("") }, ]); - f.render_widget(Paragraph::new(label_line), Rect::new(area.x, area.y, area.width, 1)); + f.render_widget( + Paragraph::new(label_line), + Rect::new(area.x, area.y, area.width, 1), + ); let border_style = if selected { Style::default().fg(GREEN) @@ -614,8 +683,7 @@ fn draw_settings_field( .borders(Borders::ALL) .border_style(border_style) .style(Style::default().bg(BG_DARK)); - let value_para = Paragraph::new(Span::styled(value_display, value_style)) - .block(value_block); + let value_para = Paragraph::new(Span::styled(value_display, value_style)).block(value_block); f.render_widget(value_para, Rect::new(area.x, area.y + 1, area.width, 3)); } @@ -707,7 +775,6 @@ fn draw_tab_bar(f: &mut Frame, area: Rect, current_tab: Tab) { } fn draw_access_tokens(f: &mut Frame, area: Rect, state: &AccessTokensState) { - let chunks = Layout::default() .direction(Direction::Vertical) .constraints([ @@ -720,8 +787,13 @@ fn draw_access_tokens(f: &mut Frame, area: Rect, state: &AccessTokensState) { let count_text = if state.loading { " loading...".to_string() } else { - let filtered_count = state.tokens.iter() - .filter(|t| state.filter.is_empty() || t.id.to_lowercase().contains(&state.filter.to_lowercase())) + let filtered_count = state + .tokens + .iter() + .filter(|t| { + state.filter.is_empty() + || t.id.to_lowercase().contains(&state.filter.to_lowercase()) + }) .count(); if filtered_count != state.tokens.len() { format!(" {}/{} tokens", filtered_count, state.tokens.len()) @@ -736,17 +808,19 @@ fn draw_access_tokens(f: &mut Frame, area: Rect, state: &AccessTokensState) { Span::styled(&count_text, Style::default().fg(GRAY_700)), ]), ]; - let title_block = Paragraph::new(title_lines) - .block(Block::default() + let title_block = Paragraph::new(title_lines).block( + Block::default() .borders(Borders::BOTTOM) - .border_style(Style::default().fg(GRAY_800))); + .border_style(Style::default().fg(GRAY_800)), + ); f.render_widget(title_block, chunks[0]); - let (search_block, search_text) = render_search_bar(&state.filter, state.filter_active, "Filter by token ID"); + let (search_block, search_text) = + render_search_bar(&state.filter, state.filter_active, "Filter by token ID"); f.render_widget(Paragraph::new(search_text).block(search_block), chunks[1]); let header = Line::from(vec![ - Span::styled(" ", Style::default()), // Space for selection prefix + Span::styled(" ", Style::default()), // Space for selection prefix Span::styled( format!("{:<30}", "TOKEN ID"), Style::default().fg(TEXT_MUTED).bold(), @@ -765,7 +839,10 @@ fn draw_access_tokens(f: &mut Frame, area: Rect, state: &AccessTokensState) { .iter() .filter(|t| { state.filter.is_empty() - || t.id.to_string().to_lowercase().contains(&state.filter.to_lowercase()) + || t.id + .to_string() + .to_lowercase() + .contains(&state.filter.to_lowercase()) }) .collect(); @@ -813,7 +890,10 @@ fn draw_access_tokens(f: &mut Frame, area: Rect, state: &AccessTokensState) { Line::from(vec![ Span::styled(prefix, style), Span::styled(format!("{:<30}", token_id_display), style), - Span::styled(format!("{:<28}", expires_display), Style::default().fg(TEXT_MUTED)), + Span::styled( + format!("{:<28}", expires_display), + Style::default().fg(TEXT_MUTED), + ), Span::styled(scope_summary, Style::default().fg(TEXT_MUTED)), ]) }) @@ -898,7 +978,8 @@ fn format_operation(op: &s2_sdk::types::Operation) -> String { SdkOp::ListAccessTokens => "list_access_tokens", SdkOp::IssueAccessToken => "issue_access_token", SdkOp::RevokeAccessToken => "revoke_access_token", - }.to_string() + } + .to_string() } /// Check if operation is account-level @@ -910,26 +991,42 @@ fn is_account_op(op: &s2_sdk::types::Operation) -> bool { /// Check if operation is basin-level fn is_basin_op(op: &s2_sdk::types::Operation) -> bool { use s2_sdk::types::Operation as SdkOp; - matches!(op, - SdkOp::CreateBasin | SdkOp::DeleteBasin | - SdkOp::GetBasinConfig | SdkOp::ReconfigureBasin | - SdkOp::ListStreams | SdkOp::GetBasinMetrics) + matches!( + op, + SdkOp::CreateBasin + | SdkOp::DeleteBasin + | SdkOp::GetBasinConfig + | SdkOp::ReconfigureBasin + | SdkOp::ListStreams + | SdkOp::GetBasinMetrics + ) } /// Check if operation is stream-level fn is_stream_op(op: &s2_sdk::types::Operation) -> bool { use s2_sdk::types::Operation as SdkOp; - matches!(op, - SdkOp::CreateStream | SdkOp::DeleteStream | - SdkOp::GetStreamConfig | SdkOp::ReconfigureStream | - SdkOp::Read | SdkOp::Append | SdkOp::CheckTail | - SdkOp::Fence | SdkOp::Trim | SdkOp::GetStreamMetrics) + matches!( + op, + SdkOp::CreateStream + | SdkOp::DeleteStream + | SdkOp::GetStreamConfig + | SdkOp::ReconfigureStream + | SdkOp::Read + | SdkOp::Append + | SdkOp::CheckTail + | SdkOp::Fence + | SdkOp::Trim + | SdkOp::GetStreamMetrics + ) } /// Check if operation is token-related fn is_token_op(op: &s2_sdk::types::Operation) -> bool { use s2_sdk::types::Operation as SdkOp; - matches!(op, SdkOp::ListAccessTokens | SdkOp::IssueAccessToken | SdkOp::RevokeAccessToken) + matches!( + op, + SdkOp::ListAccessTokens | SdkOp::IssueAccessToken | SdkOp::RevokeAccessToken + ) } fn draw_metrics_view(f: &mut Frame, area: Rect, state: &MetricsViewState) { @@ -937,24 +1034,23 @@ fn draw_metrics_view(f: &mut Frame, area: Rect, state: &MetricsViewState) { let chunks = Layout::default() .direction(Direction::Vertical) .constraints([ - Constraint::Length(3), // Title with tabs - Constraint::Length(3), // Stats header row + Constraint::Length(3), // Title with tabs + Constraint::Length(3), // Stats header row Constraint::Min(12), - Constraint::Length(6), // Timeline (scrollable) + Constraint::Length(6), // Timeline (scrollable) ]) .split(area); let title = match &state.metrics_type { MetricsType::Account => "Account".to_string(), MetricsType::Basin { basin_name } => basin_name.to_string(), - MetricsType::Stream { basin_name, stream_name } => format!("{}/{}", basin_name, stream_name), + MetricsType::Stream { + basin_name, + stream_name, + } => format!("{}/{}", basin_name, stream_name), }; if matches!(state.metrics_type, MetricsType::Account) { - - let categories = [ - MetricCategory::ActiveBasins, - MetricCategory::AccountOps, - ]; + let categories = [MetricCategory::ActiveBasins, MetricCategory::AccountOps]; let mut title_spans: Vec = vec![ Span::styled(" [ ", Style::default().fg(BORDER)), @@ -974,12 +1070,18 @@ fn draw_metrics_view(f: &mut Frame, area: Rect, state: &MetricsViewState) { title_spans.push(Span::styled(format!(" {} ", cat.as_str()), style)); } title_spans.push(Span::styled(" ", Style::default())); - title_spans.push(Span::styled(format!("[{}]", state.time_range.as_str()), Style::default().fg(CYAN))); + title_spans.push(Span::styled( + format!("[{}]", state.time_range.as_str()), + Style::default().fg(CYAN), + )); let title_block = Block::default() .borders(Borders::ALL) .border_style(Style::default().fg(GREEN)) - .title_bottom(Line::from(Span::styled(" ←/→ category t time picker ", Style::default().fg(TEXT_MUTED)))) + .title_bottom(Line::from(Span::styled( + " ←/→ category t time picker ", + Style::default().fg(TEXT_MUTED), + ))) .style(Style::default().bg(BG_PANEL)); let title_para = Paragraph::new(Line::from(title_spans)) @@ -1014,12 +1116,18 @@ fn draw_metrics_view(f: &mut Frame, area: Rect, state: &MetricsViewState) { title_spans.push(Span::styled(format!(" {} ", cat.as_str()), style)); } title_spans.push(Span::styled(" ", Style::default())); - title_spans.push(Span::styled(format!("[{}]", state.time_range.as_str()), Style::default().fg(CYAN))); + title_spans.push(Span::styled( + format!("[{}]", state.time_range.as_str()), + Style::default().fg(CYAN), + )); let title_block = Block::default() .borders(Borders::ALL) .border_style(Style::default().fg(GREEN)) - .title_bottom(Line::from(Span::styled(" ←/→ category t time picker ", Style::default().fg(TEXT_MUTED)))) + .title_bottom(Line::from(Span::styled( + " ←/→ category t time picker ", + Style::default().fg(TEXT_MUTED), + ))) .style(Style::default().bg(BG_PANEL)); let title_para = Paragraph::new(Line::from(title_spans)) @@ -1030,7 +1138,10 @@ fn draw_metrics_view(f: &mut Frame, area: Rect, state: &MetricsViewState) { let title_block = Block::default() .borders(Borders::ALL) .border_style(Style::default().fg(GREEN)) - .title_bottom(Line::from(Span::styled(" t time picker ", Style::default().fg(TEXT_MUTED)))) + .title_bottom(Line::from(Span::styled( + " t time picker ", + Style::default().fg(TEXT_MUTED), + ))) .style(Style::default().bg(BG_PANEL)); let title_para = Paragraph::new(Line::from(vec![ @@ -1039,7 +1150,10 @@ fn draw_metrics_view(f: &mut Frame, area: Rect, state: &MetricsViewState) { Span::styled(" ] ", Style::default().fg(BORDER)), Span::styled(" Storage ", Style::default().fg(BG_DARK).bg(GREEN).bold()), Span::styled(" ", Style::default()), - Span::styled(format!("[{}]", state.time_range.as_str()), Style::default().fg(CYAN)), + Span::styled( + format!("[{}]", state.time_range.as_str()), + Style::default().fg(CYAN), + ), ])) .block(title_block) .alignment(Alignment::Center); @@ -1052,7 +1166,10 @@ fn draw_metrics_view(f: &mut Frame, area: Rect, state: &MetricsViewState) { .style(Style::default().bg(BG_DARK)); let loading = Paragraph::new(vec![ Line::from(""), - Line::from(Span::styled("Loading metrics...", Style::default().fg(TEXT_MUTED))), + Line::from(Span::styled( + "Loading metrics...", + Style::default().fg(TEXT_MUTED), + )), ]) .block(loading_block) .alignment(Alignment::Center); @@ -1072,7 +1189,10 @@ fn draw_metrics_view(f: &mut Frame, area: Rect, state: &MetricsViewState) { .style(Style::default().bg(BG_DARK)); let empty = Paragraph::new(vec![ Line::from(""), - Line::from(Span::styled("No data in the last 24 hours", Style::default().fg(TEXT_MUTED))), + Line::from(Span::styled( + "No data in the last 24 hours", + Style::default().fg(TEXT_MUTED), + )), ]) .block(empty_block) .alignment(Alignment::Center); @@ -1097,12 +1217,19 @@ fn draw_metrics_view(f: &mut Frame, area: Rect, state: &MetricsViewState) { render_label_metric(f, chunks, &label_name, &label_values, state); return; } - let accumulation_metrics: Vec<_> = state.metrics.iter() - .filter_map(|m| if let Metric::Accumulation(a) = m { Some(a) } else { None }) + let accumulation_metrics: Vec<_> = state + .metrics + .iter() + .filter_map(|m| { + if let Metric::Accumulation(a) = m { + Some(a) + } else { + None + } + }) .collect(); if accumulation_metrics.len() > 1 { - render_multi_metric(f, chunks, &accumulation_metrics, state); return; } @@ -1176,17 +1303,32 @@ fn draw_metrics_view(f: &mut Frame, area: Rect, state: &MetricsViewState) { let stats_line = Line::from(vec![ Span::styled(" NOW ", Style::default().fg(BG_DARK).bg(GREEN).bold()), - Span::styled(format!(" {} ", format_metric_value_f64(latest_val, metric_unit)), Style::default().fg(GREEN).bold()), + Span::styled( + format!(" {} ", format_metric_value_f64(latest_val, metric_unit)), + Style::default().fg(GREEN).bold(), + ), Span::styled(trend_arrow, Style::default().fg(trend_color).bold()), Span::styled(format!("{} ", trend_text), Style::default().fg(trend_color)), Span::styled(" | ", Style::default().fg(BORDER)), Span::styled("min ", Style::default().fg(TEXT_MUTED)), - Span::styled(format_metric_value_f64(min_val, metric_unit), Style::default().fg(Color::Rgb(96, 165, 250))), + Span::styled( + format_metric_value_f64(min_val, metric_unit), + Style::default().fg(Color::Rgb(96, 165, 250)), + ), Span::styled(" max ", Style::default().fg(TEXT_MUTED)), - Span::styled(format_metric_value_f64(max_val, metric_unit), Style::default().fg(Color::Rgb(251, 191, 36))), + Span::styled( + format_metric_value_f64(max_val, metric_unit), + Style::default().fg(Color::Rgb(251, 191, 36)), + ), Span::styled(" avg ", Style::default().fg(TEXT_MUTED)), - Span::styled(format_metric_value_f64(avg_val, metric_unit), Style::default().fg(Color::Rgb(167, 139, 250))), - Span::styled(format!(" | {} pts", all_values.len()), Style::default().fg(TEXT_MUTED)), + Span::styled( + format_metric_value_f64(avg_val, metric_unit), + Style::default().fg(Color::Rgb(167, 139, 250)), + ), + Span::styled( + format!(" | {} pts", all_values.len()), + Style::default().fg(TEXT_MUTED), + ), ]); let stats_para = Paragraph::new(stats_line).alignment(Alignment::Center); f.render_widget(stats_para, stats_inner); @@ -1202,15 +1344,30 @@ fn draw_metrics_view(f: &mut Frame, area: Rect, state: &MetricsViewState) { let chart_inner = chart_block.inner(chunks[2]); f.render_widget(chart_block, chunks[2]); - render_area_chart(f, chart_inner, &all_values, min_val, max_val, metric_unit, first_ts, last_ts); + render_area_chart( + f, + chart_inner, + &all_values, + min_val, + max_val, + metric_unit, + first_ts, + last_ts, + ); let timeline_block = Block::default() .borders(Borders::ALL) .border_style(Style::default().fg(BORDER)) .title(Line::from(vec![ Span::styled(" Data Points ", Style::default().fg(TEXT_PRIMARY)), - Span::styled(format!("[{}/{}]", state.scroll + 1, all_values.len()), Style::default().fg(TEXT_MUTED)), + Span::styled( + format!("[{}/{}]", state.scroll + 1, all_values.len()), + Style::default().fg(TEXT_MUTED), + ), ])) - .title_bottom(Line::from(Span::styled(" j/k scroll ", Style::default().fg(TEXT_MUTED)))) + .title_bottom(Line::from(Span::styled( + " j/k scroll ", + Style::default().fg(TEXT_MUTED), + ))) .style(Style::default().bg(BG_DARK)); let timeline_inner = timeline_block.inner(chunks[3]); @@ -1231,17 +1388,33 @@ fn draw_metrics_view(f: &mut Frame, area: Rect, state: &MetricsViewState) { let intensity = if max_val > 0.0 { *value / max_val } else { 0.0 }; let bar_color = intensity_to_color(intensity); - let bar: String = (0..bar_len).map(|i| { - let pos = i as f64 / bar_len.max(1) as f64; - if pos > 0.9 { '█' } else if pos > 0.7 { '▓' } else if pos > 0.4 { '▒' } else { '░' } - }).collect(); + let bar: String = (0..bar_len) + .map(|i| { + let pos = i as f64 / bar_len.max(1) as f64; + if pos > 0.9 { + '█' + } else if pos > 0.7 { + '▓' + } else if pos > 0.4 { + '▒' + } else { + '░' + } + }) + .collect(); let time_str = format_metric_timestamp_short(*ts); Line::from(vec![ - Span::styled(format!(" {:>8} ", time_str), Style::default().fg(TEXT_MUTED)), + Span::styled( + format!(" {:>8} ", time_str), + Style::default().fg(TEXT_MUTED), + ), Span::styled(bar, Style::default().fg(bar_color)), - Span::styled(format!(" {:>10}", format_metric_value_f64(*value, metric_unit)), Style::default().fg(TEXT_SECONDARY)), + Span::styled( + format!(" {:>10}", format_metric_value_f64(*value, metric_unit)), + Style::default().fg(TEXT_SECONDARY), + ), ]) }) .collect(); @@ -1253,7 +1426,7 @@ fn draw_metrics_view(f: &mut Frame, area: Rect, state: &MetricsViewState) { /// Convert intensity (0.0-1.0) to a green gradient color fn intensity_to_color(intensity: f64) -> Color { if intensity > 0.8 { - Color::Rgb(34, 197, 94) // bright green + Color::Rgb(34, 197, 94) // bright green } else if intensity > 0.6 { Color::Rgb(74, 222, 128) } else if intensity > 0.4 { @@ -1274,16 +1447,16 @@ fn render_multi_metric( ) { use std::collections::BTreeMap; let colors = [ - Color::Rgb(139, 92, 246), // Purple (primary) - Color::Rgb(124, 58, 237), // Violet - Color::Rgb(99, 102, 241), // Indigo - Color::Rgb(79, 70, 229), // Deep indigo - Color::Rgb(59, 130, 246), // Blue - Color::Rgb(37, 99, 235), // Royal blue - Color::Rgb(96, 165, 250), // Light blue - Color::Rgb(147, 197, 253), // Pale blue - Color::Rgb(250, 204, 21), // Yellow (for highlights) - Color::Rgb(251, 146, 60), // Orange + Color::Rgb(139, 92, 246), // Purple (primary) + Color::Rgb(124, 58, 237), // Violet + Color::Rgb(99, 102, 241), // Indigo + Color::Rgb(79, 70, 229), // Deep indigo + Color::Rgb(59, 130, 246), // Blue + Color::Rgb(37, 99, 235), // Royal blue + Color::Rgb(96, 165, 250), // Light blue + Color::Rgb(147, 197, 253), // Pale blue + Color::Rgb(250, 204, 21), // Yellow (for highlights) + Color::Rgb(251, 146, 60), // Orange ]; let mut metric_totals: Vec<(String, f64, usize)> = metrics .iter() @@ -1346,17 +1519,32 @@ fn render_multi_metric( let stats_line = Line::from(vec![ Span::styled(" NOW ", Style::default().fg(BG_DARK).bg(GREEN).bold()), - Span::styled(format!(" {} ", format_count(latest_val as u64)), Style::default().fg(GREEN).bold()), + Span::styled( + format!(" {} ", format_count(latest_val as u64)), + Style::default().fg(GREEN).bold(), + ), Span::styled(trend_arrow, Style::default().fg(trend_color).bold()), Span::styled(format!("{} ", trend_text), Style::default().fg(trend_color)), Span::styled(" | ", Style::default().fg(BORDER)), Span::styled("min ", Style::default().fg(TEXT_MUTED)), - Span::styled(format_count(min_val as u64), Style::default().fg(Color::Rgb(96, 165, 250))), + Span::styled( + format_count(min_val as u64), + Style::default().fg(Color::Rgb(96, 165, 250)), + ), Span::styled(" max ", Style::default().fg(TEXT_MUTED)), - Span::styled(format_count(max_val as u64), Style::default().fg(Color::Rgb(251, 191, 36))), + Span::styled( + format_count(max_val as u64), + Style::default().fg(Color::Rgb(251, 191, 36)), + ), Span::styled(" avg ", Style::default().fg(TEXT_MUTED)), - Span::styled(format_count(avg_val as u64), Style::default().fg(Color::Rgb(167, 139, 250))), - Span::styled(format!(" | {} pts", all_values.len()), Style::default().fg(TEXT_MUTED)), + Span::styled( + format_count(avg_val as u64), + Style::default().fg(Color::Rgb(167, 139, 250)), + ), + Span::styled( + format!(" | {} pts", all_values.len()), + Style::default().fg(TEXT_MUTED), + ), ]); let stats_para = Paragraph::new(stats_line).alignment(Alignment::Center); f.render_widget(stats_para, stats_inner); @@ -1374,16 +1562,31 @@ fn render_multi_metric( f.render_widget(chart_block, chunks[2]); if !all_values.is_empty() { - render_area_chart(f, chart_inner, &all_values, min_val, max_val, s2_sdk::types::MetricUnit::Operations, first_ts, last_ts); + render_area_chart( + f, + chart_inner, + &all_values, + min_val, + max_val, + s2_sdk::types::MetricUnit::Operations, + first_ts, + last_ts, + ); } let timeline_block = Block::default() .borders(Borders::ALL) .border_style(Style::default().fg(BORDER)) .title(Line::from(vec![ Span::styled(" Breakdown ", Style::default().fg(TEXT_PRIMARY)), - Span::styled(format!("({} operation types)", metrics.len()), Style::default().fg(TEXT_MUTED)), + Span::styled( + format!("({} operation types)", metrics.len()), + Style::default().fg(TEXT_MUTED), + ), ])) - .title_bottom(Line::from(Span::styled(" j/k scroll ", Style::default().fg(TEXT_MUTED)))) + .title_bottom(Line::from(Span::styled( + " j/k scroll ", + Style::default().fg(TEXT_MUTED), + ))) .style(Style::default().bg(BG_DARK)); let timeline_inner = timeline_block.inner(chunks[3]); @@ -1420,11 +1623,14 @@ fn render_multi_metric( }; Line::from(vec![ - Span::styled(format!(" {:>14} ", display_name), Style::default().fg(TEXT_PRIMARY)), + Span::styled( + format!(" {:>14} ", display_name), + Style::default().fg(TEXT_PRIMARY), + ), Span::styled(bar, Style::default().fg(color)), Span::styled( format!(" {:>6} ({:>4.1}%)", format_count(*total as u64), percentage), - Style::default().fg(TEXT_SECONDARY) + Style::default().fg(TEXT_SECONDARY), ), ]) }) @@ -1442,7 +1648,6 @@ fn render_label_metric( values: &[String], state: &MetricsViewState, ) { - let stats_block = Block::default() .borders(Borders::ALL) .border_style(Style::default().fg(BORDER)) @@ -1453,8 +1658,14 @@ fn render_label_metric( let stats_line = Line::from(vec![ Span::styled(" TOTAL ", Style::default().fg(BG_DARK).bg(GREEN).bold()), - Span::styled(format!(" {} ", values.len()), Style::default().fg(GREEN).bold()), - Span::styled(format!(" {} in selected time range", metric_name.to_lowercase()), Style::default().fg(TEXT_MUTED)), + Span::styled( + format!(" {} ", values.len()), + Style::default().fg(GREEN).bold(), + ), + Span::styled( + format!(" {} in selected time range", metric_name.to_lowercase()), + Style::default().fg(TEXT_MUTED), + ), ]); let stats_para = Paragraph::new(stats_line).alignment(Alignment::Center); f.render_widget(stats_para, stats_inner); @@ -1476,7 +1687,6 @@ fn render_label_metric( .alignment(Alignment::Center); f.render_widget(empty, list_inner); } else { - let visible_rows = list_inner.height as usize; let total_items = values.len(); @@ -1498,17 +1708,19 @@ fn render_label_metric( let scroll_block = Block::default() .borders(Borders::ALL) .border_style(Style::default().fg(BORDER)) - .title(Line::from(vec![ - Span::styled( - format!(" Showing {}-{} of {} ", - state.scroll + 1, - (state.scroll + visible_rows).min(total_items), - total_items - ), - Style::default().fg(TEXT_MUTED) + .title(Line::from(vec![Span::styled( + format!( + " Showing {}-{} of {} ", + state.scroll + 1, + (state.scroll + visible_rows).min(total_items), + total_items ), - ])) - .title_bottom(Line::from(Span::styled(" j/k scroll ", Style::default().fg(TEXT_MUTED)))) + Style::default().fg(TEXT_MUTED), + )])) + .title_bottom(Line::from(Span::styled( + " j/k scroll ", + Style::default().fg(TEXT_MUTED), + ))) .style(Style::default().bg(BG_DARK)); f.render_widget(scroll_block, chunks[3]); @@ -1516,6 +1728,7 @@ fn render_label_metric( } /// Render a beautiful area chart with Y-axis, filled area, and X-axis +#[allow(clippy::too_many_arguments)] fn render_area_chart( f: &mut Frame, area: Rect, @@ -1554,16 +1767,17 @@ fn render_area_chart( let y_label: String = if row == 0 { format!("{:>9} ", format_metric_value_f64(chart_max, unit)) } else if row == height / 2 { - format!("{:>9} ", format_metric_value_f64((chart_max + chart_min) / 2.0, unit)) + format!( + "{:>9} ", + format_metric_value_f64((chart_max + chart_min) / 2.0, unit) + ) } else if row == height - 1 { format!("{:>9} ", format_metric_value_f64(chart_min, unit)) } else { " ".to_string() }; - let mut spans: Vec = vec![ - Span::styled(y_label, Style::default().fg(TEXT_MUTED)), - ]; + let mut spans: Vec = vec![Span::styled(y_label, Style::default().fg(TEXT_MUTED))]; // Draw each column for col in 0..width { @@ -1619,8 +1833,12 @@ fn render_area_chart( x_axis_spans.push(Span::styled(" ".repeat(padding_to_mid), Style::default())); x_axis_spans.push(Span::styled(&mid_time, Style::default().fg(TEXT_MUTED))); - let remaining_after_mid = width.saturating_sub(first_time.len() + padding_to_mid + mid_time.len() + last_time.len()); - x_axis_spans.push(Span::styled(" ".repeat(remaining_after_mid), Style::default())); + let remaining_after_mid = + width.saturating_sub(first_time.len() + padding_to_mid + mid_time.len() + last_time.len()); + x_axis_spans.push(Span::styled( + " ".repeat(remaining_after_mid), + Style::default(), + )); x_axis_spans.push(Span::styled(&last_time, Style::default().fg(TEXT_MUTED))); lines.push(Line::from(x_axis_spans)); @@ -1669,7 +1887,7 @@ fn format_metric_timestamp_short(ts: u32) -> String { .to_string() .chars() .skip(11) // Skip date portion - .take(8) // Take HH:MM:SS + .take(8) // Take HH:MM:SS .collect() } @@ -1719,29 +1937,25 @@ fn draw_time_picker(f: &mut Frame, state: &MetricsViewState) { let area = f.area(); - let item_count = TimeRangeOption::PRESETS.len() + 1; - let popup_width = 30u16; let popup_height = (item_count as u16) + 4; // Items + borders + title - let popup_x = (area.width.saturating_sub(popup_width)) / 2; let popup_y = (area.height.saturating_sub(popup_height)) / 2; let popup_area = Rect::new(popup_x, popup_y, popup_width, popup_height); - f.render_widget(Clear, popup_area); - let mut items: Vec = TimeRangeOption::PRESETS .iter() .enumerate() .map(|(i, option)| { let is_selected = i == state.time_picker_selected; - let is_current = std::mem::discriminant(option) == std::mem::discriminant(&state.time_range); + let is_current = + std::mem::discriminant(option) == std::mem::discriminant(&state.time_range); let style = if is_selected { Style::default().fg(BG_DARK).bg(GREEN).bold() @@ -1756,7 +1970,6 @@ fn draw_time_picker(f: &mut Frame, state: &MetricsViewState) { }) .collect(); - let custom_index = TimeRangeOption::PRESETS.len(); let is_custom_selected = state.time_picker_selected == custom_index; let is_custom_current = matches!(state.time_range, TimeRangeOption::Custom { .. }); @@ -1770,22 +1983,21 @@ fn draw_time_picker(f: &mut Frame, state: &MetricsViewState) { let custom_marker = if is_custom_current { " ✓" } else { "" }; items.push(ListItem::new(format!(" Custom range...{} ", custom_marker)).style(custom_style)); - let list = List::new(items) - .block( - Block::default() - .borders(Borders::ALL) - .border_style(Style::default().fg(GREEN)) - .title(Line::from(vec![ - Span::styled(" ", Style::default()), - Span::styled("Time Range", Style::default().fg(GREEN).bold()), - Span::styled(" ", Style::default()), - ])) - .title_bottom(Line::from(Span::styled( - " Enter select Esc close ", - Style::default().fg(TEXT_MUTED), - ))) - .style(Style::default().bg(BG_PANEL)), - ); + let list = List::new(items).block( + Block::default() + .borders(Borders::ALL) + .border_style(Style::default().fg(GREEN)) + .title(Line::from(vec![ + Span::styled(" ", Style::default()), + Span::styled("Time Range", Style::default().fg(GREEN).bold()), + Span::styled(" ", Style::default()), + ])) + .title_bottom(Line::from(Span::styled( + " Enter select Esc close ", + Style::default().fg(TEXT_MUTED), + ))) + .style(Style::default().bg(BG_PANEL)), + ); f.render_widget(list, popup_area); } @@ -1796,25 +2008,34 @@ fn draw_calendar_picker(f: &mut Frame, state: &MetricsViewState) { let area = f.area(); - let popup_width = 36u16; let popup_height = 14u16; - let popup_x = (area.width.saturating_sub(popup_width)) / 2; let popup_y = (area.height.saturating_sub(popup_height)) / 2; let popup_area = Rect::new(popup_x, popup_y, popup_width, popup_height); - f.render_widget(Clear, popup_area); // Month names let month_names = [ - "January", "February", "March", "April", "May", "June", - "July", "August", "September", "October", "November", "December" + "January", + "February", + "March", + "April", + "May", + "June", + "July", + "August", + "September", + "October", + "November", + "December", ]; - let month_name = month_names.get(state.calendar_month.saturating_sub(1) as usize).unwrap_or(&"???"); + let month_name = month_names + .get(state.calendar_month.saturating_sub(1) as usize) + .unwrap_or(&"???"); // Calculate first day of month and days in month (with safe fallbacks) let today = Local::now().date_naive(); @@ -1839,7 +2060,10 @@ fn draw_calendar_picker(f: &mut Frame, state: &MetricsViewState) { // Month/Year header with navigation hints lines.push(Line::from(vec![ Span::styled(" [", Style::default().fg(TEXT_MUTED)), - Span::styled(format!("{} {}", month_name, state.calendar_year), Style::default().fg(GREEN).bold()), + Span::styled( + format!("{} {}", month_name, state.calendar_year), + Style::default().fg(GREEN).bold(), + ), Span::styled("] ", Style::default().fg(TEXT_MUTED)), ])); @@ -1873,12 +2097,20 @@ fn draw_calendar_picker(f: &mut Frame, state: &MetricsViewState) { // Check if this day is in the selected range let in_range = match (state.calendar_start, state.calendar_end) { (Some(start), Some(end)) => { - let (s, e) = if start <= end { (start, end) } else { (end, start) }; + let (s, e) = if start <= end { + (start, end) + } else { + (end, start) + }; current_date >= s && current_date <= e } (Some(start), None) if state.calendar_selecting_end => { // Show range preview while selecting end - let (s, e) = if start <= current_date { (start, current_date) } else { (current_date, start) }; + let (s, e) = if start <= current_date { + (start, current_date) + } else { + (current_date, start) + }; current_date >= s && current_date <= e && is_selected } _ => false, @@ -1913,12 +2145,15 @@ fn draw_calendar_picker(f: &mut Frame, state: &MetricsViewState) { if state.calendar_selecting_end { format!("{:02}/{:02}/{} - select end", sm, sd, sy) } else { - format!("Select start date") + "Select start date".to_string() } } _ => "Select start date".to_string(), }; - lines.push(Line::from(Span::styled(format!(" {} ", status), Style::default().fg(CYAN)))); + lines.push(Line::from(Span::styled( + format!(" {} ", status), + Style::default().fg(CYAN), + ))); let calendar_block = Block::default() .borders(Borders::ALL) @@ -1942,7 +2177,6 @@ fn draw_calendar_picker(f: &mut Frame, state: &MetricsViewState) { } fn draw_basins(f: &mut Frame, area: Rect, state: &BasinsState) { - let chunks = Layout::default() .direction(Direction::Vertical) .constraints([ @@ -1955,8 +2189,16 @@ fn draw_basins(f: &mut Frame, area: Rect, state: &BasinsState) { let count_text = if state.loading { " loading...".to_string() } else { - let filtered_count = state.basins.iter() - .filter(|b| state.filter.is_empty() || b.name.to_string().to_lowercase().contains(&state.filter.to_lowercase())) + let filtered_count = state + .basins + .iter() + .filter(|b| { + state.filter.is_empty() + || b.name + .to_string() + .to_lowercase() + .contains(&state.filter.to_lowercase()) + }) .count(); if filtered_count != state.basins.len() { format!(" {}/{} basins", filtered_count, state.basins.len()) @@ -1971,13 +2213,15 @@ fn draw_basins(f: &mut Frame, area: Rect, state: &BasinsState) { Span::styled(&count_text, Style::default().fg(GRAY_700)), ]), ]; - let title_block = Paragraph::new(title_lines) - .block(Block::default() + let title_block = Paragraph::new(title_lines).block( + Block::default() .borders(Borders::BOTTOM) - .border_style(Style::default().fg(GRAY_800))); + .border_style(Style::default().fg(GRAY_800)), + ); f.render_widget(title_block, chunks[0]); - let (search_block, search_text) = render_search_bar(&state.filter, state.filter_active, "Filter by prefix"); + let (search_block, search_text) = + render_search_bar(&state.filter, state.filter_active, "Filter by prefix"); f.render_widget(Paragraph::new(search_text).block(search_block), chunks[1]); let header_area = chunks[2]; @@ -1987,19 +2231,36 @@ fn draw_basins(f: &mut Frame, area: Rect, state: &BasinsState) { let name_col = total_width.saturating_sub(state_col + scope_col + 4); let header = Line::from(vec![ - Span::styled(format!(" {: = state.basins.iter() - .filter(|b| state.filter.is_empty() || b.name.to_string().to_lowercase().contains(&state.filter.to_lowercase())) + let filtered: Vec<_> = state + .basins + .iter() + .filter(|b| { + state.filter.is_empty() + || b.name + .to_string() + .to_lowercase() + .contains(&state.filter.to_lowercase()) + }) .collect(); let table_area = chunks[3]; @@ -2012,14 +2273,23 @@ fn draw_basins(f: &mut Frame, area: Rect, state: &BasinsState) { }; let text = Paragraph::new(Span::styled(msg, Style::default().fg(TEXT_MUTED))) .alignment(Alignment::Center); - f.render_widget(text, Rect::new(table_area.x, table_area.y + 2, table_area.width, 1)); + f.render_widget( + text, + Rect::new(table_area.x, table_area.y + 2, table_area.width, 1), + ); return; } if state.loading { - let text = Paragraph::new(Span::styled("Loading basins...", Style::default().fg(TEXT_MUTED))) - .alignment(Alignment::Center); - f.render_widget(text, Rect::new(table_area.x, table_area.y + 2, table_area.width, 1)); + let text = Paragraph::new(Span::styled( + "Loading basins...", + Style::default().fg(TEXT_MUTED), + )) + .alignment(Alignment::Center); + f.render_widget( + text, + Rect::new(table_area.x, table_area.y + 2, table_area.width, 1), + ); return; } @@ -2033,7 +2303,12 @@ fn draw_basins(f: &mut Frame, area: Rect, state: &BasinsState) { 0 }; - for (view_idx, basin) in filtered.iter().enumerate().skip(scroll_offset).take(visible_height) { + for (view_idx, basin) in filtered + .iter() + .enumerate() + .skip(scroll_offset) + .take(visible_height) + { let y = table_area.y + (view_idx - scroll_offset) as u16; if y >= table_area.y + table_area.height { break; @@ -2059,8 +2334,12 @@ fn draw_basins(f: &mut Frame, area: Rect, state: &BasinsState) { s2_sdk::types::BasinState::Deleting => ("Deleting", Color::Rgb(127, 29, 29)), }; - let scope = basin.scope.as_ref() - .map(|s| match s { s2_sdk::types::BasinScope::AwsUsEast1 => "aws:us-east-1" }) + let scope = basin + .scope + .as_ref() + .map(|s| match s { + s2_sdk::types::BasinScope::AwsUsEast1 => "aws:us-east-1", + }) .unwrap_or("—"); let name_style = if is_selected { @@ -2091,7 +2370,6 @@ fn draw_basins(f: &mut Frame, area: Rect, state: &BasinsState) { } fn draw_streams(f: &mut Frame, area: Rect, state: &StreamsState) { - let chunks = Layout::default() .direction(Direction::Vertical) .constraints([ @@ -2104,8 +2382,16 @@ fn draw_streams(f: &mut Frame, area: Rect, state: &StreamsState) { let count_text = if state.loading { " loading...".to_string() } else { - let filtered_count = state.streams.iter() - .filter(|s| state.filter.is_empty() || s.name.to_string().to_lowercase().contains(&state.filter.to_lowercase())) + let filtered_count = state + .streams + .iter() + .filter(|s| { + state.filter.is_empty() + || s.name + .to_string() + .to_lowercase() + .contains(&state.filter.to_lowercase()) + }) .count(); if filtered_count != state.streams.len() { format!(" {}/{} streams", filtered_count, state.streams.len()) @@ -2123,13 +2409,15 @@ fn draw_streams(f: &mut Frame, area: Rect, state: &StreamsState) { Span::styled(&count_text, Style::default().fg(GRAY_700)), ]), ]; - let title_block = Paragraph::new(title_lines) - .block(Block::default() + let title_block = Paragraph::new(title_lines).block( + Block::default() .borders(Borders::BOTTOM) - .border_style(Style::default().fg(GRAY_800))); + .border_style(Style::default().fg(GRAY_800)), + ); f.render_widget(title_block, chunks[0]); - let (search_block, search_text) = render_search_bar(&state.filter, state.filter_active, "Filter by prefix"); + let (search_block, search_text) = + render_search_bar(&state.filter, state.filter_active, "Filter by prefix"); f.render_widget(Paragraph::new(search_text).block(search_block), chunks[1]); let header_area = chunks[2]; @@ -2138,18 +2426,32 @@ fn draw_streams(f: &mut Frame, area: Rect, state: &StreamsState) { let name_col = total_width.saturating_sub(created_col + 4); let header = Line::from(vec![ - Span::styled(format!(" {: = state.streams.iter() - .filter(|s| state.filter.is_empty() || s.name.to_string().to_lowercase().contains(&state.filter.to_lowercase())) + let filtered: Vec<_> = state + .streams + .iter() + .filter(|s| { + state.filter.is_empty() + || s.name + .to_string() + .to_lowercase() + .contains(&state.filter.to_lowercase()) + }) .collect(); let table_area = chunks[3]; @@ -2162,14 +2464,23 @@ fn draw_streams(f: &mut Frame, area: Rect, state: &StreamsState) { }; let text = Paragraph::new(Span::styled(msg, Style::default().fg(TEXT_MUTED))) .alignment(Alignment::Center); - f.render_widget(text, Rect::new(table_area.x, table_area.y + 2, table_area.width, 1)); + f.render_widget( + text, + Rect::new(table_area.x, table_area.y + 2, table_area.width, 1), + ); return; } if state.loading { - let text = Paragraph::new(Span::styled("Loading streams...", Style::default().fg(TEXT_MUTED))) - .alignment(Alignment::Center); - f.render_widget(text, Rect::new(table_area.x, table_area.y + 2, table_area.width, 1)); + let text = Paragraph::new(Span::styled( + "Loading streams...", + Style::default().fg(TEXT_MUTED), + )) + .alignment(Alignment::Center); + f.render_widget( + text, + Rect::new(table_area.x, table_area.y + 2, table_area.width, 1), + ); return; } @@ -2183,7 +2494,12 @@ fn draw_streams(f: &mut Frame, area: Rect, state: &StreamsState) { 0 }; - for (view_idx, stream) in filtered.iter().enumerate().skip(scroll_offset).take(visible_height) { + for (view_idx, stream) in filtered + .iter() + .enumerate() + .skip(scroll_offset) + .take(visible_height) + { let y = table_area.y + (view_idx - scroll_offset) as u16; if y >= table_area.y + table_area.height { break; @@ -2229,7 +2545,7 @@ fn draw_stream_detail(f: &mut Frame, area: Rect, state: &StreamDetailState) { .direction(Direction::Vertical) .constraints([ Constraint::Length(3), - Constraint::Length(5), // Stats cards + Constraint::Length(5), // Stats cards Constraint::Min(12), ]) .split(area); @@ -2245,17 +2561,18 @@ fn draw_stream_detail(f: &mut Frame, area: Rect, state: &StreamDetailState) { Span::styled(&stream_str, Style::default().fg(GREEN).bold()), ]), ]; - let header = Paragraph::new(header_lines) - .block(Block::default() + let header = Paragraph::new(header_lines).block( + Block::default() .borders(Borders::BOTTOM) - .border_style(Style::default().fg(GRAY_800))); + .border_style(Style::default().fg(GRAY_800)), + ); f.render_widget(header, chunks[0]); let stats_area = Layout::default() .direction(Direction::Horizontal) .constraints([ Constraint::Length(2), - Constraint::Min(20), // Stats content + Constraint::Min(20), // Stats content Constraint::Length(2), ]) .split(chunks[1])[1]; @@ -2270,22 +2587,33 @@ fn draw_stream_detail(f: &mut Frame, area: Rect, state: &StreamDetailState) { ]) .split(stats_area); - fn render_stat_card_v2(f: &mut Frame, area: Rect, icon: &str, label: &str, value: &str, value_color: Color) { + fn render_stat_card_v2( + f: &mut Frame, + area: Rect, + icon: &str, + label: &str, + value: &str, + value_color: Color, + ) { let lines = vec![ Line::from(vec![ Span::styled(icon, Style::default().fg(value_color)), - Span::styled(format!(" {}", label), Style::default().fg(Color::Rgb(120, 120, 120))), + Span::styled( + format!(" {}", label), + Style::default().fg(Color::Rgb(120, 120, 120)), + ), ]), Line::from(vec![ Span::styled(" ", Style::default()), Span::styled(value, Style::default().fg(value_color).bold()), ]), ]; - let widget = Paragraph::new(lines) - .block(Block::default() + let widget = Paragraph::new(lines).block( + Block::default() .borders(Borders::ALL) .border_style(Style::default().fg(GRAY_750)) - .border_type(ratatui::widgets::BorderType::Rounded)); + .border_type(ratatui::widgets::BorderType::Rounded), + ); f.render_widget(widget, area); } @@ -2338,7 +2666,8 @@ fn draw_stream_detail(f: &mut Frame, area: Rect, state: &StreamDetailState) { // Storage Class let (storage_val, storage_color) = if let Some(config) = &state.config { - let val = config.storage_class + let val = config + .storage_class .as_ref() .map(|s| format!("{:?}", s).to_lowercase()) .unwrap_or_else(|| "default".to_string()); @@ -2351,9 +2680,17 @@ fn draw_stream_detail(f: &mut Frame, area: Rect, state: &StreamDetailState) { } else { ("--".to_string(), GRAY_700) }; - render_stat_card_v2(f, stats_chunks[2], "◈", "Storage", &storage_val, storage_color); + render_stat_card_v2( + f, + stats_chunks[2], + "◈", + "Storage", + &storage_val, + storage_color, + ); let (retention_val, retention_color) = if let Some(config) = &state.config { - let val = config.retention_policy + let val = config + .retention_policy .as_ref() .map(|p| match p { crate::types::RetentionPolicy::Age(dur) => { @@ -2380,7 +2717,14 @@ fn draw_stream_detail(f: &mut Frame, area: Rect, state: &StreamDetailState) { } else { ("--".to_string(), GRAY_700) }; - render_stat_card_v2(f, stats_chunks[3], "◔", "Retention", &retention_val, retention_color); + render_stat_card_v2( + f, + stats_chunks[3], + "◔", + "Retention", + &retention_val, + retention_color, + ); let actions_outer = Layout::default() .direction(Direction::Horizontal) @@ -2393,10 +2737,7 @@ fn draw_stream_detail(f: &mut Frame, area: Rect, state: &StreamDetailState) { let action_cols = Layout::default() .direction(Direction::Horizontal) - .constraints([ - Constraint::Ratio(1, 2), - Constraint::Ratio(1, 2), - ]) + .constraints([Constraint::Ratio(1, 2), Constraint::Ratio(1, 2)]) .split(actions_outer); let data_ops: Vec<(&str, &str, &str, &str)> = vec![ ("t", "Tail", "Live stream, see records as they arrive", "◉"), @@ -2410,13 +2751,23 @@ fn draw_stream_detail(f: &mut Frame, area: Rect, state: &StreamDetailState) { ("m", "Trim", "Delete records before seq number", "✂"), ]; - fn render_action_column(f: &mut Frame, area: Rect, title: &str, actions: &[(&str, &str, &str, &str)], selected: usize, offset: usize) { + fn render_action_column( + f: &mut Frame, + area: Rect, + title: &str, + actions: &[(&str, &str, &str, &str)], + selected: usize, + offset: usize, + ) { let title_width = title.len() + 4; let line_width = area.width.saturating_sub(title_width as u16 + 2) as usize; let mut lines = vec![ Line::from(vec![ - Span::styled(format!(" {} ", title), Style::default().fg(Color::Rgb(34, 211, 238)).bold()), + Span::styled( + format!(" {} ", title), + Style::default().fg(Color::Rgb(34, 211, 238)).bold(), + ), Span::styled("─".repeat(line_width), Style::default().fg(GRAY_800)), ]), Line::from(""), @@ -2431,35 +2782,62 @@ fn draw_stream_detail(f: &mut Frame, area: Rect, state: &StreamDetailState) { lines.push(Line::from(vec![ Span::styled(" ▶ ", Style::default().fg(GREEN)), Span::styled(*icon, Style::default().fg(GREEN)), - Span::styled(format!(" {} ", name), Style::default().fg(Color::White).bold()), + Span::styled( + format!(" {} ", name), + Style::default().fg(Color::White).bold(), + ), Span::styled(format!("[{}]", key), Style::default().fg(GREEN).bold()), ])); lines.push(Line::from(vec![ Span::styled(" ", Style::default()), - Span::styled(*desc, Style::default().fg(Color::Rgb(180, 180, 180)).italic()), + Span::styled( + *desc, + Style::default().fg(Color::Rgb(180, 180, 180)).italic(), + ), ])); } else { // Unselected action - dimmed lines.push(Line::from(vec![ Span::styled(" ", Style::default()), Span::styled(*icon, Style::default().fg(Color::Rgb(80, 80, 80))), - Span::styled(format!(" {} ", name), Style::default().fg(Color::Rgb(140, 140, 140))), - Span::styled(format!("[{}]", key), Style::default().fg(Color::Rgb(80, 80, 80))), + Span::styled( + format!(" {} ", name), + Style::default().fg(Color::Rgb(140, 140, 140)), + ), + Span::styled( + format!("[{}]", key), + Style::default().fg(Color::Rgb(80, 80, 80)), + ), ])); } lines.push(Line::from("")); } - let widget = Paragraph::new(lines) - .block(Block::default() + let widget = Paragraph::new(lines).block( + Block::default() .borders(Borders::ALL) .border_style(Style::default().fg(GRAY_750)) - .border_type(ratatui::widgets::BorderType::Rounded)); + .border_type(ratatui::widgets::BorderType::Rounded), + ); f.render_widget(widget, area); } - render_action_column(f, action_cols[0], "Data Operations", &data_ops, state.selected_action, 0); - render_action_column(f, action_cols[1], "Stream Management", &mgmt_ops, state.selected_action, 3); + render_action_column( + f, + action_cols[0], + "Data Operations", + &data_ops, + state.selected_action, + 0, + ); + render_action_column( + f, + action_cols[1], + "Stream Management", + &mgmt_ops, + state.selected_action, + 3, + ); } fn draw_read_view(f: &mut Frame, area: Rect, state: &ReadViewState) { @@ -2484,7 +2862,7 @@ fn draw_read_view(f: &mut Frame, area: Rect, state: &ReadViewState) { .constraints([ Constraint::Length(3), Constraint::Length(sparkline_height), - Constraint::Min(1), // Content + Constraint::Min(1), // Content Constraint::Length(timeline_height), ]) .split(area); @@ -2499,7 +2877,10 @@ fn draw_read_view(f: &mut Frame, area: Rect, state: &ReadViewState) { Span::styled(" / ", Style::default().fg(Color::Rgb(80, 80, 80))), Span::styled(&stream_str, Style::default().fg(Color::Rgb(180, 180, 180))), Span::styled(" ", Style::default()), - Span::styled(format!(" {} ", mode_text), Style::default().fg(BG_DARK).bg(mode_color).bold()), + Span::styled( + format!(" {} ", mode_text), + Style::default().fg(BG_DARK).bg(mode_color).bold(), + ), Span::styled(&record_count, Style::default().fg(GRAY_700)), ]; @@ -2520,23 +2901,25 @@ fn draw_read_view(f: &mut Frame, area: Rect, state: &ReadViewState) { header_spans.push(Span::styled(output, Style::default().fg(YELLOW))); } - let header_lines = vec![ - Line::from(""), - Line::from(header_spans), - ]; - let header = Paragraph::new(header_lines) - .block(Block::default() + let header_lines = vec![Line::from(""), Line::from(header_spans)]; + let header = Paragraph::new(header_lines).block( + Block::default() .borders(Borders::BOTTOM) - .border_style(Style::default().fg(GRAY_800))); + .border_style(Style::default().fg(GRAY_800)), + ); f.render_widget(header, main_chunks[0]); if show_sparklines { - draw_tail_sparklines(f, main_chunks[1], &state.throughput_history, &state.records_per_sec_history); + draw_tail_sparklines( + f, + main_chunks[1], + &state.throughput_history, + &state.records_per_sec_history, + ); } let content_area = main_chunks[2]; - let outer_block = Block::default() - .borders(Borders::NONE); + let outer_block = Block::default().borders(Borders::NONE); let inner_area = outer_block.inner(content_area); f.render_widget(outer_block, content_area); @@ -2549,7 +2932,10 @@ fn draw_read_view(f: &mut Frame, area: Rect, state: &ReadViewState) { }; let para = Paragraph::new(Span::styled(text, Style::default().fg(TEXT_MUTED))) .alignment(Alignment::Center); - f.render_widget(para, Rect::new(inner_area.x, inner_area.y + 2, inner_area.width, 1)); + f.render_widget( + para, + Rect::new(inner_area.x, inner_area.y + 2, inner_area.width, 1), + ); return; } @@ -2566,7 +2952,7 @@ fn draw_read_view(f: &mut Frame, area: Rect, state: &ReadViewState) { .direction(Direction::Horizontal) .constraints([ Constraint::Length(28), - Constraint::Min(20), // Body preview - takes remaining space + Constraint::Min(20), // Body preview - takes remaining space ]) .split(inner_area); @@ -2583,7 +2969,13 @@ fn draw_read_view(f: &mut Frame, area: Rect, state: &ReadViewState) { 0 }; - for (view_idx, record) in state.records.iter().enumerate().skip(scroll_offset).take(visible_height) { + for (view_idx, record) in state + .records + .iter() + .enumerate() + .skip(scroll_offset) + .take(visible_height) + { let y = list_area.y + (view_idx - scroll_offset) as u16; if y >= list_area.y + list_area.height { break; @@ -2607,7 +2999,9 @@ fn draw_read_view(f: &mut Frame, area: Rect, state: &ReadViewState) { Span::styled(sel_indicator, Style::default().fg(GREEN)), Span::styled( format!("#{:<8}", record.seq_num), - Style::default().fg(if is_selected { GREEN } else { TEXT_SECONDARY }).bold(), + Style::default() + .fg(if is_selected { GREEN } else { TEXT_SECONDARY }) + .bold(), ), Span::styled( format!("{:>13}", record.timestamp), @@ -2645,20 +3039,38 @@ fn draw_read_view(f: &mut Frame, area: Rect, state: &ReadViewState) { (body_area.y, body_height) } else { let header_line = Line::from(vec![ - Span::styled(format!(" #{}", record.seq_num), Style::default().fg(GREEN).bold()), - Span::styled(format!(" {}ms", record.timestamp), Style::default().fg(TEXT_MUTED)), - Span::styled(format!(" {} bytes", record.body.len()), Style::default().fg(TEXT_MUTED)), + Span::styled( + format!(" #{}", record.seq_num), + Style::default().fg(GREEN).bold(), + ), + Span::styled( + format!(" {}ms", record.timestamp), + Style::default().fg(TEXT_MUTED), + ), + Span::styled( + format!(" {} bytes", record.body.len()), + Style::default().fg(TEXT_MUTED), + ), if !record.headers.is_empty() { - Span::styled(format!(" ⌘{}", record.headers.len()), Style::default().fg(YELLOW)) + Span::styled( + format!(" ⌘{}", record.headers.len()), + Style::default().fg(YELLOW), + ) } else { Span::styled("", Style::default()) }, ]); - f.render_widget(Paragraph::new(header_line), Rect::new(body_area.x, body_area.y, body_area.width, 1)); + f.render_widget( + Paragraph::new(header_line), + Rect::new(body_area.x, body_area.y, body_area.width, 1), + ); let sep = "─".repeat(body_width); f.render_widget( - Paragraph::new(Span::styled(format!(" {}", sep), Style::default().fg(BORDER))), + Paragraph::new(Span::styled( + format!(" {}", sep), + Style::default().fg(BORDER), + )), Rect::new(body_area.x, body_area.y + 1, body_area.width, 1), ); @@ -2667,7 +3079,10 @@ fn draw_read_view(f: &mut Frame, area: Rect, state: &ReadViewState) { if body.is_empty() { f.render_widget( - Paragraph::new(Span::styled(" (empty body)", Style::default().fg(TEXT_MUTED).italic())), + Paragraph::new(Span::styled( + " (empty body)", + Style::default().fg(TEXT_MUTED).italic(), + )), Rect::new(body_area.x, content_start_y, body_area.width, 1), ); } else { @@ -2675,7 +3090,10 @@ fn draw_read_view(f: &mut Frame, area: Rect, state: &ReadViewState) { for line in body.lines().take(content_height) { if cinema_mode { - display_lines.push(Line::from(Span::styled(line.to_string(), Style::default().fg(TEXT_PRIMARY)))); + display_lines.push(Line::from(Span::styled( + line.to_string(), + Style::default().fg(TEXT_PRIMARY), + ))); } else { let chars: Vec = line.chars().collect(); if chars.is_empty() { @@ -2683,17 +3101,33 @@ fn draw_read_view(f: &mut Frame, area: Rect, state: &ReadViewState) { } else { for chunk in chars.chunks(body_width.max(1)) { let text: String = chunk.iter().collect(); - display_lines.push(Line::from(Span::styled(text, Style::default().fg(TEXT_PRIMARY)))); - if display_lines.len() >= content_height { break; } + display_lines.push(Line::from(Span::styled( + text, + Style::default().fg(TEXT_PRIMARY), + ))); + if display_lines.len() >= content_height { + break; + } } } } - if display_lines.len() >= content_height { break; } + if display_lines.len() >= content_height { + break; + } } - let body_para = Paragraph::new(display_lines) - .block(Block::default().padding(Padding::horizontal(if cinema_mode { 0 } else { 1 }))); - f.render_widget(body_para, Rect::new(body_area.x, content_start_y, body_area.width, content_height as u16)); + let body_para = Paragraph::new(display_lines).block( + Block::default().padding(Padding::horizontal(if cinema_mode { 0 } else { 1 })), + ); + f.render_widget( + body_para, + Rect::new( + body_area.x, + content_start_y, + body_area.width, + content_height as u16, + ), + ); } } @@ -2703,16 +3137,18 @@ fn draw_read_view(f: &mut Frame, area: Rect, state: &ReadViewState) { } // Draw headers popup if showing - if state.show_detail { - if let Some(record) = state.records.get(selected) { - draw_headers_popup(f, record); - } + if state.show_detail + && let Some(record) = state.records.get(selected) + { + draw_headers_popup(f, record); } } fn draw_timeline_scrubber(f: &mut Frame, area: Rect, state: &ReadViewState) { let total = state.records.len(); - if total == 0 { return; } + if total == 0 { + return; + } let selected = state.selected.min(total.saturating_sub(1)); let width = area.width.saturating_sub(4) as usize; @@ -2757,11 +3193,12 @@ fn draw_timeline_scrubber(f: &mut Frame, area: Rect, state: &ReadViewState) { let position_pct = (selected as f64 / (total - 1).max(1) as f64) * 100.0; // Time info from records - let (first_ts, last_ts) = if let (Some(first), Some(last)) = (state.records.front(), state.records.back()) { - (first.timestamp, last.timestamp) - } else { - (0, 0) - }; + let (first_ts, last_ts) = + if let (Some(first), Some(last)) = (state.records.front(), state.records.back()) { + (first.timestamp, last.timestamp) + } else { + (0, 0) + }; let time_span = if last_ts > first_ts { let span_ms = last_ts - first_ts; @@ -2781,37 +3218,58 @@ fn draw_timeline_scrubber(f: &mut Frame, area: Rect, state: &ReadViewState) { .border_style(Style::default().fg(BORDER)) .title(Line::from(vec![ Span::styled(" Timeline ", Style::default().fg(TEXT_MUTED)), - Span::styled(format!("#{}", state.records.get(selected).map(|r| r.seq_num).unwrap_or(0)), Style::default().fg(GREEN).bold()), - Span::styled(format!(" ({:.0}%) ", position_pct), Style::default().fg(TEXT_MUTED)), + Span::styled( + format!( + "#{}", + state.records.get(selected).map(|r| r.seq_num).unwrap_or(0) + ), + Style::default().fg(GREEN).bold(), + ), + Span::styled( + format!(" ({:.0}%) ", position_pct), + Style::default().fg(TEXT_MUTED), + ), Span::styled(time_span, Style::default().fg(CYAN)), ])) - .title_bottom(Line::from(Span::styled(" [ ] seek T toggle ", Style::default().fg(TEXT_MUTED)))); + .title_bottom(Line::from(Span::styled( + " [ ] seek T toggle ", + Style::default().fg(TEXT_MUTED), + ))); let inner = block.inner(area); f.render_widget(block, area); // Draw histogram - f.render_widget(Paragraph::new(Line::from(histogram_spans)).alignment(Alignment::Center), inner); + f.render_widget( + Paragraph::new(Line::from(histogram_spans)).alignment(Alignment::Center), + inner, + ); } fn draw_headers_popup(f: &mut Frame, record: &s2_sdk::types::SequencedRecord) { // Size popup based on number of headers (min height for "no headers" message) - let content_lines = if record.headers.is_empty() { 1 } else { record.headers.len() }; + let content_lines = if record.headers.is_empty() { + 1 + } else { + record.headers.len() + }; let height = (content_lines + 5).min(20) as u16; let area = centered_rect(50, height * 100 / f.area().height.max(1), f.area()); let mut lines = vec![ Line::from(""), - Line::from(vec![ - Span::styled(format!(" Record #{}", record.seq_num), Style::default().fg(GREEN).bold()), - ]), + Line::from(vec![Span::styled( + format!(" Record #{}", record.seq_num), + Style::default().fg(GREEN).bold(), + )]), Line::from(""), ]; if record.headers.is_empty() { - lines.push(Line::from(vec![ - Span::styled(" No headers", Style::default().fg(TEXT_MUTED).italic()), - ])); + lines.push(Line::from(vec![Span::styled( + " No headers", + Style::default().fg(TEXT_MUTED).italic(), + )])); } else { for header in &record.headers { let name = String::from_utf8_lossy(&header.name); @@ -2832,7 +3290,10 @@ fn draw_headers_popup(f: &mut Frame, record: &s2_sdk::types::SequencedRecord) { }; let block = Block::default() - .title(Line::from(Span::styled(title, Style::default().fg(border_color).bold()))) + .title(Line::from(Span::styled( + title, + Style::default().fg(border_color).bold(), + ))) .borders(Borders::ALL) .border_style(Style::default().fg(border_color)) .style(Style::default().bg(BG_DARK)); @@ -2846,10 +3307,7 @@ fn draw_append_view(f: &mut Frame, area: Rect, state: &AppendViewState) { // Split into header and content let outer_chunks = Layout::default() .direction(Direction::Vertical) - .constraints([ - Constraint::Length(3), - Constraint::Min(1), - ]) + .constraints([Constraint::Length(3), Constraint::Min(1)]) .split(area); let basin_str = state.basin_name.to_string(); @@ -2865,18 +3323,16 @@ fn draw_append_view(f: &mut Frame, area: Rect, state: &AppendViewState) { Span::styled(" APPEND ", Style::default().fg(BG_DARK).bg(GREEN).bold()), ]), ]; - let header = Paragraph::new(header_lines) - .block(Block::default() + let header = Paragraph::new(header_lines).block( + Block::default() .borders(Borders::BOTTOM) - .border_style(Style::default().fg(GRAY_800))); + .border_style(Style::default().fg(GRAY_800)), + ); f.render_widget(header, outer_chunks[0]); let main_chunks = Layout::default() .direction(Direction::Horizontal) - .constraints([ - Constraint::Percentage(50), - Constraint::Percentage(50), - ]) + .constraints([Constraint::Percentage(50), Constraint::Percentage(50)]) .split(outer_chunks[1]); let form_block = Block::default() @@ -2897,7 +3353,14 @@ fn draw_append_view(f: &mut Frame, area: Rect, state: &AppendViewState) { let body_editing = body_selected && state.editing; lines.push(Line::from(vec![ Span::styled(selected_marker(body_selected), Style::default().fg(GREEN)), - Span::styled("Body", Style::default().fg(if body_selected { TEXT_PRIMARY } else { TEXT_MUTED })), + Span::styled( + "Body", + Style::default().fg(if body_selected { + TEXT_PRIMARY + } else { + TEXT_MUTED + }), + ), ])); lines.push(Line::from(vec![ Span::styled(" ", Style::default()), @@ -2907,7 +3370,13 @@ fn draw_append_view(f: &mut Frame, area: Rect, state: &AppendViewState) { } else { format!("{}{}", &state.body, cursor(body_editing)) }, - Style::default().fg(if body_editing { GREEN } else if state.body.is_empty() { TEXT_MUTED } else { TEXT_SECONDARY }) + Style::default().fg(if body_editing { + GREEN + } else if state.body.is_empty() { + TEXT_MUTED + } else { + TEXT_SECONDARY + }), ), ])); lines.push(Line::from("")); @@ -2915,9 +3384,22 @@ fn draw_append_view(f: &mut Frame, area: Rect, state: &AppendViewState) { let headers_selected = state.selected == 1; let headers_editing = headers_selected && state.editing; lines.push(Line::from(vec![ - Span::styled(selected_marker(headers_selected), Style::default().fg(GREEN)), - Span::styled("Headers", Style::default().fg(if headers_selected { TEXT_PRIMARY } else { TEXT_MUTED })), - Span::styled(format!(" ({} added)", state.headers.len()), Style::default().fg(TEXT_MUTED)), + Span::styled( + selected_marker(headers_selected), + Style::default().fg(GREEN), + ), + Span::styled( + "Headers", + Style::default().fg(if headers_selected { + TEXT_PRIMARY + } else { + TEXT_MUTED + }), + ), + Span::styled( + format!(" ({} added)", state.headers.len()), + Style::default().fg(TEXT_MUTED), + ), if headers_selected && !headers_editing { Span::styled(" d=del", Style::default().fg(BORDER)) } else { @@ -2938,20 +3420,39 @@ fn draw_append_view(f: &mut Frame, area: Rect, state: &AppendViewState) { lines.push(Line::from(vec![ Span::styled(" + ", Style::default().fg(GREEN)), Span::styled( - format!("{}{}", &state.header_key_input, if state.editing_header_key { "▎" } else { "" }), - Style::default().fg(if state.editing_header_key { GREEN } else { YELLOW }) + format!( + "{}{}", + &state.header_key_input, + if state.editing_header_key { "▎" } else { "" } + ), + Style::default().fg(if state.editing_header_key { + GREEN + } else { + YELLOW + }), ), Span::styled(": ", Style::default().fg(TEXT_MUTED)), Span::styled( - format!("{}{}", &state.header_value_input, if !state.editing_header_key { "▎" } else { "" }), - Style::default().fg(if !state.editing_header_key { GREEN } else { TEXT_SECONDARY }) + format!( + "{}{}", + &state.header_value_input, + if !state.editing_header_key { "▎" } else { "" } + ), + Style::default().fg(if !state.editing_header_key { + GREEN + } else { + TEXT_SECONDARY + }), ), Span::styled(" ⇥=switch", Style::default().fg(BORDER)), ])); } else if headers_selected { lines.push(Line::from(vec![ Span::styled(" ", Style::default()), - Span::styled("Press Enter to add header", Style::default().fg(TEXT_MUTED).italic()), + Span::styled( + "Press Enter to add header", + Style::default().fg(TEXT_MUTED).italic(), + ), ])); } lines.push(Line::from("")); @@ -2960,7 +3461,14 @@ fn draw_append_view(f: &mut Frame, area: Rect, state: &AppendViewState) { let match_editing = match_selected && state.editing; lines.push(Line::from(vec![ Span::styled(selected_marker(match_selected), Style::default().fg(GREEN)), - Span::styled("Match Seq#", Style::default().fg(if match_selected { TEXT_PRIMARY } else { TEXT_MUTED })), + Span::styled( + "Match Seq#", + Style::default().fg(if match_selected { + TEXT_PRIMARY + } else { + TEXT_MUTED + }), + ), Span::styled(" ", Style::default()), Span::styled( if state.match_seq_num.is_empty() && !match_editing { @@ -2968,7 +3476,13 @@ fn draw_append_view(f: &mut Frame, area: Rect, state: &AppendViewState) { } else { format!("{}{}", &state.match_seq_num, cursor(match_editing)) }, - Style::default().fg(if match_editing { GREEN } else if state.match_seq_num.is_empty() { TEXT_MUTED } else { TEXT_SECONDARY }) + Style::default().fg(if match_editing { + GREEN + } else if state.match_seq_num.is_empty() { + TEXT_MUTED + } else { + TEXT_SECONDARY + }), ), ])); lines.push(Line::from("")); @@ -2977,7 +3491,14 @@ fn draw_append_view(f: &mut Frame, area: Rect, state: &AppendViewState) { let fence_editing = fence_selected && state.editing; lines.push(Line::from(vec![ Span::styled(selected_marker(fence_selected), Style::default().fg(GREEN)), - Span::styled("Fencing Token", Style::default().fg(if fence_selected { TEXT_PRIMARY } else { TEXT_MUTED })), + Span::styled( + "Fencing Token", + Style::default().fg(if fence_selected { + TEXT_PRIMARY + } else { + TEXT_MUTED + }), + ), Span::styled(" ", Style::default()), Span::styled( if state.fencing_token.is_empty() && !fence_editing { @@ -2985,7 +3506,13 @@ fn draw_append_view(f: &mut Frame, area: Rect, state: &AppendViewState) { } else { format!("{}{}", &state.fencing_token, cursor(fence_editing)) }, - Style::default().fg(if fence_editing { GREEN } else if state.fencing_token.is_empty() { TEXT_MUTED } else { TEXT_SECONDARY }) + Style::default().fg(if fence_editing { + GREEN + } else if state.fencing_token.is_empty() { + TEXT_MUTED + } else { + TEXT_SECONDARY + }), ), ])); lines.push(Line::from("")); @@ -3003,7 +3530,14 @@ fn draw_append_view(f: &mut Frame, area: Rect, state: &AppendViewState) { let file_editing = file_selected && state.editing; lines.push(Line::from(vec![ Span::styled(selected_marker(file_selected), Style::default().fg(GREEN)), - Span::styled("Input File", Style::default().fg(if file_selected { TEXT_PRIMARY } else { TEXT_MUTED })), + Span::styled( + "Input File", + Style::default().fg(if file_selected { + TEXT_PRIMARY + } else { + TEXT_MUTED + }), + ), Span::styled(" ", Style::default()), Span::styled( if state.input_file.is_empty() && !file_editing { @@ -3011,7 +3545,13 @@ fn draw_append_view(f: &mut Frame, area: Rect, state: &AppendViewState) { } else { format!("{}{}", &state.input_file, cursor(file_editing)) }, - Style::default().fg(if file_editing { GREEN } else if state.input_file.is_empty() { TEXT_MUTED } else { CYAN }) + Style::default().fg(if file_editing { + GREEN + } else if state.input_file.is_empty() { + TEXT_MUTED + } else { + CYAN + }), ), ])); @@ -3020,11 +3560,21 @@ fn draw_append_view(f: &mut Frame, area: Rect, state: &AppendViewState) { let format_opts = [ ("Text", state.input_format == super::app::InputFormat::Text), ("JSON", state.input_format == super::app::InputFormat::Json), - ("JSON+Base64", state.input_format == super::app::InputFormat::JsonBase64), + ( + "JSON+Base64", + state.input_format == super::app::InputFormat::JsonBase64, + ), ]; lines.push(Line::from(vec![ Span::styled(selected_marker(format_selected), Style::default().fg(GREEN)), - Span::styled("Format", Style::default().fg(if format_selected { TEXT_PRIMARY } else { TEXT_MUTED })), + Span::styled( + "Format", + Style::default().fg(if format_selected { + TEXT_PRIMARY + } else { + TEXT_MUTED + }), + ), Span::styled(" ", Style::default()), render_pill(format_opts[0].0, format_selected, format_opts[0].1), Span::raw(" "), @@ -3038,7 +3588,10 @@ fn draw_append_view(f: &mut Frame, area: Rect, state: &AppendViewState) { let pct = if total > 0 { (done * 100) / total } else { 0 }; lines.push(Line::from(vec![ Span::styled(" ", Style::default()), - Span::styled(format!("Progress: {}/{} records ({}%)", done, total, pct), Style::default().fg(YELLOW)), + Span::styled( + format!("Progress: {}/{} records ({}%)", done, total, pct), + Style::default().fg(YELLOW), + ), ])); } lines.push(Line::from("")); @@ -3074,7 +3627,10 @@ fn draw_append_view(f: &mut Frame, area: Rect, state: &AppendViewState) { let history_block = Block::default() .title(Line::from(vec![ Span::styled(" History ", Style::default().fg(TEXT_PRIMARY)), - Span::styled(format!(" {} appended", state.history.len()), Style::default().fg(TEXT_MUTED)), + Span::styled( + format!(" {} appended", state.history.len()), + Style::default().fg(TEXT_MUTED), + ), ])) .borders(Borders::ALL) .border_style(Style::default().fg(BORDER)); @@ -3096,13 +3652,20 @@ fn draw_append_view(f: &mut Frame, area: Rect, state: &AppendViewState) { let mut history_lines: Vec = Vec::new(); for result in state.history.iter().skip(start) { - let mut spans = vec![ - Span::styled(format!("#{:<8}", result.seq_num), Style::default().fg(GREEN)), - ]; + let mut spans = vec![Span::styled( + format!("#{:<8}", result.seq_num), + Style::default().fg(GREEN), + )]; if result.header_count > 0 { - spans.push(Span::styled(format!(" ⌘{}", result.header_count), Style::default().fg(YELLOW))); + spans.push(Span::styled( + format!(" ⌘{}", result.header_count), + Style::default().fg(YELLOW), + )); } - spans.push(Span::styled(format!(" {}", &result.body_preview), Style::default().fg(TEXT_SECONDARY))); + spans.push(Span::styled( + format!(" {}", &result.body_preview), + Style::default().fg(TEXT_SECONDARY), + )); history_lines.push(Line::from(spans)); } @@ -3118,36 +3681,34 @@ fn draw_status_bar(f: &mut Frame, area: Rect, app: &App) { let hints = get_responsive_hints(&app.screen, width); // Create message spans with accessible text prefixes (not just colors) - let message_spans: Option> = app - .message - .as_ref() - .map(|m| { - let (prefix, prefix_color, text_color) = match m.level { - MessageLevel::Info => ("ℹ ", CYAN, ACCENT), - MessageLevel::Success => ("✓ ", SUCCESS, SUCCESS), - MessageLevel::Error => ("✗ ", ERROR, ERROR), - }; - vec![ - Span::styled(prefix, Style::default().fg(prefix_color).bold()), - Span::styled(&m.text, Style::default().fg(text_color)), - ] - }); + let message_spans: Option> = app.message.as_ref().map(|m| { + let (prefix, prefix_color, text_color) = match m.level { + MessageLevel::Info => ("ℹ ", CYAN, ACCENT), + MessageLevel::Success => ("✓ ", SUCCESS, SUCCESS), + MessageLevel::Error => ("✗ ", ERROR, ERROR), + }; + vec![ + Span::styled(prefix, Style::default().fg(prefix_color).bold()), + Span::styled(&m.text, Style::default().fg(text_color)), + ] + }); // PiP indicator let pip_indicator: Option> = app.pip.as_ref().map(|pip| { vec![ Span::styled(" PiP:", Style::default().fg(TEXT_MUTED)), - Span::styled( - format!("{}", pip.stream_name), - Style::default().fg(CYAN), - ), + Span::styled(format!("{}", pip.stream_name), Style::default().fg(CYAN)), Span::styled(" ", Style::default()), ] }); // Calculate available width for hints after message and PiP indicator let msg_len = app.message.as_ref().map(|m| m.text.len() + 2).unwrap_or(0); - let pip_len = app.pip.as_ref().map(|p| p.stream_name.to_string().len() + 7).unwrap_or(0); + let pip_len = app + .pip + .as_ref() + .map(|p| p.stream_name.to_string().len() + 7) + .unwrap_or(0); let available = width.saturating_sub(msg_len + pip_len); // Truncate hints if needed @@ -3199,7 +3760,8 @@ fn get_responsive_hints(screen: &Screen, width: usize) -> String { Screen::Splash | Screen::Setup(_) => String::new(), Screen::Settings(_) => { if wide { - "jk nav | e edit | hl compression | space toggle | ⏎ save | r reload | ⇥ switch | q".to_string() + "jk nav | e edit | hl compression | space toggle | ⏎ save | r reload | ⇥ switch | q" + .to_string() } else if medium { "jk e hl space ⏎ r ⇥ q".to_string() } else { @@ -3226,7 +3788,8 @@ fn get_responsive_hints(screen: &Screen, width: usize) -> String { } Screen::StreamDetail(_) => { if wide { - "t tail | r read | a append | f fence | m trim | p pip | M metrics | e cfg | esc".to_string() + "t tail | r read | a append | f fence | m trim | p pip | M metrics | e cfg | esc" + .to_string() } else if medium { "t tail | r read | a append | p pip | f m M e | esc".to_string() } else { @@ -3238,20 +3801,19 @@ fn get_responsive_hints(screen: &Screen, width: usize) -> String { "esc/⏎ close".to_string() } else if s.is_tailing { if wide { - "jk nav | [] seek | h headers | T timeline | ⇥ list | space pause | esc".to_string() + "jk nav | [] seek | h headers | T timeline | ⇥ list | space pause | esc" + .to_string() } else if medium { "jk [] nav | h | T time | ⇥ | space | esc".to_string() } else { "jk [] h T ⇥ space esc".to_string() } + } else if wide { + "jk nav | [] seek | h headers | T timeline | ⇥ list | esc".to_string() + } else if medium { + "jk [] nav | h | T time | ⇥ | esc".to_string() } else { - if wide { - "jk nav | [] seek | h headers | T timeline | ⇥ list | esc".to_string() - } else if medium { - "jk [] nav | h | T time | ⇥ | esc".to_string() - } else { - "jk [] h T ⇥ esc".to_string() - } + "jk [] h T ⇥ esc".to_string() } } Screen::AppendView(s) => { @@ -3261,12 +3823,10 @@ fn get_responsive_hints(screen: &Screen, width: usize) -> String { } else { "type | ⏎ done | esc cancel".to_string() } + } else if wide { + "jk nav | ⏎ edit/send | d del header | esc back".to_string() } else { - if wide { - "jk nav | ⏎ edit/send | d del header | esc back".to_string() - } else { - "jk ⏎ edit | d del | esc".to_string() - } + "jk ⏎ edit | d del | esc".to_string() } } Screen::AccessTokens(_) => { @@ -3279,18 +3839,19 @@ fn get_responsive_hints(screen: &Screen, width: usize) -> String { } } Screen::MetricsView(state) => { - if matches!(state.metrics_type, MetricsType::Basin { .. } | MetricsType::Account) { + if matches!( + state.metrics_type, + MetricsType::Basin { .. } | MetricsType::Account + ) { if wide { "←→ category | jk scroll | r refresh | esc back | q quit".to_string() } else { "←→ cat | jk | r | esc q".to_string() } + } else if wide { + "jk scroll | r refresh | esc back | q quit".to_string() } else { - if wide { - "jk scroll | r refresh | esc back | q quit".to_string() - } else { - "jk | r | esc q".to_string() - } + "jk | r | esc q".to_string() } } Screen::BenchView(state) => { @@ -3306,12 +3867,10 @@ fn get_responsive_hints(screen: &Screen, width: usize) -> String { } else { "space q".to_string() } + } else if wide { + "r restart | esc back | q quit".to_string() } else { - if wide { - "r restart | esc back | q quit".to_string() - } else { - "r esc q".to_string() - } + "r esc q".to_string() } } } @@ -3333,10 +3892,16 @@ fn draw_help_overlay(f: &mut Frame, screen: &Screen) { fn key(keys: &str, action: &str, desc: &str) -> Line<'static> { let mut spans = vec![ Span::styled(format!("{:>6} ", keys), Style::default().fg(GREEN).bold()), - Span::styled(format!("{:<20}", action), Style::default().fg(TEXT_SECONDARY)), + Span::styled( + format!("{:<20}", action), + Style::default().fg(TEXT_SECONDARY), + ), ]; if !desc.is_empty() { - spans.push(Span::styled(format!(" {}", desc), Style::default().fg(TEXT_MUTED).italic())); + spans.push(Span::styled( + format!(" {}", desc), + Style::default().fg(TEXT_MUTED).italic(), + )); } Line::from(spans) } @@ -3365,13 +3930,21 @@ fn draw_help_overlay(f: &mut Frame, screen: &Screen) { Line::from(""), section("Editing"), key("e", "Edit field", "Modify the selected setting"), - key("h / l", "Cycle option left / right", "Change compression level"), + key( + "h / l", + "Cycle option left / right", + "Change compression level", + ), key("space", "Toggle visibility", "Show/hide auth token"), key("enter", "Save changes", "Write settings to config file"), key("r", "Reload", "Discard changes, reload from file"), Line::from(""), section("Application"), - key("tab", "Switch tab", "Navigate between Basins/Tokens/Settings"), + key( + "tab", + "Switch tab", + "Navigate between Basins/Tokens/Settings", + ), key("q", "Quit", "Exit the application"), Line::from(""), ], @@ -3449,12 +4022,20 @@ fn draw_help_overlay(f: &mut Frame, screen: &Screen) { section("Scrolling"), key("j / k", "Scroll down / up", "Move through records"), key("g / G", "Jump to top / bottom", ""), - key("[ / ]", "Seek backward / forward", "Jump by larger increments"), + key( + "[ / ]", + "Seek backward / forward", + "Jump by larger increments", + ), ]; if state.is_tailing { lines.push(Line::from("")); lines.push(section("Live Tailing")); - lines.push(key("space", "Pause / Resume", "Temporarily stop live updates")); + lines.push(key( + "space", + "Pause / Resume", + "Temporarily stop live updates", + )); } lines.extend(vec![ Line::from(""), @@ -3472,7 +4053,7 @@ fn draw_help_overlay(f: &mut Frame, screen: &Screen) { Line::from(""), ]); lines - }, + } Screen::AppendView(state) => { if state.editing { vec![ @@ -3483,7 +4064,11 @@ fn draw_help_overlay(f: &mut Frame, screen: &Screen) { key("esc", "Cancel", "Discard changes to field"), Line::from(""), section("Header Fields"), - key("tab", "Switch key/value", "Toggle between header key and value"), + key( + "tab", + "Switch key/value", + "Toggle between header key and value", + ), Line::from(""), ] } else { @@ -3498,14 +4083,18 @@ fn draw_help_overlay(f: &mut Frame, screen: &Screen) { Line::from(""), section("Batch from File"), key("enter", "Edit file path", "Enter path to file with records"), - key("", "(one record per line)", "Each line becomes a record body"), + key( + "", + "(one record per line)", + "Each line becomes a record body", + ), Line::from(""), section("Navigation"), key("esc", "Back", "Return to stream detail"), Line::from(""), ] } - }, + } Screen::AccessTokens(_) => vec![ Line::from(""), section("Navigation"), @@ -3529,8 +4118,15 @@ fn draw_help_overlay(f: &mut Frame, screen: &Screen) { section("Navigation"), key("j / k", "Scroll down / up", "Navigate metrics display"), ]; - if matches!(state.metrics_type, MetricsType::Basin { .. } | MetricsType::Account) { - lines.push(key("← / →", "Change category", "Switch between metric types")); + if matches!( + state.metrics_type, + MetricsType::Basin { .. } | MetricsType::Account + ) { + lines.push(key( + "← / →", + "Change category", + "Switch between metric types", + )); } lines.extend(vec![ Line::from(""), @@ -3544,7 +4140,7 @@ fn draw_help_overlay(f: &mut Frame, screen: &Screen) { Line::from(""), ]); lines - }, + } Screen::BenchView(state) => { if state.config_phase { vec![ @@ -3563,7 +4159,11 @@ fn draw_help_overlay(f: &mut Frame, screen: &Screen) { vec![ Line::from(""), section("Benchmark Control"), - key("space", "Pause / Resume", "Temporarily stop/continue benchmark"), + key( + "space", + "Pause / Resume", + "Temporarily stop/continue benchmark", + ), key("q", "Stop", "End benchmark and show results"), Line::from(""), ] @@ -3579,7 +4179,7 @@ fn draw_help_overlay(f: &mut Frame, screen: &Screen) { Line::from(""), ] } - }, + } }; // Add dismiss hint at the bottom @@ -3595,7 +4195,10 @@ fn draw_help_overlay(f: &mut Frame, screen: &Screen) { let title = format!(" {} · Keyboard Shortcuts ", screen_title); let block = Block::default() - .title(Line::from(Span::styled(title, Style::default().fg(TEXT_PRIMARY).bold()))) + .title(Line::from(Span::styled( + title, + Style::default().fg(TEXT_PRIMARY).bold(), + ))) .borders(Borders::ALL) .border_style(Style::default().fg(ACCENT)) .style(Style::default().bg(BG_DARK)) @@ -3651,26 +4254,42 @@ fn draw_input_dialog(f: &mut Frame, mode: &InputMode) { let name_valid = name.len() >= 8 && name.len() <= 48; // Scope options - let scope_opts = [ - ("AWS us-east-1", *scope == BasinScopeOption::AwsUsEast1), - ]; + let scope_opts = [("AWS us-east-1", *scope == BasinScopeOption::AwsUsEast1)]; // Storage class options let storage_opts = [ ("Default", storage_class.is_none()), - ("Standard", matches!(storage_class, Some(StorageClass::Standard))), - ("Express", matches!(storage_class, Some(StorageClass::Express))), + ( + "Standard", + matches!(storage_class, Some(StorageClass::Standard)), + ), + ( + "Express", + matches!(storage_class, Some(StorageClass::Express)), + ), ]; // Timestamping mode options let ts_opts = [ ("Default", timestamping_mode.is_none()), - ("ClientPrefer", matches!(timestamping_mode, Some(TimestampingMode::ClientPrefer))), - ("ClientRequire", matches!(timestamping_mode, Some(TimestampingMode::ClientRequire))), - ("Arrival", matches!(timestamping_mode, Some(TimestampingMode::Arrival))), + ( + "ClientPrefer", + matches!(timestamping_mode, Some(TimestampingMode::ClientPrefer)), + ), + ( + "ClientRequire", + matches!(timestamping_mode, Some(TimestampingMode::ClientRequire)), + ), + ( + "Arrival", + matches!(timestamping_mode, Some(TimestampingMode::Arrival)), + ), ]; let ret_opts = [ - ("Infinite", *retention_policy == RetentionPolicyOption::Infinite), + ( + "Infinite", + *retention_policy == RetentionPolicyOption::Infinite, + ), ("Age-based", *retention_policy == RetentionPolicyOption::Age), ]; @@ -3683,9 +4302,20 @@ fn draw_input_dialog(f: &mut Frame, mode: &InputMode) { // Basin Name field let (ind, lbl) = render_field_row_bold(0, "Name", *selected); - let name_color = if name.is_empty() { GRAY_600 } else if name_valid { GREEN } else { YELLOW }; + let name_color = if name.is_empty() { + GRAY_600 + } else if name_valid { + GREEN + } else { + YELLOW + }; let mut name_spans = vec![ind, lbl, Span::raw(" ")]; - name_spans.extend(render_text_input(name, *selected == 0 && *editing, "enter name...", name_color)); + name_spans.extend(render_text_input( + name, + *selected == 0 && *editing, + "enter name...", + name_color, + )); lines.push(Line::from(name_spans)); // Validation hint @@ -3698,7 +4328,11 @@ fn draw_input_dialog(f: &mut Frame, mode: &InputMode) { } else { format!("{}/48 chars", name.len()) }; - let hint_color = if name_valid || name.is_empty() { GRAY_600 } else { YELLOW }; + let hint_color = if name_valid || name.is_empty() { + GRAY_600 + } else { + YELLOW + }; lines.push(Line::from(vec![ Span::raw(" "), Span::styled(hint_text, Style::default().fg(hint_color).italic()), @@ -3740,8 +4374,16 @@ fn draw_input_dialog(f: &mut Frame, mode: &InputMode) { if *retention_policy == RetentionPolicyOption::Age { let (ind, lbl) = render_field_row_bold(4, " Duration", *selected); let mut duration_spans = vec![ind, lbl, Span::raw(" ")]; - duration_spans.extend(render_text_input(retention_age_input, *selected == 4 && *editing, "", YELLOW)); - duration_spans.push(Span::styled(" e.g. 7d, 30d, 1y", Style::default().fg(GRAY_600).italic())); + duration_spans.extend(render_text_input( + retention_age_input, + *selected == 4 && *editing, + "", + YELLOW, + )); + duration_spans.push(Span::styled( + " e.g. 7d, 30d, 1y", + Style::default().fg(GRAY_600).italic(), + )); lines.push(Line::from(duration_spans)); } @@ -3779,8 +4421,16 @@ fn draw_input_dialog(f: &mut Frame, mode: &InputMode) { if *delete_on_empty_enabled { let (ind, lbl) = render_field_row_bold(8, " Threshold", *selected); let mut threshold_spans = vec![ind, lbl, Span::raw(" ")]; - threshold_spans.extend(render_text_input(delete_on_empty_min_age, *selected == 8 && *editing, "", YELLOW)); - threshold_spans.push(Span::styled(" e.g. 1h, 7d", Style::default().fg(GRAY_600).italic())); + threshold_spans.extend(render_text_input( + delete_on_empty_min_age, + *selected == 8 && *editing, + "", + YELLOW, + )); + threshold_spans.push(Span::styled( + " e.g. 1h, 7d", + Style::default().fg(GRAY_600).italic(), + )); lines.push(Line::from(threshold_spans)); } @@ -3804,13 +4454,19 @@ fn draw_input_dialog(f: &mut Frame, mode: &InputMode) { // Create button section lines.push(Line::from("")); - lines.push(Line::from(vec![ - Span::styled("─".repeat(52), Style::default().fg(GRAY_750)), - ])); + lines.push(Line::from(vec![Span::styled( + "─".repeat(52), + Style::default().fg(GRAY_750), + )])); lines.push(Line::from("")); let can_create = name_valid; - lines.push(render_button("CREATE BASIN", *selected == 11, can_create, GREEN)); + lines.push(render_button( + "CREATE BASIN", + *selected == 11, + can_create, + GREEN, + )); if !can_create { lines.push(Line::from(vec![ Span::raw(" "), @@ -3843,28 +4499,45 @@ fn draw_input_dialog(f: &mut Frame, mode: &InputMode) { // Storage options let storage_opts = [ ("Default", storage_class.is_none()), - ("Standard", matches!(storage_class, Some(StorageClass::Standard))), - ("Express", matches!(storage_class, Some(StorageClass::Express))), + ( + "Standard", + matches!(storage_class, Some(StorageClass::Standard)), + ), + ( + "Express", + matches!(storage_class, Some(StorageClass::Express)), + ), ]; // Timestamping mode options let ts_opts = [ ("Default", timestamping_mode.is_none()), - ("ClientPrefer", matches!(timestamping_mode, Some(TimestampingMode::ClientPrefer))), - ("ClientRequire", matches!(timestamping_mode, Some(TimestampingMode::ClientRequire))), - ("Arrival", matches!(timestamping_mode, Some(TimestampingMode::Arrival))), + ( + "ClientPrefer", + matches!(timestamping_mode, Some(TimestampingMode::ClientPrefer)), + ), + ( + "ClientRequire", + matches!(timestamping_mode, Some(TimestampingMode::ClientRequire)), + ), + ( + "Arrival", + matches!(timestamping_mode, Some(TimestampingMode::Arrival)), + ), ]; let ret_opts = [ - ("Infinite", *retention_policy == RetentionPolicyOption::Infinite), + ( + "Infinite", + *retention_policy == RetentionPolicyOption::Infinite, + ), ("Age-based", *retention_policy == RetentionPolicyOption::Age), ]; - let mut lines = vec![]; - - // Stream name section - lines.push(Line::from("")); - lines.push(render_section_header("Stream name", 48)); - lines.push(Line::from("")); + let mut lines = vec![ + Line::from(""), + render_section_header("Stream name", 48), + Line::from(""), + ]; // Show which basin this stream will be created in lines.push(Line::from(vec![ @@ -3878,7 +4551,12 @@ fn draw_input_dialog(f: &mut Frame, mode: &InputMode) { let (ind, lbl) = render_field_row(0, "Name", *selected); let name_color = if name.is_empty() { GRAY_600 } else { GREEN }; let mut name_spans = vec![ind, lbl, Span::raw(" ")]; - name_spans.extend(render_text_input(name, *selected == 0 && *editing, "enter name...", name_color)); + name_spans.extend(render_text_input( + name, + *selected == 0 && *editing, + "enter name...", + name_color, + )); lines.push(Line::from(name_spans)); // Stream configuration section @@ -3907,8 +4585,16 @@ fn draw_input_dialog(f: &mut Frame, mode: &InputMode) { if *retention_policy == RetentionPolicyOption::Age { let (ind, lbl) = render_field_row(3, " Duration", *selected); let mut duration_spans = vec![ind, lbl, Span::raw(" ")]; - duration_spans.extend(render_text_input(retention_age_input, *selected == 3 && *editing, "", YELLOW)); - duration_spans.push(Span::styled(" e.g. 7d, 30d, 1y", Style::default().fg(GRAY_600).italic())); + duration_spans.extend(render_text_input( + retention_age_input, + *selected == 3 && *editing, + "", + YELLOW, + )); + duration_spans.push(Span::styled( + " e.g. 7d, 30d, 1y", + Style::default().fg(GRAY_600).italic(), + )); lines.push(Line::from(duration_spans)); } @@ -3946,24 +4632,41 @@ fn draw_input_dialog(f: &mut Frame, mode: &InputMode) { if *delete_on_empty_enabled { let (ind, lbl) = render_field_row(7, " Threshold", *selected); let mut threshold_spans = vec![ind, lbl, Span::raw(" ")]; - threshold_spans.extend(render_text_input(delete_on_empty_min_age, *selected == 7 && *editing, "", YELLOW)); - threshold_spans.push(Span::styled(" e.g. 1h, 7d", Style::default().fg(GRAY_600).italic())); + threshold_spans.extend(render_text_input( + delete_on_empty_min_age, + *selected == 7 && *editing, + "", + YELLOW, + )); + threshold_spans.push(Span::styled( + " e.g. 1h, 7d", + Style::default().fg(GRAY_600).italic(), + )); lines.push(Line::from(threshold_spans)); } // Create button section lines.push(Line::from("")); - lines.push(Line::from(vec![ - Span::styled("─".repeat(52), Style::default().fg(GRAY_750)), - ])); + lines.push(Line::from(vec![Span::styled( + "─".repeat(52), + Style::default().fg(GRAY_750), + )])); lines.push(Line::from("")); let can_create = !name.is_empty(); - lines.push(render_button("CREATE STREAM", *selected == 8, can_create, GREEN)); + lines.push(render_button( + "CREATE STREAM", + *selected == 8, + can_create, + GREEN, + )); if !can_create { lines.push(Line::from(vec![ Span::raw(" "), - Span::styled("(enter stream name)", Style::default().fg(GRAY_600).italic()), + Span::styled( + "(enter stream name)", + Style::default().fg(GRAY_600).italic(), + ), ])); } @@ -3986,12 +4689,14 @@ fn draw_input_dialog(f: &mut Frame, mode: &InputMode) { Span::styled("?", Style::default().fg(TEXT_SECONDARY)), ]), Line::from(""), - Line::from(vec![ - Span::styled("This will delete all streams in this basin.", Style::default().fg(TEXT_MUTED)), - ]), - Line::from(vec![ - Span::styled("This action cannot be undone.", Style::default().fg(ERROR)), - ]), + Line::from(vec![Span::styled( + "This will delete all streams in this basin.", + Style::default().fg(TEXT_MUTED), + )]), + Line::from(vec![Span::styled( + "This action cannot be undone.", + Style::default().fg(ERROR), + )]), ], "y confirm n/esc cancel", ), @@ -4011,9 +4716,10 @@ fn draw_input_dialog(f: &mut Frame, mode: &InputMode) { Span::styled(basin.to_string(), Style::default().fg(TEXT_SECONDARY)), ]), Line::from(""), - Line::from(vec![ - Span::styled("This action cannot be undone.", Style::default().fg(ERROR)), - ]), + Line::from(vec![Span::styled( + "This action cannot be undone.", + Style::default().fg(ERROR), + )]), ], "y confirm n/esc cancel", ), @@ -4034,33 +4740,48 @@ fn draw_input_dialog(f: &mut Frame, mode: &InputMode) { // Options let storage_opts = [ ("Default", storage_class.is_none()), - ("Standard", matches!(storage_class, Some(StorageClass::Standard))), - ("Express", matches!(storage_class, Some(StorageClass::Express))), + ( + "Standard", + matches!(storage_class, Some(StorageClass::Standard)), + ), + ( + "Express", + matches!(storage_class, Some(StorageClass::Express)), + ), ]; let ret_opts = [ - ("Infinite", *retention_policy == RetentionPolicyOption::Infinite), + ( + "Infinite", + *retention_policy == RetentionPolicyOption::Infinite, + ), ("Age-based", *retention_policy == RetentionPolicyOption::Age), ]; let ts_opts = [ ("Default", timestamping_mode.is_none()), - ("ClientPrefer", matches!(timestamping_mode, Some(TimestampingMode::ClientPrefer))), - ("ClientRequire", matches!(timestamping_mode, Some(TimestampingMode::ClientRequire))), - ("Arrival", matches!(timestamping_mode, Some(TimestampingMode::Arrival))), + ( + "ClientPrefer", + matches!(timestamping_mode, Some(TimestampingMode::ClientPrefer)), + ), + ( + "ClientRequire", + matches!(timestamping_mode, Some(TimestampingMode::ClientRequire)), + ), + ( + "Arrival", + matches!(timestamping_mode, Some(TimestampingMode::Arrival)), + ), ]; - let mut lines = vec![]; - - // Basin name header - lines.push(Line::from("")); - lines.push(Line::from(vec![ - Span::styled(" ", Style::default()), - Span::styled(basin.to_string(), Style::default().fg(GREEN).bold()), - ])); - - // Default stream configuration section - lines.push(Line::from("")); - lines.push(render_section_header("Default stream configuration", 48)); - lines.push(Line::from("")); + let mut lines = vec![ + Line::from(""), + Line::from(vec![ + Span::styled(" ", Style::default()), + Span::styled(basin.to_string(), Style::default().fg(GREEN).bold()), + ]), + Line::from(""), + render_section_header("Default stream configuration", 48), + Line::from(""), + ]; // Storage Class let (ind, lbl) = render_field_row_bold(0, "Storage", *selected); @@ -4082,10 +4803,22 @@ fn draw_input_dialog(f: &mut Frame, mode: &InputMode) { if *retention_policy == RetentionPolicyOption::Age { let (ind, lbl) = render_field_row_bold(2, " Duration", *selected); - let age_display = if *editing_age { age_input.clone() } else { format!("{}s", retention_age_secs) }; + let age_display = if *editing_age { + age_input.clone() + } else { + format!("{}s", retention_age_secs) + }; let mut duration_spans = vec![ind, lbl, Span::raw(" ")]; - duration_spans.extend(render_text_input(&age_display, *selected == 2 && *editing_age, "", YELLOW)); - duration_spans.push(Span::styled(" e.g. 604800 (7 days)", Style::default().fg(GRAY_600).italic())); + duration_spans.extend(render_text_input( + &age_display, + *selected == 2 && *editing_age, + "", + YELLOW, + )); + duration_spans.push(Span::styled( + " e.g. 604800 (7 days)", + Style::default().fg(GRAY_600).italic(), + )); lines.push(Line::from(duration_spans)); } @@ -4102,7 +4835,10 @@ fn draw_input_dialog(f: &mut Frame, mode: &InputMode) { // Uncapped Timestamps let (ind, lbl) = render_field_row_bold(4, " Uncapped", *selected); let mut uncapped_spans = vec![ind, lbl, Span::raw(" ")]; - uncapped_spans.extend(render_toggle(timestamping_uncapped.unwrap_or(false), *selected == 4)); + uncapped_spans.extend(render_toggle( + timestamping_uncapped.unwrap_or(false), + *selected == 4, + )); lines.push(Line::from(uncapped_spans)); // Create streams automatically section @@ -4113,14 +4849,20 @@ fn draw_input_dialog(f: &mut Frame, mode: &InputMode) { // On Append let (ind, lbl) = render_field_row_bold(5, "On append", *selected); let mut append_spans = vec![ind, lbl, Span::raw(" ")]; - append_spans.extend(render_toggle(create_stream_on_append.unwrap_or(false), *selected == 5)); + append_spans.extend(render_toggle( + create_stream_on_append.unwrap_or(false), + *selected == 5, + )); lines.push(Line::from(append_spans)); // On Read lines.push(Line::from("")); let (ind, lbl) = render_field_row_bold(6, "On read", *selected); let mut read_spans = vec![ind, lbl, Span::raw(" ")]; - read_spans.extend(render_toggle(create_stream_on_read.unwrap_or(false), *selected == 6)); + read_spans.extend(render_toggle( + create_stream_on_read.unwrap_or(false), + *selected == 6, + )); lines.push(Line::from(read_spans)); lines.push(Line::from("")); @@ -4149,18 +4891,36 @@ fn draw_input_dialog(f: &mut Frame, mode: &InputMode) { // Options let storage_opts = [ ("Default", storage_class.is_none()), - ("Standard", matches!(storage_class, Some(StorageClass::Standard))), - ("Express", matches!(storage_class, Some(StorageClass::Express))), + ( + "Standard", + matches!(storage_class, Some(StorageClass::Standard)), + ), + ( + "Express", + matches!(storage_class, Some(StorageClass::Express)), + ), ]; let ret_opts = [ - ("Infinite", *retention_policy == RetentionPolicyOption::Infinite), + ( + "Infinite", + *retention_policy == RetentionPolicyOption::Infinite, + ), ("Age-based", *retention_policy == RetentionPolicyOption::Age), ]; let ts_opts = [ ("Default", timestamping_mode.is_none()), - ("ClientPrefer", matches!(timestamping_mode, Some(TimestampingMode::ClientPrefer))), - ("ClientRequire", matches!(timestamping_mode, Some(TimestampingMode::ClientRequire))), - ("Arrival", matches!(timestamping_mode, Some(TimestampingMode::Arrival))), + ( + "ClientPrefer", + matches!(timestamping_mode, Some(TimestampingMode::ClientPrefer)), + ), + ( + "ClientRequire", + matches!(timestamping_mode, Some(TimestampingMode::ClientRequire)), + ), + ( + "Arrival", + matches!(timestamping_mode, Some(TimestampingMode::Arrival)), + ), ]; let mut lines = vec![]; @@ -4169,7 +4929,10 @@ fn draw_input_dialog(f: &mut Frame, mode: &InputMode) { lines.push(Line::from("")); lines.push(Line::from(vec![ Span::styled(" ", Style::default()), - Span::styled(format!("{}/{}", basin, stream), Style::default().fg(GREEN).bold()), + Span::styled( + format!("{}/{}", basin, stream), + Style::default().fg(GREEN).bold(), + ), ])); // Stream configuration section @@ -4197,10 +4960,22 @@ fn draw_input_dialog(f: &mut Frame, mode: &InputMode) { if *retention_policy == RetentionPolicyOption::Age { let (ind, lbl) = render_field_row(2, " Duration", *selected); - let age_display = if *selected == 2 && *editing_age { age_input.clone() } else { format!("{}s", retention_age_secs) }; + let age_display = if *selected == 2 && *editing_age { + age_input.clone() + } else { + format!("{}s", retention_age_secs) + }; let mut duration_spans = vec![ind, lbl, Span::raw(" ")]; - duration_spans.extend(render_text_input(&age_display, *selected == 2 && *editing_age, "", YELLOW)); - duration_spans.push(Span::styled(" e.g. 604800 (7 days)", Style::default().fg(GRAY_600).italic())); + duration_spans.extend(render_text_input( + &age_display, + *selected == 2 && *editing_age, + "", + YELLOW, + )); + duration_spans.push(Span::styled( + " e.g. 604800 (7 days)", + Style::default().fg(GRAY_600).italic(), + )); lines.push(Line::from(duration_spans)); } @@ -4217,7 +4992,10 @@ fn draw_input_dialog(f: &mut Frame, mode: &InputMode) { // Uncapped Timestamps let (ind, lbl) = render_field_row(4, " Uncapped", *selected); let mut uncapped_spans = vec![ind, lbl, Span::raw(" ")]; - uncapped_spans.extend(render_toggle(timestamping_uncapped.unwrap_or(false), *selected == 4)); + uncapped_spans.extend(render_toggle( + timestamping_uncapped.unwrap_or(false), + *selected == 4, + )); lines.push(Line::from(uncapped_spans)); // Delete on Empty @@ -4238,8 +5016,16 @@ fn draw_input_dialog(f: &mut Frame, mode: &InputMode) { if *delete_on_empty_enabled { let (ind, lbl) = render_field_row(6, " Threshold", *selected); let mut threshold_spans = vec![ind, lbl, Span::raw(" ")]; - threshold_spans.extend(render_text_input(delete_on_empty_min_age, *selected == 6 && *editing_age, "", YELLOW)); - threshold_spans.push(Span::styled(" e.g. 1h, 7d", Style::default().fg(GRAY_600).italic())); + threshold_spans.extend(render_text_input( + delete_on_empty_min_age, + *selected == 6 && *editing_age, + "", + YELLOW, + )); + threshold_spans.push(Span::styled( + " e.g. 1h, 7d", + Style::default().fg(GRAY_600).italic(), + )); lines.push(Line::from(threshold_spans)); } @@ -4291,7 +5077,10 @@ fn draw_input_dialog(f: &mut Frame, mode: &InputMode) { lines.push(Line::from("")); lines.push(Line::from(vec![ Span::styled(" Reading from: ", Style::default().fg(TEXT_MUTED)), - Span::styled(format!("s2://{}/{}", basin, stream), Style::default().fg(GREEN).bold()), + Span::styled( + format!("s2://{}/{}", basin, stream), + Style::default().fg(GREEN).bold(), + ), ])); // Start position section @@ -4303,20 +5092,36 @@ fn draw_input_dialog(f: &mut Frame, mode: &InputMode) { let is_seq = *start_from == ReadStartFrom::SeqNum; let (ind, lbl) = render_field_row(0, "Sequence #", *selected); let mut seq_spans = vec![ind]; - seq_spans.push(Span::styled(if is_seq { "● " } else { "○ " }, Style::default().fg(if is_seq { GREEN } else { GRAY_650 }))); + seq_spans.push(Span::styled( + if is_seq { "● " } else { "○ " }, + Style::default().fg(if is_seq { GREEN } else { GRAY_650 }), + )); seq_spans.push(lbl); seq_spans.push(Span::raw(" ")); - seq_spans.extend(render_text_input(seq_num_value, *selected == 0 && *editing, "0", if is_seq { GREEN } else { TEXT_MUTED })); + seq_spans.extend(render_text_input( + seq_num_value, + *selected == 0 && *editing, + "0", + if is_seq { GREEN } else { TEXT_MUTED }, + )); lines.push(Line::from(seq_spans)); // Row 1: Timestamp option let is_ts = *start_from == ReadStartFrom::Timestamp; let (ind, lbl) = render_field_row(1, "Timestamp", *selected); let mut ts_spans = vec![ind]; - ts_spans.push(Span::styled(if is_ts { "● " } else { "○ " }, Style::default().fg(if is_ts { GREEN } else { GRAY_650 }))); + ts_spans.push(Span::styled( + if is_ts { "● " } else { "○ " }, + Style::default().fg(if is_ts { GREEN } else { GRAY_650 }), + )); ts_spans.push(lbl); ts_spans.push(Span::raw(" ")); - ts_spans.extend(render_text_input(timestamp_value, *selected == 1 && *editing, "0", if is_ts { GREEN } else { TEXT_MUTED })); + ts_spans.extend(render_text_input( + timestamp_value, + *selected == 1 && *editing, + "0", + if is_ts { GREEN } else { TEXT_MUTED }, + )); ts_spans.push(Span::styled(" ms", Style::default().fg(TEXT_MUTED))); lines.push(Line::from(ts_spans)); @@ -4324,22 +5129,44 @@ fn draw_input_dialog(f: &mut Frame, mode: &InputMode) { let is_ago = *start_from == ReadStartFrom::Ago; let (ind, lbl) = render_field_row(2, "Time ago", *selected); let mut ago_spans = vec![ind]; - ago_spans.push(Span::styled(if is_ago { "● " } else { "○ " }, Style::default().fg(if is_ago { GREEN } else { GRAY_650 }))); + ago_spans.push(Span::styled( + if is_ago { "● " } else { "○ " }, + Style::default().fg(if is_ago { GREEN } else { GRAY_650 }), + )); ago_spans.push(lbl); ago_spans.push(Span::raw(" ")); - ago_spans.extend(render_text_input(ago_value, *selected == 2 && *editing, "5", if is_ago { GREEN } else { TEXT_MUTED })); - ago_spans.push(Span::styled(format!(" {}", unit_str), Style::default().fg(if is_ago { TEXT_SECONDARY } else { TEXT_MUTED }))); - ago_spans.push(Span::styled(" ‹tab› cycle", Style::default().fg(GRAY_600).italic())); + ago_spans.extend(render_text_input( + ago_value, + *selected == 2 && *editing, + "5", + if is_ago { GREEN } else { TEXT_MUTED }, + )); + ago_spans.push(Span::styled( + format!(" {}", unit_str), + Style::default().fg(if is_ago { TEXT_SECONDARY } else { TEXT_MUTED }), + )); + ago_spans.push(Span::styled( + " ‹tab› cycle", + Style::default().fg(GRAY_600).italic(), + )); lines.push(Line::from(ago_spans)); // Row 3: Tail offset option let is_off = *start_from == ReadStartFrom::TailOffset; let (ind, lbl) = render_field_row(3, "Tail offset", *selected); let mut off_spans = vec![ind]; - off_spans.push(Span::styled(if is_off { "● " } else { "○ " }, Style::default().fg(if is_off { GREEN } else { GRAY_650 }))); + off_spans.push(Span::styled( + if is_off { "● " } else { "○ " }, + Style::default().fg(if is_off { GREEN } else { GRAY_650 }), + )); off_spans.push(lbl); off_spans.push(Span::raw(" ")); - off_spans.extend(render_text_input(tail_offset_value, *selected == 3 && *editing, "10", if is_off { GREEN } else { TEXT_MUTED })); + off_spans.extend(render_text_input( + tail_offset_value, + *selected == 3 && *editing, + "10", + if is_off { GREEN } else { TEXT_MUTED }, + )); off_spans.push(Span::styled(" back", Style::default().fg(TEXT_MUTED))); lines.push(Line::from(off_spans)); @@ -4351,19 +5178,34 @@ fn draw_input_dialog(f: &mut Frame, mode: &InputMode) { // Row 4: Max records let (ind, lbl) = render_field_row(4, "Max records", *selected); let mut count_spans = vec![ind, lbl, Span::raw(" ")]; - count_spans.extend(render_text_input(count_limit, *selected == 4 && *editing, "∞ unlimited", YELLOW)); + count_spans.extend(render_text_input( + count_limit, + *selected == 4 && *editing, + "∞ unlimited", + YELLOW, + )); lines.push(Line::from(count_spans)); // Row 5: Max bytes let (ind, lbl) = render_field_row(5, "Max bytes", *selected); let mut bytes_spans = vec![ind, lbl, Span::raw(" ")]; - bytes_spans.extend(render_text_input(byte_limit, *selected == 5 && *editing, "∞ unlimited", YELLOW)); + bytes_spans.extend(render_text_input( + byte_limit, + *selected == 5 && *editing, + "∞ unlimited", + YELLOW, + )); lines.push(Line::from(bytes_spans)); // Row 6: Until timestamp let (ind, lbl) = render_field_row(6, "Until", *selected); let mut until_spans = vec![ind, lbl, Span::raw(" ")]; - until_spans.extend(render_text_input(until_timestamp, *selected == 6 && *editing, "∞ unlimited", YELLOW)); + until_spans.extend(render_text_input( + until_timestamp, + *selected == 6 && *editing, + "∞ unlimited", + YELLOW, + )); until_spans.push(Span::styled(" ms", Style::default().fg(TEXT_MUTED))); lines.push(Line::from(until_spans)); @@ -4391,14 +5233,20 @@ fn draw_input_dialog(f: &mut Frame, mode: &InputMode) { lines.push(Line::from("")); let (ind, lbl) = render_field_row(9, "Output file", *selected); let mut output_spans = vec![ind, lbl, Span::raw(" ")]; - output_spans.extend(render_text_input(output_file, *selected == 9 && *editing, "display only", TEXT_SECONDARY)); + output_spans.extend(render_text_input( + output_file, + *selected == 9 && *editing, + "display only", + TEXT_SECONDARY, + )); lines.push(Line::from(output_spans)); // Divider and button lines.push(Line::from("")); - lines.push(Line::from(vec![ - Span::styled("─".repeat(52), Style::default().fg(GRAY_750)), - ])); + lines.push(Line::from(vec![Span::styled( + "─".repeat(52), + Style::default().fg(GRAY_750), + )])); lines.push(Line::from("")); // Row 10: Start button @@ -4427,10 +5275,16 @@ fn draw_input_dialog(f: &mut Frame, mode: &InputMode) { let mut lines = vec![ Line::from(vec![ Span::styled(" ", Style::default()), - Span::styled(format!("s2://{}/{}", basin, stream), Style::default().fg(GREEN)), + Span::styled( + format!("s2://{}/{}", basin, stream), + Style::default().fg(GREEN), + ), ]), Line::from(""), - Line::from(Span::styled(" Set a new fencing token to block other writers.", Style::default().fg(TEXT_MUTED))), + Line::from(Span::styled( + " Set a new fencing token to block other writers.", + Style::default().fg(TEXT_MUTED), + )), Line::from(""), ]; @@ -4438,14 +5292,27 @@ fn draw_input_dialog(f: &mut Frame, mode: &InputMode) { let new_editing = *editing && *selected == 0; lines.push(Line::from(vec![ Span::styled(marker(*selected == 0), Style::default().fg(GREEN)), - Span::styled("New Token ", Style::default().fg(if *selected == 0 { TEXT_PRIMARY } else { TEXT_MUTED })), + Span::styled( + "New Token ", + Style::default().fg(if *selected == 0 { + TEXT_PRIMARY + } else { + TEXT_MUTED + }), + ), Span::styled( if new_token.is_empty() && !new_editing { "(required)".to_string() } else { format!("{}{}", new_token, cursor(new_editing)) }, - Style::default().fg(if new_editing { GREEN } else if new_token.is_empty() { WARNING } else { TEXT_SECONDARY }) + Style::default().fg(if new_editing { + GREEN + } else if new_token.is_empty() { + WARNING + } else { + TEXT_SECONDARY + }), ), ])); @@ -4453,14 +5320,27 @@ fn draw_input_dialog(f: &mut Frame, mode: &InputMode) { let cur_editing = *editing && *selected == 1; lines.push(Line::from(vec![ Span::styled(marker(*selected == 1), Style::default().fg(GREEN)), - Span::styled("Current Token ", Style::default().fg(if *selected == 1 { TEXT_PRIMARY } else { TEXT_MUTED })), + Span::styled( + "Current Token ", + Style::default().fg(if *selected == 1 { + TEXT_PRIMARY + } else { + TEXT_MUTED + }), + ), Span::styled( if current_token.is_empty() && !cur_editing { "(none)".to_string() } else { format!("{}{}", current_token, cursor(cur_editing)) }, - Style::default().fg(if cur_editing { GREEN } else if current_token.is_empty() { TEXT_MUTED } else { TEXT_SECONDARY }) + Style::default().fg(if cur_editing { + GREEN + } else if current_token.is_empty() { + TEXT_MUTED + } else { + TEXT_SECONDARY + }), ), ])); @@ -4478,11 +5358,7 @@ fn draw_input_dialog(f: &mut Frame, mode: &InputMode) { Span::styled(" ▶ FENCE ", Style::default().fg(btn_fg).bg(btn_bg).bold()), ])); - ( - " Fence Stream ", - lines, - "↑↓ nav ⏎ edit/submit esc", - ) + (" Fence Stream ", lines, "↑↓ nav ⏎ edit/submit esc") } InputMode::Trim { @@ -4499,11 +5375,20 @@ fn draw_input_dialog(f: &mut Frame, mode: &InputMode) { let mut lines = vec![ Line::from(vec![ Span::styled(" ", Style::default()), - Span::styled(format!("s2://{}/{}", basin, stream), Style::default().fg(GREEN)), + Span::styled( + format!("s2://{}/{}", basin, stream), + Style::default().fg(GREEN), + ), ]), Line::from(""), - Line::from(Span::styled(" Delete all records before the trim point.", Style::default().fg(TEXT_MUTED))), - Line::from(Span::styled(" This is eventually consistent.", Style::default().fg(TEXT_MUTED))), + Line::from(Span::styled( + " Delete all records before the trim point.", + Style::default().fg(TEXT_MUTED), + )), + Line::from(Span::styled( + " This is eventually consistent.", + Style::default().fg(TEXT_MUTED), + )), Line::from(""), ]; @@ -4511,14 +5396,27 @@ fn draw_input_dialog(f: &mut Frame, mode: &InputMode) { let trim_editing = *editing && *selected == 0; lines.push(Line::from(vec![ Span::styled(marker(*selected == 0), Style::default().fg(GREEN)), - Span::styled("Trim Point ", Style::default().fg(if *selected == 0 { TEXT_PRIMARY } else { TEXT_MUTED })), + Span::styled( + "Trim Point ", + Style::default().fg(if *selected == 0 { + TEXT_PRIMARY + } else { + TEXT_MUTED + }), + ), Span::styled( if trim_point.is_empty() && !trim_editing { "(seq num)".to_string() } else { format!("{}{}", trim_point, cursor(trim_editing)) }, - Style::default().fg(if trim_editing { GREEN } else if trim_point.is_empty() { WARNING } else { TEXT_SECONDARY }) + Style::default().fg(if trim_editing { + GREEN + } else if trim_point.is_empty() { + WARNING + } else { + TEXT_SECONDARY + }), ), ])); @@ -4526,14 +5424,27 @@ fn draw_input_dialog(f: &mut Frame, mode: &InputMode) { let fence_editing = *editing && *selected == 1; lines.push(Line::from(vec![ Span::styled(marker(*selected == 1), Style::default().fg(GREEN)), - Span::styled("Fencing Token ", Style::default().fg(if *selected == 1 { TEXT_PRIMARY } else { TEXT_MUTED })), + Span::styled( + "Fencing Token ", + Style::default().fg(if *selected == 1 { + TEXT_PRIMARY + } else { + TEXT_MUTED + }), + ), Span::styled( if fencing_token.is_empty() && !fence_editing { "(none)".to_string() } else { format!("{}{}", fencing_token, cursor(fence_editing)) }, - Style::default().fg(if fence_editing { GREEN } else if fencing_token.is_empty() { TEXT_MUTED } else { TEXT_SECONDARY }) + Style::default().fg(if fence_editing { + GREEN + } else if fencing_token.is_empty() { + TEXT_MUTED + } else { + TEXT_SECONDARY + }), ), ])); @@ -4551,11 +5462,7 @@ fn draw_input_dialog(f: &mut Frame, mode: &InputMode) { Span::styled(" ▶ TRIM ", Style::default().fg(btn_fg).bg(btn_bg).bold()), ])); - ( - " Trim Stream ", - lines, - "↑↓ nav ⏎ edit/submit esc", - ) + (" Trim Stream ", lines, "↑↓ nav ⏎ edit/submit esc") } InputMode::IssueAccessToken { @@ -4588,22 +5495,45 @@ fn draw_input_dialog(f: &mut Frame, mode: &InputMode) { let id_editing = *editing && *selected == 0; lines.push(Line::from(vec![ Span::styled(marker(*selected == 0), Style::default().fg(GREEN)), - Span::styled("Token ID ", Style::default().fg(if *selected == 0 { TEXT_PRIMARY } else { TEXT_MUTED })), + Span::styled( + "Token ID ", + Style::default().fg(if *selected == 0 { + TEXT_PRIMARY + } else { + TEXT_MUTED + }), + ), Span::styled( if id.is_empty() && !id_editing { "(required)".to_string() } else { format!("{}{}", id, cursor(id_editing)) }, - Style::default().fg(if id_editing { GREEN } else if id.is_empty() { WARNING } else { TEXT_SECONDARY }) + Style::default().fg(if id_editing { + GREEN + } else if id.is_empty() { + WARNING + } else { + TEXT_SECONDARY + }), ), ])); // Row 1: Expiration (cycle) lines.push(Line::from(vec![ Span::styled(marker(*selected == 1), Style::default().fg(GREEN)), - Span::styled("Expiration ", Style::default().fg(if *selected == 1 { TEXT_PRIMARY } else { TEXT_MUTED })), - Span::styled(format!("< {} >", expiry.as_str()), Style::default().fg(TEXT_SECONDARY)), + Span::styled( + "Expiration ", + Style::default().fg(if *selected == 1 { + TEXT_PRIMARY + } else { + TEXT_MUTED + }), + ), + Span::styled( + format!("< {} >", expiry.as_str()), + Style::default().fg(TEXT_SECONDARY), + ), ])); // Row 2: Custom expiration (only if Custom selected) @@ -4611,25 +5541,49 @@ fn draw_input_dialog(f: &mut Frame, mode: &InputMode) { let custom_editing = *editing && *selected == 2; lines.push(Line::from(vec![ Span::styled(marker(*selected == 2), Style::default().fg(GREEN)), - Span::styled(" Custom ", Style::default().fg(if *selected == 2 { TEXT_PRIMARY } else { TEXT_MUTED })), + Span::styled( + " Custom ", + Style::default().fg(if *selected == 2 { + TEXT_PRIMARY + } else { + TEXT_MUTED + }), + ), Span::styled( if expiry_custom.is_empty() && !custom_editing { "(e.g., 30d, 1w)".to_string() } else { format!("{}{}", expiry_custom, cursor(custom_editing)) }, - Style::default().fg(if custom_editing { GREEN } else { TEXT_SECONDARY }) + Style::default().fg(if custom_editing { + GREEN + } else { + TEXT_SECONDARY + }), ), ])); } lines.push(Line::from("")); - lines.push(Line::from(Span::styled("── Resources ──", Style::default().fg(TEXT_MUTED)))); + lines.push(Line::from(Span::styled( + "── Resources ──", + Style::default().fg(TEXT_MUTED), + ))); // Row 3: Basins scope lines.push(Line::from(vec![ Span::styled(marker(*selected == 3), Style::default().fg(GREEN)), - Span::styled("Basins ", Style::default().fg(if *selected == 3 { TEXT_PRIMARY } else { TEXT_MUTED })), - Span::styled(format!("< {} >", basins_scope.as_str()), Style::default().fg(TEXT_SECONDARY)), + Span::styled( + "Basins ", + Style::default().fg(if *selected == 3 { + TEXT_PRIMARY + } else { + TEXT_MUTED + }), + ), + Span::styled( + format!("< {} >", basins_scope.as_str()), + Style::default().fg(TEXT_SECONDARY), + ), ])); // Row 4: Basins value (only if Prefix/Exact) @@ -4637,14 +5591,25 @@ fn draw_input_dialog(f: &mut Frame, mode: &InputMode) { let basins_editing = *editing && *selected == 4; lines.push(Line::from(vec![ Span::styled(marker(*selected == 4), Style::default().fg(GREEN)), - Span::styled(" Pattern ", Style::default().fg(if *selected == 4 { TEXT_PRIMARY } else { TEXT_MUTED })), + Span::styled( + " Pattern ", + Style::default().fg(if *selected == 4 { + TEXT_PRIMARY + } else { + TEXT_MUTED + }), + ), Span::styled( if basins_value.is_empty() && !basins_editing { "(enter pattern)".to_string() } else { format!("{}{}", basins_value, cursor(basins_editing)) }, - Style::default().fg(if basins_editing { GREEN } else { TEXT_SECONDARY }) + Style::default().fg(if basins_editing { + GREEN + } else { + TEXT_SECONDARY + }), ), ])); } @@ -4652,8 +5617,18 @@ fn draw_input_dialog(f: &mut Frame, mode: &InputMode) { // Row 5: Streams scope lines.push(Line::from(vec![ Span::styled(marker(*selected == 5), Style::default().fg(GREEN)), - Span::styled("Streams ", Style::default().fg(if *selected == 5 { TEXT_PRIMARY } else { TEXT_MUTED })), - Span::styled(format!("< {} >", streams_scope.as_str()), Style::default().fg(TEXT_SECONDARY)), + Span::styled( + "Streams ", + Style::default().fg(if *selected == 5 { + TEXT_PRIMARY + } else { + TEXT_MUTED + }), + ), + Span::styled( + format!("< {} >", streams_scope.as_str()), + Style::default().fg(TEXT_SECONDARY), + ), ])); // Row 6: Streams value (only if Prefix/Exact) @@ -4661,14 +5636,25 @@ fn draw_input_dialog(f: &mut Frame, mode: &InputMode) { let streams_editing = *editing && *selected == 6; lines.push(Line::from(vec![ Span::styled(marker(*selected == 6), Style::default().fg(GREEN)), - Span::styled(" Pattern ", Style::default().fg(if *selected == 6 { TEXT_PRIMARY } else { TEXT_MUTED })), + Span::styled( + " Pattern ", + Style::default().fg(if *selected == 6 { + TEXT_PRIMARY + } else { + TEXT_MUTED + }), + ), Span::styled( if streams_value.is_empty() && !streams_editing { "(enter pattern)".to_string() } else { format!("{}{}", streams_value, cursor(streams_editing)) }, - Style::default().fg(if streams_editing { GREEN } else { TEXT_SECONDARY }) + Style::default().fg(if streams_editing { + GREEN + } else { + TEXT_SECONDARY + }), ), ])); } @@ -4676,8 +5662,18 @@ fn draw_input_dialog(f: &mut Frame, mode: &InputMode) { // Row 7: Access Tokens scope lines.push(Line::from(vec![ Span::styled(marker(*selected == 7), Style::default().fg(GREEN)), - Span::styled("Access Tokens ", Style::default().fg(if *selected == 7 { TEXT_PRIMARY } else { TEXT_MUTED })), - Span::styled(format!("< {} >", tokens_scope.as_str()), Style::default().fg(TEXT_SECONDARY)), + Span::styled( + "Access Tokens ", + Style::default().fg(if *selected == 7 { + TEXT_PRIMARY + } else { + TEXT_MUTED + }), + ), + Span::styled( + format!("< {} >", tokens_scope.as_str()), + Style::default().fg(TEXT_SECONDARY), + ), ])); // Row 8: Tokens value (only if Prefix/Exact) @@ -4685,61 +5681,152 @@ fn draw_input_dialog(f: &mut Frame, mode: &InputMode) { let tokens_editing = *editing && *selected == 8; lines.push(Line::from(vec![ Span::styled(marker(*selected == 8), Style::default().fg(GREEN)), - Span::styled(" Pattern ", Style::default().fg(if *selected == 8 { TEXT_PRIMARY } else { TEXT_MUTED })), + Span::styled( + " Pattern ", + Style::default().fg(if *selected == 8 { + TEXT_PRIMARY + } else { + TEXT_MUTED + }), + ), Span::styled( if tokens_value.is_empty() && !tokens_editing { "(enter pattern)".to_string() } else { format!("{}{}", tokens_value, cursor(tokens_editing)) }, - Style::default().fg(if tokens_editing { GREEN } else { TEXT_SECONDARY }) + Style::default().fg(if tokens_editing { + GREEN + } else { + TEXT_SECONDARY + }), ), ])); } // Operations section header lines.push(Line::from("")); - lines.push(Line::from(Span::styled("── Operations ──", Style::default().fg(TEXT_MUTED)))); + lines.push(Line::from(Span::styled( + "── Operations ──", + Style::default().fg(TEXT_MUTED), + ))); // Row 9-10: Account operations lines.push(Line::from(vec![ Span::styled(marker(*selected == 9), Style::default().fg(GREEN)), - Span::styled(format!("{} ", checkbox(*account_read)), Style::default().fg(if *account_read { GREEN } else { TEXT_MUTED })), - Span::styled("Account Read ", Style::default().fg(if *selected == 9 { TEXT_PRIMARY } else { TEXT_MUTED })), + Span::styled( + format!("{} ", checkbox(*account_read)), + Style::default().fg(if *account_read { GREEN } else { TEXT_MUTED }), + ), + Span::styled( + "Account Read ", + Style::default().fg(if *selected == 9 { + TEXT_PRIMARY + } else { + TEXT_MUTED + }), + ), Span::styled(marker(*selected == 10), Style::default().fg(GREEN)), - Span::styled(format!("{} ", checkbox(*account_write)), Style::default().fg(if *account_write { GREEN } else { TEXT_MUTED })), - Span::styled("Write", Style::default().fg(if *selected == 10 { TEXT_PRIMARY } else { TEXT_MUTED })), + Span::styled( + format!("{} ", checkbox(*account_write)), + Style::default().fg(if *account_write { GREEN } else { TEXT_MUTED }), + ), + Span::styled( + "Write", + Style::default().fg(if *selected == 10 { + TEXT_PRIMARY + } else { + TEXT_MUTED + }), + ), ])); // Row 11-12: Basin operations lines.push(Line::from(vec![ Span::styled(marker(*selected == 11), Style::default().fg(GREEN)), - Span::styled(format!("{} ", checkbox(*basin_read)), Style::default().fg(if *basin_read { GREEN } else { TEXT_MUTED })), - Span::styled("Basin Read ", Style::default().fg(if *selected == 11 { TEXT_PRIMARY } else { TEXT_MUTED })), + Span::styled( + format!("{} ", checkbox(*basin_read)), + Style::default().fg(if *basin_read { GREEN } else { TEXT_MUTED }), + ), + Span::styled( + "Basin Read ", + Style::default().fg(if *selected == 11 { + TEXT_PRIMARY + } else { + TEXT_MUTED + }), + ), Span::styled(marker(*selected == 12), Style::default().fg(GREEN)), - Span::styled(format!("{} ", checkbox(*basin_write)), Style::default().fg(if *basin_write { GREEN } else { TEXT_MUTED })), - Span::styled("Write", Style::default().fg(if *selected == 12 { TEXT_PRIMARY } else { TEXT_MUTED })), + Span::styled( + format!("{} ", checkbox(*basin_write)), + Style::default().fg(if *basin_write { GREEN } else { TEXT_MUTED }), + ), + Span::styled( + "Write", + Style::default().fg(if *selected == 12 { + TEXT_PRIMARY + } else { + TEXT_MUTED + }), + ), ])); // Row 13-14: Stream operations lines.push(Line::from(vec![ Span::styled(marker(*selected == 13), Style::default().fg(GREEN)), - Span::styled(format!("{} ", checkbox(*stream_read)), Style::default().fg(if *stream_read { GREEN } else { TEXT_MUTED })), - Span::styled("Stream Read ", Style::default().fg(if *selected == 13 { TEXT_PRIMARY } else { TEXT_MUTED })), + Span::styled( + format!("{} ", checkbox(*stream_read)), + Style::default().fg(if *stream_read { GREEN } else { TEXT_MUTED }), + ), + Span::styled( + "Stream Read ", + Style::default().fg(if *selected == 13 { + TEXT_PRIMARY + } else { + TEXT_MUTED + }), + ), Span::styled(marker(*selected == 14), Style::default().fg(GREEN)), - Span::styled(format!("{} ", checkbox(*stream_write)), Style::default().fg(if *stream_write { GREEN } else { TEXT_MUTED })), - Span::styled("Write", Style::default().fg(if *selected == 14 { TEXT_PRIMARY } else { TEXT_MUTED })), + Span::styled( + format!("{} ", checkbox(*stream_write)), + Style::default().fg(if *stream_write { GREEN } else { TEXT_MUTED }), + ), + Span::styled( + "Write", + Style::default().fg(if *selected == 14 { + TEXT_PRIMARY + } else { + TEXT_MUTED + }), + ), ])); // Options section lines.push(Line::from("")); - lines.push(Line::from(Span::styled("── Options ──", Style::default().fg(TEXT_MUTED)))); + lines.push(Line::from(Span::styled( + "── Options ──", + Style::default().fg(TEXT_MUTED), + ))); // Row 15: Auto-prefix streams lines.push(Line::from(vec![ Span::styled(marker(*selected == 15), Style::default().fg(GREEN)), - Span::styled(format!("{} ", checkbox(*auto_prefix_streams)), Style::default().fg(if *auto_prefix_streams { GREEN } else { TEXT_MUTED })), - Span::styled("Auto-prefix streams", Style::default().fg(if *selected == 15 { TEXT_PRIMARY } else { TEXT_MUTED })), + Span::styled( + format!("{} ", checkbox(*auto_prefix_streams)), + Style::default().fg(if *auto_prefix_streams { + GREEN + } else { + TEXT_MUTED + }), + ), + Span::styled( + "Auto-prefix streams", + Style::default().fg(if *selected == 15 { + TEXT_PRIMARY + } else { + TEXT_MUTED + }), + ), ])); lines.push(Line::from("")); @@ -4753,7 +5840,10 @@ fn draw_input_dialog(f: &mut Frame, mode: &InputMode) { }; lines.push(Line::from(vec![ Span::styled(marker(*selected == 16), Style::default().fg(GREEN)), - Span::styled(" ▶ ISSUE TOKEN ", Style::default().fg(btn_fg).bg(btn_bg).bold()), + Span::styled( + " ▶ ISSUE TOKEN ", + Style::default().fg(btn_fg).bg(btn_bg).bold(), + ), ])); ( @@ -4773,12 +5863,14 @@ fn draw_input_dialog(f: &mut Frame, mode: &InputMode) { Span::styled("?", Style::default().fg(TEXT_SECONDARY)), ]), Line::from(""), - Line::from(vec![ - Span::styled("The token will be immediately invalidated.", Style::default().fg(TEXT_MUTED)), - ]), - Line::from(vec![ - Span::styled("This action cannot be undone.", Style::default().fg(ERROR)), - ]), + Line::from(vec![Span::styled( + "The token will be immediately invalidated.", + Style::default().fg(TEXT_MUTED), + )]), + Line::from(vec![Span::styled( + "This action cannot be undone.", + Style::default().fg(ERROR), + )]), ], "y confirm n/esc cancel", ), @@ -4787,7 +5879,10 @@ fn draw_input_dialog(f: &mut Frame, mode: &InputMode) { " Access Token Issued ", vec![ Line::from(""), - Line::from(Span::styled("Copy this token now - it won't be shown again!", Style::default().fg(WARNING).bold())), + Line::from(Span::styled( + "Copy this token now - it won't be shown again!", + Style::default().fg(WARNING).bold(), + )), Line::from(""), Line::from(Span::styled(token, Style::default().fg(GREEN))), Line::from(""), @@ -4800,21 +5895,38 @@ fn draw_input_dialog(f: &mut Frame, mode: &InputMode) { Line::from(""), Line::from(vec![ Span::styled("Token ID: ", Style::default().fg(TEXT_MUTED)), - Span::styled(token.id.to_string(), Style::default().fg(TEXT_PRIMARY).bold()), + Span::styled( + token.id.to_string(), + Style::default().fg(TEXT_PRIMARY).bold(), + ), ]), Line::from(vec![ Span::styled("Expires At: ", Style::default().fg(TEXT_MUTED)), - Span::styled(token.expires_at.to_string(), Style::default().fg(TEXT_PRIMARY)), + Span::styled( + token.expires_at.to_string(), + Style::default().fg(TEXT_PRIMARY), + ), ]), Line::from(vec![ Span::styled("Auto-prefix: ", Style::default().fg(TEXT_MUTED)), Span::styled( - if token.auto_prefix_streams { "Yes" } else { "No" }, - Style::default().fg(if token.auto_prefix_streams { GREEN } else { TEXT_MUTED }), + if token.auto_prefix_streams { + "Yes" + } else { + "No" + }, + Style::default().fg(if token.auto_prefix_streams { + GREEN + } else { + TEXT_MUTED + }), ), ]), Line::from(""), - Line::from(Span::styled("─── Resource Scope ───", Style::default().fg(BORDER))), + Line::from(Span::styled( + "─── Resource Scope ───", + Style::default().fg(BORDER), + )), ]; // Basins scope @@ -4839,13 +5951,17 @@ fn draw_input_dialog(f: &mut Frame, mode: &InputMode) { ])); lines.push(Line::from("")); - lines.push(Line::from(Span::styled("─── Operations ───", Style::default().fg(BORDER)))); + lines.push(Line::from(Span::styled( + "─── Operations ───", + Style::default().fg(BORDER), + ))); // Group operations by category let ops = &token.scope.ops; // Account operations - let account_ops: Vec<_> = ops.iter() + let account_ops: Vec<_> = ops + .iter() .filter(|o| is_account_op(o)) .map(format_operation) .collect(); @@ -4857,7 +5973,8 @@ fn draw_input_dialog(f: &mut Frame, mode: &InputMode) { } // Basin operations - let basin_ops: Vec<_> = ops.iter() + let basin_ops: Vec<_> = ops + .iter() .filter(|o| is_basin_op(o)) .map(format_operation) .collect(); @@ -4869,7 +5986,8 @@ fn draw_input_dialog(f: &mut Frame, mode: &InputMode) { } // Stream operations - let stream_ops: Vec<_> = ops.iter() + let stream_ops: Vec<_> = ops + .iter() .filter(|o| is_stream_op(o)) .map(format_operation) .collect(); @@ -4881,7 +5999,8 @@ fn draw_input_dialog(f: &mut Frame, mode: &InputMode) { } // Token operations - let token_ops: Vec<_> = ops.iter() + let token_ops: Vec<_> = ops + .iter() .filter(|o| is_token_op(o)) .map(format_operation) .collect(); @@ -4894,18 +6013,17 @@ fn draw_input_dialog(f: &mut Frame, mode: &InputMode) { lines.push(Line::from("")); - ( - " Access Token Details ", - lines, - "esc/enter close", - ) - }, + (" Access Token Details ", lines, "esc/enter close") + } }; let area = centered_rect(60, 85, f.area()); let block = Block::default() - .title(Line::from(Span::styled(title, Style::default().fg(TEXT_PRIMARY).bold()))) + .title(Line::from(Span::styled( + title, + Style::default().fg(TEXT_PRIMARY).bold(), + ))) .borders(Borders::ALL) .border_style(Style::default().fg(GREEN)) .style(Style::default().bg(BG_DARK)) @@ -4914,10 +6032,7 @@ fn draw_input_dialog(f: &mut Frame, mode: &InputMode) { // Split area for content and hint let chunks = Layout::default() .direction(Direction::Vertical) - .constraints([ - Constraint::Min(1), - Constraint::Length(1), - ]) + .constraints([Constraint::Min(1), Constraint::Length(1)]) .split(area); f.render_widget(Clear, area); @@ -4947,11 +6062,20 @@ fn render_hint_with_keys(hint: &str) -> Vec> { if let Some(space_idx) = part.find(' ') { let key = &part[..space_idx]; let desc = &part[space_idx..]; - spans.push(Span::styled(key.to_string(), Style::default().fg(CYAN).bold())); - spans.push(Span::styled(desc.to_string(), Style::default().fg(TEXT_MUTED))); + spans.push(Span::styled( + key.to_string(), + Style::default().fg(CYAN).bold(), + )); + spans.push(Span::styled( + desc.to_string(), + Style::default().fg(TEXT_MUTED), + )); } else { // No space, treat whole thing as key - spans.push(Span::styled(part.to_string(), Style::default().fg(CYAN).bold())); + spans.push(Span::styled( + part.to_string(), + Style::default().fg(CYAN).bold(), + )); } } @@ -4999,31 +6123,35 @@ fn draw_bench_config(f: &mut Frame, area: Rect, state: &BenchViewState) { .split(area); // Title - let title = Paragraph::new(Line::from(vec![ - Span::styled("Configure Benchmark", Style::default().fg(TEXT_PRIMARY).bold()), - ])) + let title = Paragraph::new(Line::from(vec![Span::styled( + "Configure Benchmark", + Style::default().fg(TEXT_PRIMARY).bold(), + )])) .alignment(Alignment::Center); f.render_widget(title, chunks[0]); // Helper to draw a config field - let draw_field = |f: &mut Frame, area: Rect, label: &str, value: &str, selected: bool, editing: bool| { - let style = if selected { - Style::default().fg(if editing { YELLOW } else { GREEN }).bold() - } else { - Style::default().fg(TEXT_SECONDARY) - }; + let draw_field = + |f: &mut Frame, area: Rect, label: &str, value: &str, selected: bool, editing: bool| { + let style = if selected { + Style::default() + .fg(if editing { YELLOW } else { GREEN }) + .bold() + } else { + Style::default().fg(TEXT_SECONDARY) + }; - let prefix = if selected { "▸ " } else { " " }; - let suffix = if selected && !editing { " ◂" } else { "" }; + let prefix = if selected { "▸ " } else { " " }; + let suffix = if selected && !editing { " ◂" } else { "" }; - let line = Line::from(vec![ - Span::styled(prefix, style), - Span::styled(format!("{}: ", label), Style::default().fg(TEXT_MUTED)), - Span::styled(value, style), - Span::styled(suffix, style), - ]); - f.render_widget(Paragraph::new(line), area); - }; + let line = Line::from(vec![ + Span::styled(prefix, style), + Span::styled(format!("{}: ", label), Style::default().fg(TEXT_MUTED)), + Span::styled(value, style), + Span::styled(suffix, style), + ]); + f.render_widget(Paragraph::new(line), area); + }; let record_size_str = if state.editing && state.config_field == BenchConfigField::RecordSize { format!("{}_", state.edit_buffer) } else { @@ -5100,11 +6228,11 @@ fn draw_bench_running(f: &mut Frame, area: Rect, state: &BenchViewState) { let chunks = Layout::default() .direction(Direction::Vertical) .constraints([ - Constraint::Length(3), // Progress bar - Constraint::Length(5), // Write stats + Constraint::Length(3), // Progress bar + Constraint::Length(5), // Write stats Constraint::Length(5), - Constraint::Length(5), // Catchup stats (or waiting) - Constraint::Min(3), // Latency stats or chart + Constraint::Length(5), // Catchup stats (or waiting) + Constraint::Min(3), // Latency stats or chart ]) .margin(1) .split(area); @@ -5121,10 +6249,7 @@ fn draw_bench_running(f: &mut Frame, area: Rect, state: &BenchViewState) { let progress_block = Block::default() .title(Line::from(vec![ Span::styled(" Progress ", Style::default().fg(TEXT_PRIMARY).bold()), - Span::styled( - format!("• {} ", phase_text), - Style::default().fg(YELLOW), - ), + Span::styled(format!("• {} ", phase_text), Style::default().fg(YELLOW)), ])) .borders(Borders::ALL) .border_style(Style::default().fg(BORDER)); @@ -5138,12 +6263,19 @@ fn draw_bench_running(f: &mut Frame, area: Rect, state: &BenchViewState) { let empty = bar_width.saturating_sub(filled); let time_display = if state.phase == BenchPhase::Write { - format!(" {:.1}s / {}s", state.elapsed_secs.min(state.duration_secs as f64), state.duration_secs) + format!( + " {:.1}s / {}s", + state.elapsed_secs.min(state.duration_secs as f64), + state.duration_secs + ) } else { format!(" {:.1}s", state.elapsed_secs) }; let progress_line = Line::from(vec![ - Span::styled(format!("{:>5.1}% ", progress_pct), Style::default().fg(TEXT_PRIMARY)), + Span::styled( + format!("{:>5.1}% ", progress_pct), + Style::default().fg(TEXT_PRIMARY), + ), Span::styled("█".repeat(filled), Style::default().fg(GREEN)), Span::styled("░".repeat(empty), Style::default().fg(TEXT_MUTED)), Span::styled(time_display, Style::default().fg(TEXT_SECONDARY)), @@ -5177,7 +6309,10 @@ fn draw_bench_running(f: &mut Frame, area: Rect, state: &BenchViewState) { // Catchup stats or waiting message if matches!(state.phase, BenchPhase::CatchupWait) { let wait_block = Block::default() - .title(Line::from(Span::styled(" Catchup ", Style::default().fg(TEXT_PRIMARY).bold()))) + .title(Line::from(Span::styled( + " Catchup ", + Style::default().fg(TEXT_PRIMARY).bold(), + ))) .borders(Borders::ALL) .border_style(Style::default().fg(BORDER)); let wait_inner = wait_block.inner(chunks[3]); @@ -5212,7 +6347,10 @@ fn draw_bench_running(f: &mut Frame, area: Rect, state: &BenchViewState) { draw_latency_stats(f, chunks[4], &state.ack_latency, &state.e2e_latency); } else if let Some(error) = &state.error { let error_block = Block::default() - .title(Line::from(Span::styled(" Error ", Style::default().fg(RED).bold()))) + .title(Line::from(Span::styled( + " Error ", + Style::default().fg(RED).bold(), + ))) .borders(Borders::ALL) .border_style(Style::default().fg(RED)); let error_inner = error_block.inner(chunks[4]); @@ -5228,6 +6366,7 @@ fn draw_bench_running(f: &mut Frame, area: Rect, state: &BenchViewState) { } } +#[allow(clippy::too_many_arguments)] fn draw_bench_stat_box( f: &mut Frame, area: Rect, @@ -5265,9 +6404,15 @@ fn draw_bench_stat_box( Span::styled(" rec/s", Style::default().fg(TEXT_MUTED)), ]), Line::from(vec![ - Span::styled(format!("{:>8}", format_bytes(bytes)), Style::default().fg(TEXT_SECONDARY)), + Span::styled( + format!("{:>8}", format_bytes(bytes)), + Style::default().fg(TEXT_SECONDARY), + ), Span::styled(" total ", Style::default().fg(TEXT_MUTED)), - Span::styled(format!("{:>8}", format_number(records)), Style::default().fg(TEXT_SECONDARY)), + Span::styled( + format!("{:>8}", format_number(records)), + Style::default().fg(TEXT_SECONDARY), + ), Span::styled(" records", Style::default().fg(TEXT_MUTED)), ]), ]; @@ -5283,9 +6428,17 @@ fn draw_bench_stat_box( } } -fn draw_throughput_sparklines(f: &mut Frame, area: Rect, write_history: &[f64], read_history: &[f64]) { +fn draw_throughput_sparklines( + f: &mut Frame, + area: Rect, + write_history: &[f64], + read_history: &[f64], +) { let block = Block::default() - .title(Line::from(Span::styled(" Throughput ", Style::default().fg(TEXT_PRIMARY).bold()))) + .title(Line::from(Span::styled( + " Throughput ", + Style::default().fg(TEXT_PRIMARY).bold(), + ))) .borders(Borders::ALL) .border_style(Style::default().fg(BORDER)); @@ -5331,7 +6484,10 @@ fn draw_latency_stats( e2e_latency: &Option, ) { let block = Block::default() - .title(Line::from(Span::styled(" Latency Statistics ", Style::default().fg(TEXT_PRIMARY).bold()))) + .title(Line::from(Span::styled( + " Latency Statistics ", + Style::default().fg(TEXT_PRIMARY).bold(), + ))) .borders(Borders::ALL) .border_style(Style::default().fg(BORDER)); @@ -5362,7 +6518,10 @@ fn draw_latency_box( stats: &crate::types::LatencyStats, ) { let block = Block::default() - .title(Line::from(Span::styled(title, Style::default().fg(color).bold()))) + .title(Line::from(Span::styled( + title, + Style::default().fg(color).bold(), + ))) .borders(Borders::ALL) .border_style(Style::default().fg(BORDER)); @@ -5394,7 +6553,12 @@ fn draw_latency_box( f.render_widget(Paragraph::new(lines), inner); } -fn draw_tail_sparklines(f: &mut Frame, area: Rect, throughput_history: &[f64], records_history: &[f64]) { +fn draw_tail_sparklines( + f: &mut Frame, + area: Rect, + throughput_history: &[f64], + records_history: &[f64], +) { let chunks = Layout::default() .direction(Direction::Horizontal) .constraints([ @@ -5417,7 +6581,10 @@ fn draw_tail_sparklines(f: &mut Frame, area: Rect, throughput_history: &[f64], r let block = Block::default() .title(Line::from(vec![ Span::styled(" ▲ ", Style::default().fg(CYAN)), - Span::styled(format!("{:.2} MiB/s ", current), Style::default().fg(CYAN).bold()), + Span::styled( + format!("{:.2} MiB/s ", current), + Style::default().fg(CYAN).bold(), + ), ])) .borders(Borders::ALL) .border_style(Style::default().fg(BORDER)); @@ -5443,7 +6610,10 @@ fn draw_tail_sparklines(f: &mut Frame, area: Rect, throughput_history: &[f64], r let block = Block::default() .title(Line::from(vec![ Span::styled(" ◆ ", Style::default().fg(GREEN)), - Span::styled(format!("{:.0} rec/s ", current), Style::default().fg(GREEN).bold()), + Span::styled( + format!("{:.0} rec/s ", current), + Style::default().fg(GREEN).bold(), + ), ])) .borders(Borders::ALL) .border_style(Style::default().fg(BORDER)); @@ -5514,8 +6684,8 @@ fn draw_pip(f: &mut Frame, pip: &PipState) { let chunks = Layout::default() .direction(Direction::Vertical) .constraints([ - Constraint::Length(1), // Stats line - Constraint::Min(1), // Records + Constraint::Length(1), // Stats line + Constraint::Min(1), // Records ]) .split(inner); @@ -5535,7 +6705,9 @@ fn draw_pip(f: &mut Frame, pip: &PipState) { // Records list (show last N that fit) let visible_height = chunks[1].height as usize; - let records_to_show: Vec<_> = pip.records.iter() + let records_to_show: Vec<_> = pip + .records + .iter() .rev() .take(visible_height) .collect::>() @@ -5547,23 +6719,27 @@ fn draw_pip(f: &mut Frame, pip: &PipState) { let waiting = Paragraph::new(Span::styled( "Waiting for records...", Style::default().fg(TEXT_MUTED).italic(), - )).alignment(Alignment::Center); + )) + .alignment(Alignment::Center); f.render_widget(waiting, chunks[1]); } else { - let items: Vec = records_to_show.iter().map(|record| { - let seq = format!("#{:<6}", record.seq_num); - let body_preview: String = String::from_utf8_lossy(&record.body) - .chars() - .take(28) - .filter(|c| !c.is_control()) - .collect(); - - ListItem::new(Line::from(vec![ - Span::styled(seq, Style::default().fg(TEXT_MUTED)), - Span::styled(" ", Style::default()), - Span::styled(body_preview, Style::default().fg(TEXT_PRIMARY)), - ])) - }).collect(); + let items: Vec = records_to_show + .iter() + .map(|record| { + let seq = format!("#{:<6}", record.seq_num); + let body_preview: String = String::from_utf8_lossy(&record.body) + .chars() + .take(28) + .filter(|c| !c.is_control()) + .collect(); + + ListItem::new(Line::from(vec![ + Span::styled(seq, Style::default().fg(TEXT_MUTED)), + Span::styled(" ", Style::default()), + Span::styled(body_preview, Style::default().fg(TEXT_PRIMARY)), + ])) + }) + .collect(); let list = List::new(items); f.render_widget(list, chunks[1]); diff --git a/src/types.rs b/src/types.rs index 1a4b1df..6a5eec2 100644 --- a/src/types.rs +++ b/src/types.rs @@ -695,7 +695,9 @@ impl From for AccessTokenScope { } } -#[derive(Debug, Clone, PartialEq, Eq, Serialize, clap::ValueEnum, strum::Display, strum::EnumString)] +#[derive( + Debug, Clone, PartialEq, Eq, Serialize, clap::ValueEnum, strum::Display, strum::EnumString, +)] #[serde(rename_all = "snake_case")] #[clap(rename_all = "snake_case")] #[strum(serialize_all = "snake_case")] From 6595e10db0fc9f4fe660116a3cc28c40e12581bf Mon Sep 17 00:00:00 2001 From: Mehul Arora Date: Mon, 26 Jan 2026 14:28:37 -0500 Subject: [PATCH 23/31] .. --- src/tui/app.rs | 21 -- src/tui/ui.rs | 743 +++++++++++++++++-------------------------------- 2 files changed, 256 insertions(+), 508 deletions(-) diff --git a/src/tui/app.rs b/src/tui/app.rs index 1c8844f..7411932 100644 --- a/src/tui/app.rs +++ b/src/tui/app.rs @@ -828,18 +828,6 @@ impl ExpiryOption { } } - pub fn as_str(&self) -> &'static str { - match self { - ExpiryOption::Never => "Never (permanent)", - ExpiryOption::OneDay => "1 day", - ExpiryOption::SevenDays => "7 days", - ExpiryOption::ThirtyDays => "30 days", - ExpiryOption::NinetyDays => "90 days", - ExpiryOption::OneYear => "1 year", - ExpiryOption::Custom => "Custom", - } - } - pub fn duration_str(self) -> Option<&'static str> { match self { ExpiryOption::Never => None, @@ -881,15 +869,6 @@ impl ScopeOption { ScopeOption::None => ScopeOption::Exact, } } - - pub fn as_str(&self) -> &'static str { - match self { - ScopeOption::All => "All", - ScopeOption::Prefix => "Prefix", - ScopeOption::Exact => "Exact", - ScopeOption::None => "None", - } - } } /// Start position for read operation diff --git a/src/tui/ui.rs b/src/tui/ui.rs index 08271b3..a866007 100644 --- a/src/tui/ui.rs +++ b/src/tui/ui.rs @@ -11,8 +11,8 @@ use crate::types::{StorageClass, TimestampingMode}; use super::app::{ AccessTokensState, AgoUnit, App, AppendViewState, BasinsState, BenchViewState, CompressionOption, ExpiryOption, InputMode, MessageLevel, MetricCategory, MetricsType, - MetricsViewState, PipState, ReadStartFrom, ReadViewState, RetentionPolicyOption, ScopeOption, - Screen, SettingsState, SetupState, StreamDetailState, StreamsState, Tab, + MetricsViewState, PipState, ReadStartFrom, ReadViewState, RetentionPolicyOption, Screen, + SettingsState, SetupState, StreamDetailState, StreamsState, Tab, }; const GREEN: Color = Color::Rgb(34, 197, 94); // Active green @@ -236,18 +236,6 @@ fn render_text_input( } } -/// Render a checkbox -#[allow(dead_code)] -fn render_checkbox(checked: bool) -> &'static str { - if checked { "[x]" } else { "[ ]" } -} - -/// Render a radio button -#[allow(dead_code)] -fn render_radio(active: bool) -> &'static str { - if active { "●" } else { "○" } -} - /// Render a search/filter bar with consistent styling fn render_search_bar( filter: &str, @@ -5269,96 +5257,65 @@ fn draw_input_dialog(f: &mut Frame, mode: &InputMode) { selected, editing, } => { - let cursor = |is_editing: bool| if is_editing { "▎" } else { "" }; - let marker = |sel: bool| if sel { "▸ " } else { " " }; - let mut lines = vec![ Line::from(vec![ - Span::styled(" ", Style::default()), + Span::raw(" "), Span::styled( format!("s2://{}/{}", basin, stream), - Style::default().fg(GREEN), + Style::default().fg(GREEN).bold(), ), ]), Line::from(""), Line::from(Span::styled( - " Set a new fencing token to block other writers.", + " Set a new fencing token to block other writers.", Style::default().fg(TEXT_MUTED), )), Line::from(""), ]; // Row 0: New token - let new_editing = *editing && *selected == 0; - lines.push(Line::from(vec![ - Span::styled(marker(*selected == 0), Style::default().fg(GREEN)), - Span::styled( - "New Token ", - Style::default().fg(if *selected == 0 { - TEXT_PRIMARY - } else { - TEXT_MUTED - }), - ), - Span::styled( - if new_token.is_empty() && !new_editing { - "(required)".to_string() - } else { - format!("{}{}", new_token, cursor(new_editing)) - }, - Style::default().fg(if new_editing { - GREEN - } else if new_token.is_empty() { - WARNING - } else { - TEXT_SECONDARY - }), - ), - ])); + let (ind, lbl) = render_field_row_bold(0, "New Token", *selected); + let new_color = if new_token.is_empty() { WARNING } else { GREEN }; + let mut new_spans = vec![ind, lbl, Span::raw(" ")]; + new_spans.extend(render_text_input( + new_token, + *selected == 0 && *editing, + "(required)", + new_color, + )); + lines.push(Line::from(new_spans)); // Row 1: Current token - let cur_editing = *editing && *selected == 1; - lines.push(Line::from(vec![ - Span::styled(marker(*selected == 1), Style::default().fg(GREEN)), - Span::styled( - "Current Token ", - Style::default().fg(if *selected == 1 { - TEXT_PRIMARY - } else { - TEXT_MUTED - }), - ), - Span::styled( - if current_token.is_empty() && !cur_editing { - "(none)".to_string() - } else { - format!("{}{}", current_token, cursor(cur_editing)) - }, - Style::default().fg(if cur_editing { - GREEN - } else if current_token.is_empty() { - TEXT_MUTED - } else { - TEXT_SECONDARY - }), - ), - ])); + lines.push(Line::from("")); + let (ind, lbl) = render_field_row_bold(1, "Current", *selected); + let mut cur_spans = vec![ind, lbl, Span::raw(" ")]; + cur_spans.extend(render_text_input( + current_token, + *selected == 1 && *editing, + "(none)", + TEXT_SECONDARY, + )); + lines.push(Line::from(cur_spans)); + // Divider and button + lines.push(Line::from("")); + lines.push(Line::from(vec![Span::styled( + "─".repeat(44), + Style::default().fg(GRAY_750), + )])); lines.push(Line::from("")); // Row 2: Submit button let can_submit = !new_token.is_empty(); - let (btn_fg, btn_bg) = if *selected == 2 && can_submit { - (BG_DARK, GREEN) - } else { - (if can_submit { GREEN } else { TEXT_MUTED }, BG_PANEL) - }; - lines.push(Line::from(vec![ - Span::styled(marker(*selected == 2), Style::default().fg(GREEN)), - Span::styled(" ▶ FENCE ", Style::default().fg(btn_fg).bg(btn_bg).bold()), - ])); + lines.push(render_button("FENCE", *selected == 2, can_submit, GREEN)); - (" Fence Stream ", lines, "↑↓ nav ⏎ edit/submit esc") + lines.push(Line::from("")); + + ( + " Fence Stream ", + lines, + "j/k navigate · Enter edit · Esc cancel", + ) } InputMode::Trim { @@ -5369,100 +5326,73 @@ fn draw_input_dialog(f: &mut Frame, mode: &InputMode) { selected, editing, } => { - let cursor = |is_editing: bool| if is_editing { "▎" } else { "" }; - let marker = |sel: bool| if sel { "▸ " } else { " " }; - let mut lines = vec![ Line::from(vec![ - Span::styled(" ", Style::default()), + Span::raw(" "), Span::styled( format!("s2://{}/{}", basin, stream), - Style::default().fg(GREEN), + Style::default().fg(GREEN).bold(), ), ]), Line::from(""), Line::from(Span::styled( - " Delete all records before the trim point.", + " Delete all records before the trim point.", Style::default().fg(TEXT_MUTED), )), Line::from(Span::styled( - " This is eventually consistent.", + " This is eventually consistent.", Style::default().fg(TEXT_MUTED), )), Line::from(""), ]; // Row 0: Trim point - let trim_editing = *editing && *selected == 0; - lines.push(Line::from(vec![ - Span::styled(marker(*selected == 0), Style::default().fg(GREEN)), - Span::styled( - "Trim Point ", - Style::default().fg(if *selected == 0 { - TEXT_PRIMARY - } else { - TEXT_MUTED - }), - ), - Span::styled( - if trim_point.is_empty() && !trim_editing { - "(seq num)".to_string() - } else { - format!("{}{}", trim_point, cursor(trim_editing)) - }, - Style::default().fg(if trim_editing { - GREEN - } else if trim_point.is_empty() { - WARNING - } else { - TEXT_SECONDARY - }), - ), - ])); + let (ind, lbl) = render_field_row_bold(0, "Trim Point", *selected); + let trim_color = if trim_point.is_empty() { + WARNING + } else { + YELLOW + }; + let mut trim_spans = vec![ind, lbl, Span::raw(" ")]; + trim_spans.extend(render_text_input( + trim_point, + *selected == 0 && *editing, + "(seq num)", + trim_color, + )); + lines.push(Line::from(trim_spans)); // Row 1: Fencing token - let fence_editing = *editing && *selected == 1; - lines.push(Line::from(vec![ - Span::styled(marker(*selected == 1), Style::default().fg(GREEN)), - Span::styled( - "Fencing Token ", - Style::default().fg(if *selected == 1 { - TEXT_PRIMARY - } else { - TEXT_MUTED - }), - ), - Span::styled( - if fencing_token.is_empty() && !fence_editing { - "(none)".to_string() - } else { - format!("{}{}", fencing_token, cursor(fence_editing)) - }, - Style::default().fg(if fence_editing { - GREEN - } else if fencing_token.is_empty() { - TEXT_MUTED - } else { - TEXT_SECONDARY - }), - ), - ])); + lines.push(Line::from("")); + let (ind, lbl) = render_field_row_bold(1, "Fence Token", *selected); + let mut fence_spans = vec![ind, lbl, Span::raw(" ")]; + fence_spans.extend(render_text_input( + fencing_token, + *selected == 1 && *editing, + "(none)", + TEXT_SECONDARY, + )); + lines.push(Line::from(fence_spans)); + // Divider and button + lines.push(Line::from("")); + lines.push(Line::from(vec![Span::styled( + "─".repeat(44), + Style::default().fg(GRAY_750), + )])); lines.push(Line::from("")); // Row 2: Submit button let can_submit = !trim_point.is_empty() && trim_point.parse::().is_ok(); - let (btn_fg, btn_bg) = if *selected == 2 && can_submit { - (BG_DARK, WARNING) - } else { - (if can_submit { WARNING } else { TEXT_MUTED }, BG_PANEL) - }; - lines.push(Line::from(vec![ - Span::styled(marker(*selected == 2), Style::default().fg(GREEN)), - Span::styled(" ▶ TRIM ", Style::default().fg(btn_fg).bg(btn_bg).bold()), - ])); + lines.push(render_button("TRIM", *selected == 2, can_submit, WARNING)); + + lines.push(Line::from("")); - (" Trim Stream ", lines, "↑↓ nav ⏎ edit/submit esc") + ( + " Trim Stream ", + lines, + "j/k navigate · Enter edit · Esc cancel", + ) } InputMode::IssueAccessToken { @@ -5485,371 +5415,216 @@ fn draw_input_dialog(f: &mut Frame, mode: &InputMode) { selected, editing, } => { - let cursor = |is_editing: bool| if is_editing { "▎" } else { "" }; - let marker = |sel: bool| if sel { "▸ " } else { " " }; - let checkbox = |checked: bool| if checked { "[x]" } else { "[ ]" }; + use crate::tui::app::ScopeOption; + + let expiry_opts = [ + ("Never", *expiry == ExpiryOption::Never), + ("1d", *expiry == ExpiryOption::OneDay), + ("7d", *expiry == ExpiryOption::SevenDays), + ("30d", *expiry == ExpiryOption::ThirtyDays), + ("90d", *expiry == ExpiryOption::NinetyDays), + ("1y", *expiry == ExpiryOption::OneYear), + ("Custom", *expiry == ExpiryOption::Custom), + ]; + + let scope_opts = |scope: &ScopeOption| { + [ + ("All", *scope == ScopeOption::All), + ("Prefix", *scope == ScopeOption::Prefix), + ("Exact", *scope == ScopeOption::Exact), + ] + }; let mut lines = vec![]; + lines.push(Line::from("")); // Row 0: Token ID - let id_editing = *editing && *selected == 0; - lines.push(Line::from(vec![ - Span::styled(marker(*selected == 0), Style::default().fg(GREEN)), - Span::styled( - "Token ID ", - Style::default().fg(if *selected == 0 { - TEXT_PRIMARY - } else { - TEXT_MUTED - }), - ), - Span::styled( - if id.is_empty() && !id_editing { - "(required)".to_string() - } else { - format!("{}{}", id, cursor(id_editing)) - }, - Style::default().fg(if id_editing { - GREEN - } else if id.is_empty() { - WARNING - } else { - TEXT_SECONDARY - }), - ), - ])); + let (ind, lbl) = render_field_row_bold(0, "Token ID", *selected); + let id_color = if id.is_empty() { WARNING } else { GREEN }; + let mut id_spans = vec![ind, lbl, Span::raw(" ")]; + id_spans.extend(render_text_input( + id, + *selected == 0 && *editing, + "(required)", + id_color, + )); + lines.push(Line::from(id_spans)); - // Row 1: Expiration (cycle) - lines.push(Line::from(vec![ - Span::styled(marker(*selected == 1), Style::default().fg(GREEN)), - Span::styled( - "Expiration ", - Style::default().fg(if *selected == 1 { - TEXT_PRIMARY - } else { - TEXT_MUTED - }), - ), - Span::styled( - format!("< {} >", expiry.as_str()), - Style::default().fg(TEXT_SECONDARY), - ), - ])); + // Row 1: Expiration + lines.push(Line::from("")); + let (ind, lbl) = render_field_row_bold(1, "Expiration", *selected); + let mut expiry_spans = vec![ind, lbl, Span::raw(" ")]; + for (label, active) in &expiry_opts { + expiry_spans.push(render_pill(label, *selected == 1, *active)); + expiry_spans.push(Span::raw(" ")); + } + lines.push(Line::from(expiry_spans)); // Row 2: Custom expiration (only if Custom selected) if *expiry == ExpiryOption::Custom { - let custom_editing = *editing && *selected == 2; - lines.push(Line::from(vec![ - Span::styled(marker(*selected == 2), Style::default().fg(GREEN)), - Span::styled( - " Custom ", - Style::default().fg(if *selected == 2 { - TEXT_PRIMARY - } else { - TEXT_MUTED - }), - ), - Span::styled( - if expiry_custom.is_empty() && !custom_editing { - "(e.g., 30d, 1w)".to_string() - } else { - format!("{}{}", expiry_custom, cursor(custom_editing)) - }, - Style::default().fg(if custom_editing { - GREEN - } else { - TEXT_SECONDARY - }), - ), - ])); + let (ind, lbl) = render_field_row_bold(2, " Duration", *selected); + let mut custom_spans = vec![ind, lbl, Span::raw(" ")]; + custom_spans.extend(render_text_input( + expiry_custom, + *selected == 2 && *editing, + "e.g. 30d, 1w", + YELLOW, + )); + lines.push(Line::from(custom_spans)); } + + // Resources section + lines.push(Line::from("")); + lines.push(render_section_header("Resources", 48)); lines.push(Line::from("")); - lines.push(Line::from(Span::styled( - "── Resources ──", - Style::default().fg(TEXT_MUTED), - ))); // Row 3: Basins scope - lines.push(Line::from(vec![ - Span::styled(marker(*selected == 3), Style::default().fg(GREEN)), - Span::styled( - "Basins ", - Style::default().fg(if *selected == 3 { - TEXT_PRIMARY - } else { - TEXT_MUTED - }), - ), - Span::styled( - format!("< {} >", basins_scope.as_str()), - Style::default().fg(TEXT_SECONDARY), - ), - ])); + let (ind, lbl) = render_field_row_bold(3, "Basins", *selected); + let mut basins_spans = vec![ind, lbl, Span::raw(" ")]; + for (label, active) in scope_opts(basins_scope) { + basins_spans.push(render_pill(label, *selected == 3, active)); + basins_spans.push(Span::raw(" ")); + } + lines.push(Line::from(basins_spans)); // Row 4: Basins value (only if Prefix/Exact) if matches!(basins_scope, ScopeOption::Prefix | ScopeOption::Exact) { - let basins_editing = *editing && *selected == 4; - lines.push(Line::from(vec![ - Span::styled(marker(*selected == 4), Style::default().fg(GREEN)), - Span::styled( - " Pattern ", - Style::default().fg(if *selected == 4 { - TEXT_PRIMARY - } else { - TEXT_MUTED - }), - ), - Span::styled( - if basins_value.is_empty() && !basins_editing { - "(enter pattern)".to_string() - } else { - format!("{}{}", basins_value, cursor(basins_editing)) - }, - Style::default().fg(if basins_editing { - GREEN - } else { - TEXT_SECONDARY - }), - ), - ])); + let (ind, lbl) = render_field_row_bold(4, " Pattern", *selected); + let mut pattern_spans = vec![ind, lbl, Span::raw(" ")]; + pattern_spans.extend(render_text_input( + basins_value, + *selected == 4 && *editing, + "enter pattern", + TEXT_SECONDARY, + )); + lines.push(Line::from(pattern_spans)); } // Row 5: Streams scope - lines.push(Line::from(vec![ - Span::styled(marker(*selected == 5), Style::default().fg(GREEN)), - Span::styled( - "Streams ", - Style::default().fg(if *selected == 5 { - TEXT_PRIMARY - } else { - TEXT_MUTED - }), - ), - Span::styled( - format!("< {} >", streams_scope.as_str()), - Style::default().fg(TEXT_SECONDARY), - ), - ])); + lines.push(Line::from("")); + let (ind, lbl) = render_field_row_bold(5, "Streams", *selected); + let mut streams_spans = vec![ind, lbl, Span::raw(" ")]; + for (label, active) in scope_opts(streams_scope) { + streams_spans.push(render_pill(label, *selected == 5, active)); + streams_spans.push(Span::raw(" ")); + } + lines.push(Line::from(streams_spans)); // Row 6: Streams value (only if Prefix/Exact) if matches!(streams_scope, ScopeOption::Prefix | ScopeOption::Exact) { - let streams_editing = *editing && *selected == 6; - lines.push(Line::from(vec![ - Span::styled(marker(*selected == 6), Style::default().fg(GREEN)), - Span::styled( - " Pattern ", - Style::default().fg(if *selected == 6 { - TEXT_PRIMARY - } else { - TEXT_MUTED - }), - ), - Span::styled( - if streams_value.is_empty() && !streams_editing { - "(enter pattern)".to_string() - } else { - format!("{}{}", streams_value, cursor(streams_editing)) - }, - Style::default().fg(if streams_editing { - GREEN - } else { - TEXT_SECONDARY - }), - ), - ])); + let (ind, lbl) = render_field_row_bold(6, " Pattern", *selected); + let mut pattern_spans = vec![ind, lbl, Span::raw(" ")]; + pattern_spans.extend(render_text_input( + streams_value, + *selected == 6 && *editing, + "enter pattern", + TEXT_SECONDARY, + )); + lines.push(Line::from(pattern_spans)); } // Row 7: Access Tokens scope - lines.push(Line::from(vec![ - Span::styled(marker(*selected == 7), Style::default().fg(GREEN)), - Span::styled( - "Access Tokens ", - Style::default().fg(if *selected == 7 { - TEXT_PRIMARY - } else { - TEXT_MUTED - }), - ), - Span::styled( - format!("< {} >", tokens_scope.as_str()), - Style::default().fg(TEXT_SECONDARY), - ), - ])); + lines.push(Line::from("")); + let (ind, lbl) = render_field_row_bold(7, "Tokens", *selected); + let mut tokens_spans = vec![ind, lbl, Span::raw(" ")]; + for (label, active) in scope_opts(tokens_scope) { + tokens_spans.push(render_pill(label, *selected == 7, active)); + tokens_spans.push(Span::raw(" ")); + } + lines.push(Line::from(tokens_spans)); // Row 8: Tokens value (only if Prefix/Exact) if matches!(tokens_scope, ScopeOption::Prefix | ScopeOption::Exact) { - let tokens_editing = *editing && *selected == 8; - lines.push(Line::from(vec![ - Span::styled(marker(*selected == 8), Style::default().fg(GREEN)), - Span::styled( - " Pattern ", - Style::default().fg(if *selected == 8 { - TEXT_PRIMARY - } else { - TEXT_MUTED - }), - ), - Span::styled( - if tokens_value.is_empty() && !tokens_editing { - "(enter pattern)".to_string() - } else { - format!("{}{}", tokens_value, cursor(tokens_editing)) - }, - Style::default().fg(if tokens_editing { - GREEN - } else { - TEXT_SECONDARY - }), - ), - ])); + let (ind, lbl) = render_field_row_bold(8, " Pattern", *selected); + let mut pattern_spans = vec![ind, lbl, Span::raw(" ")]; + pattern_spans.extend(render_text_input( + tokens_value, + *selected == 8 && *editing, + "enter pattern", + TEXT_SECONDARY, + )); + lines.push(Line::from(pattern_spans)); } - // Operations section header + // Operations section + lines.push(Line::from("")); + lines.push(render_section_header("Operations", 48)); lines.push(Line::from("")); - lines.push(Line::from(Span::styled( - "── Operations ──", - Style::default().fg(TEXT_MUTED), - ))); - // Row 9-10: Account operations - lines.push(Line::from(vec![ - Span::styled(marker(*selected == 9), Style::default().fg(GREEN)), - Span::styled( - format!("{} ", checkbox(*account_read)), - Style::default().fg(if *account_read { GREEN } else { TEXT_MUTED }), - ), - Span::styled( - "Account Read ", - Style::default().fg(if *selected == 9 { - TEXT_PRIMARY - } else { - TEXT_MUTED - }), - ), - Span::styled(marker(*selected == 10), Style::default().fg(GREEN)), - Span::styled( - format!("{} ", checkbox(*account_write)), - Style::default().fg(if *account_write { GREEN } else { TEXT_MUTED }), - ), - Span::styled( - "Write", - Style::default().fg(if *selected == 10 { - TEXT_PRIMARY - } else { - TEXT_MUTED - }), - ), - ])); + // Row 9: Account Read + let (ind, lbl) = render_field_row_bold(9, "Account Read", *selected); + let mut acc_read_spans = vec![ind, lbl, Span::raw(" ")]; + acc_read_spans.extend(render_toggle(*account_read, *selected == 9)); + lines.push(Line::from(acc_read_spans)); - // Row 11-12: Basin operations - lines.push(Line::from(vec![ - Span::styled(marker(*selected == 11), Style::default().fg(GREEN)), - Span::styled( - format!("{} ", checkbox(*basin_read)), - Style::default().fg(if *basin_read { GREEN } else { TEXT_MUTED }), - ), - Span::styled( - "Basin Read ", - Style::default().fg(if *selected == 11 { - TEXT_PRIMARY - } else { - TEXT_MUTED - }), - ), - Span::styled(marker(*selected == 12), Style::default().fg(GREEN)), - Span::styled( - format!("{} ", checkbox(*basin_write)), - Style::default().fg(if *basin_write { GREEN } else { TEXT_MUTED }), - ), - Span::styled( - "Write", - Style::default().fg(if *selected == 12 { - TEXT_PRIMARY - } else { - TEXT_MUTED - }), - ), - ])); + // Row 10: Account Write + let (ind, lbl) = render_field_row_bold(10, "Account Write", *selected); + let mut acc_write_spans = vec![ind, lbl, Span::raw(" ")]; + acc_write_spans.extend(render_toggle(*account_write, *selected == 10)); + lines.push(Line::from(acc_write_spans)); - // Row 13-14: Stream operations - lines.push(Line::from(vec![ - Span::styled(marker(*selected == 13), Style::default().fg(GREEN)), - Span::styled( - format!("{} ", checkbox(*stream_read)), - Style::default().fg(if *stream_read { GREEN } else { TEXT_MUTED }), - ), - Span::styled( - "Stream Read ", - Style::default().fg(if *selected == 13 { - TEXT_PRIMARY - } else { - TEXT_MUTED - }), - ), - Span::styled(marker(*selected == 14), Style::default().fg(GREEN)), - Span::styled( - format!("{} ", checkbox(*stream_write)), - Style::default().fg(if *stream_write { GREEN } else { TEXT_MUTED }), - ), - Span::styled( - "Write", - Style::default().fg(if *selected == 14 { - TEXT_PRIMARY - } else { - TEXT_MUTED - }), - ), - ])); + lines.push(Line::from("")); + + // Row 11: Basin Read + let (ind, lbl) = render_field_row_bold(11, "Basin Read", *selected); + let mut basin_read_spans = vec![ind, lbl, Span::raw(" ")]; + basin_read_spans.extend(render_toggle(*basin_read, *selected == 11)); + lines.push(Line::from(basin_read_spans)); + + // Row 12: Basin Write + let (ind, lbl) = render_field_row_bold(12, "Basin Write", *selected); + let mut basin_write_spans = vec![ind, lbl, Span::raw(" ")]; + basin_write_spans.extend(render_toggle(*basin_write, *selected == 12)); + lines.push(Line::from(basin_write_spans)); + + lines.push(Line::from("")); + + // Row 13: Stream Read + let (ind, lbl) = render_field_row_bold(13, "Stream Read", *selected); + let mut stream_read_spans = vec![ind, lbl, Span::raw(" ")]; + stream_read_spans.extend(render_toggle(*stream_read, *selected == 13)); + lines.push(Line::from(stream_read_spans)); + + // Row 14: Stream Write + let (ind, lbl) = render_field_row_bold(14, "Stream Write", *selected); + let mut stream_write_spans = vec![ind, lbl, Span::raw(" ")]; + stream_write_spans.extend(render_toggle(*stream_write, *selected == 14)); + lines.push(Line::from(stream_write_spans)); // Options section lines.push(Line::from("")); - lines.push(Line::from(Span::styled( - "── Options ──", - Style::default().fg(TEXT_MUTED), - ))); + lines.push(render_section_header("Options", 48)); + lines.push(Line::from("")); // Row 15: Auto-prefix streams - lines.push(Line::from(vec![ - Span::styled(marker(*selected == 15), Style::default().fg(GREEN)), - Span::styled( - format!("{} ", checkbox(*auto_prefix_streams)), - Style::default().fg(if *auto_prefix_streams { - GREEN - } else { - TEXT_MUTED - }), - ), - Span::styled( - "Auto-prefix streams", - Style::default().fg(if *selected == 15 { - TEXT_PRIMARY - } else { - TEXT_MUTED - }), - ), - ])); + let (ind, lbl) = render_field_row_bold(15, "Auto-prefix", *selected); + let mut prefix_spans = vec![ind, lbl, Span::raw(" ")]; + prefix_spans.extend(render_toggle(*auto_prefix_streams, *selected == 15)); + lines.push(Line::from(prefix_spans)); + // Divider and button + lines.push(Line::from("")); + lines.push(Line::from(vec![Span::styled( + "─".repeat(52), + Style::default().fg(GRAY_750), + )])); lines.push(Line::from("")); // Row 16: Submit button let can_submit = !id.is_empty(); - let (btn_fg, btn_bg) = if *selected == 16 && can_submit { - (BG_DARK, SUCCESS) - } else { - (if can_submit { SUCCESS } else { TEXT_MUTED }, BG_PANEL) - }; - lines.push(Line::from(vec![ - Span::styled(marker(*selected == 16), Style::default().fg(GREEN)), - Span::styled( - " ▶ ISSUE TOKEN ", - Style::default().fg(btn_fg).bg(btn_bg).bold(), - ), - ])); + lines.push(render_button( + "ISSUE TOKEN", + *selected == 16, + can_submit, + SUCCESS, + )); + + lines.push(Line::from("")); ( " Issue Access Token ", lines, - "↑↓ nav ←→ cycle space toggle ⏎ edit/submit esc", + "j/k navigate · h/l cycle · Space toggle · Enter edit · Esc cancel", ) } @@ -5894,21 +5669,21 @@ fn draw_input_dialog(f: &mut Frame, mode: &InputMode) { let mut lines = vec![ Line::from(""), Line::from(vec![ - Span::styled("Token ID: ", Style::default().fg(TEXT_MUTED)), + Span::styled(" Token ID: ", Style::default().fg(TEXT_MUTED)), Span::styled( token.id.to_string(), Style::default().fg(TEXT_PRIMARY).bold(), ), ]), Line::from(vec![ - Span::styled("Expires At: ", Style::default().fg(TEXT_MUTED)), + Span::styled(" Expires At: ", Style::default().fg(TEXT_MUTED)), Span::styled( token.expires_at.to_string(), Style::default().fg(TEXT_PRIMARY), ), ]), Line::from(vec![ - Span::styled("Auto-prefix: ", Style::default().fg(TEXT_MUTED)), + Span::styled(" Auto-prefix: ", Style::default().fg(TEXT_MUTED)), Span::styled( if token.auto_prefix_streams { "Yes" @@ -5923,38 +5698,32 @@ fn draw_input_dialog(f: &mut Frame, mode: &InputMode) { ), ]), Line::from(""), - Line::from(Span::styled( - "─── Resource Scope ───", - Style::default().fg(BORDER), - )), + render_section_header("Resource Scope", 44), ]; // Basins scope let basins_str = format_basin_matcher(&token.scope.basins); lines.push(Line::from(vec![ - Span::styled("Basins: ", Style::default().fg(TEXT_MUTED)), + Span::styled(" Basins: ", Style::default().fg(TEXT_MUTED)), Span::styled(basins_str, Style::default().fg(TEXT_PRIMARY)), ])); // Streams scope let streams_str = format_stream_matcher(&token.scope.streams); lines.push(Line::from(vec![ - Span::styled("Streams: ", Style::default().fg(TEXT_MUTED)), + Span::styled(" Streams: ", Style::default().fg(TEXT_MUTED)), Span::styled(streams_str, Style::default().fg(TEXT_PRIMARY)), ])); // Access tokens scope let tokens_str = format_token_matcher(&token.scope.access_tokens); lines.push(Line::from(vec![ - Span::styled("Tokens: ", Style::default().fg(TEXT_MUTED)), + Span::styled(" Tokens: ", Style::default().fg(TEXT_MUTED)), Span::styled(tokens_str, Style::default().fg(TEXT_PRIMARY)), ])); lines.push(Line::from("")); - lines.push(Line::from(Span::styled( - "─── Operations ───", - Style::default().fg(BORDER), - ))); + lines.push(render_section_header("Operations", 44)); // Group operations by category let ops = &token.scope.ops; @@ -5967,7 +5736,7 @@ fn draw_input_dialog(f: &mut Frame, mode: &InputMode) { .collect(); if !account_ops.is_empty() { lines.push(Line::from(vec![ - Span::styled("Account: ", Style::default().fg(TEXT_MUTED)), + Span::styled(" Account: ", Style::default().fg(TEXT_MUTED)), Span::styled(account_ops.join(", "), Style::default().fg(TEXT_PRIMARY)), ])); } @@ -5980,7 +5749,7 @@ fn draw_input_dialog(f: &mut Frame, mode: &InputMode) { .collect(); if !basin_ops.is_empty() { lines.push(Line::from(vec![ - Span::styled("Basin: ", Style::default().fg(TEXT_MUTED)), + Span::styled(" Basin: ", Style::default().fg(TEXT_MUTED)), Span::styled(basin_ops.join(", "), Style::default().fg(TEXT_PRIMARY)), ])); } @@ -5993,7 +5762,7 @@ fn draw_input_dialog(f: &mut Frame, mode: &InputMode) { .collect(); if !stream_ops.is_empty() { lines.push(Line::from(vec![ - Span::styled("Stream: ", Style::default().fg(TEXT_MUTED)), + Span::styled(" Stream: ", Style::default().fg(TEXT_MUTED)), Span::styled(stream_ops.join(", "), Style::default().fg(TEXT_PRIMARY)), ])); } @@ -6006,7 +5775,7 @@ fn draw_input_dialog(f: &mut Frame, mode: &InputMode) { .collect(); if !token_ops.is_empty() { lines.push(Line::from(vec![ - Span::styled("Tokens: ", Style::default().fg(TEXT_MUTED)), + Span::styled(" Tokens: ", Style::default().fg(TEXT_MUTED)), Span::styled(token_ops.join(", "), Style::default().fg(TEXT_PRIMARY)), ])); } From 3709beb24330b7dc3effd139df7f06c50d2371d9 Mon Sep 17 00:00:00 2001 From: Mehul Arora Date: Mon, 26 Jan 2026 16:38:20 -0500 Subject: [PATCH 24/31] . --- src/tui/ui.rs | 246 +++++++++++++++++++++++++++++--------------------- 1 file changed, 142 insertions(+), 104 deletions(-) diff --git a/src/tui/ui.rs b/src/tui/ui.rs index a866007..207757e 100644 --- a/src/tui/ui.rs +++ b/src/tui/ui.rs @@ -15,17 +15,30 @@ use super::app::{ SettingsState, SetupState, StreamDetailState, StreamsState, Tab, }; -const GREEN: Color = Color::Rgb(34, 197, 94); // Active green -const YELLOW: Color = Color::Rgb(250, 204, 21); // Warning yellow -const RED: Color = Color::Rgb(239, 68, 68); // Error red -const CYAN: Color = Color::Rgb(34, 211, 238); // Cyan accent -const BLUE: Color = Color::Rgb(59, 130, 246); // Blue accent -const WHITE: Color = Color::Rgb(255, 255, 255); // Pure white -const GRAY_100: Color = Color::Rgb(243, 244, 246); // Near white -const GRAY_500: Color = Color::Rgb(107, 114, 128); // Muted gray -const BG_DARK: Color = Color::Rgb(17, 17, 17); // Main background -const BG_PANEL: Color = Color::Rgb(24, 24, 27); // Panel background -const BORDER: Color = Color::Rgb(63, 63, 70); // Border gray +const GREEN: Color = Color::Rgb(34, 197, 94); +const YELLOW: Color = Color::Rgb(250, 204, 21); +const RED: Color = Color::Rgb(239, 68, 68); +const CYAN: Color = Color::Rgb(34, 211, 238); +const BLUE: Color = Color::Rgb(59, 130, 246); +const PURPLE: Color = Color::Rgb(167, 139, 250); +const ORANGE: Color = Color::Rgb(251, 146, 60); +const WHITE: Color = Color::Rgb(255, 255, 255); + +const GRAY_100: Color = Color::Rgb(243, 244, 246); +const GRAY_200: Color = Color::Rgb(180, 180, 180); +const GRAY_300: Color = Color::Rgb(150, 150, 150); +const GRAY_400: Color = Color::Rgb(120, 120, 120); +const GRAY_500: Color = Color::Rgb(107, 114, 128); +const GRAY_600: Color = Color::Rgb(100, 100, 100); +const GRAY_700: Color = Color::Rgb(80, 80, 80); +const GRAY_750: Color = Color::Rgb(63, 63, 70); +const GRAY_800: Color = Color::Rgb(60, 60, 60); +const GRAY_850: Color = Color::Rgb(50, 50, 50); +const GRAY_900: Color = Color::Rgb(40, 40, 40); + +const BG_DARK: Color = Color::Rgb(17, 17, 17); +const BG_PANEL: Color = Color::Rgb(24, 24, 27); +const BG_SELECTED: Color = Color::Rgb(39, 39, 42); const ACCENT: Color = WHITE; const SUCCESS: Color = GREEN; @@ -34,18 +47,43 @@ const ERROR: Color = RED; const TEXT_PRIMARY: Color = WHITE; const TEXT_SECONDARY: Color = GRAY_100; const TEXT_MUTED: Color = GRAY_500; +const BORDER: Color = GRAY_750; +const BORDER_DIM: Color = GRAY_850; +const BORDER_TITLE: Color = GRAY_900; + +const BADGE_ACTIVE: Color = Color::Rgb(22, 101, 52); +const BADGE_PENDING: Color = Color::Rgb(113, 63, 18); +const BADGE_DANGER: Color = Color::Rgb(127, 29, 29); + +const STAT_MIN: Color = Color::Rgb(96, 165, 250); +const STAT_MAX: Color = Color::Rgb(251, 191, 36); +const STAT_AVG: Color = PURPLE; + +const GREEN_BRIGHT: Color = Color::Rgb(34, 197, 94); +const GREEN_LIGHT: Color = Color::Rgb(74, 222, 128); +const GREEN_LIGHTER: Color = Color::Rgb(134, 239, 172); +const GREEN_PALE: Color = Color::Rgb(187, 247, 208); +const GREEN_PALEST: Color = Color::Rgb(220, 252, 231); + +const CHART_PURPLE: Color = Color::Rgb(139, 92, 246); +const CHART_VIOLET: Color = Color::Rgb(124, 58, 237); +const CHART_INDIGO: Color = Color::Rgb(99, 102, 241); +const CHART_DEEP_INDIGO: Color = Color::Rgb(79, 70, 229); +const CHART_BLUE: Color = Color::Rgb(59, 130, 246); +const CHART_ROYAL_BLUE: Color = Color::Rgb(37, 99, 235); +const CHART_LIGHT_BLUE: Color = Color::Rgb(96, 165, 250); +const CHART_PALE_BLUE: Color = Color::Rgb(147, 197, 253); +const CHART_YELLOW: Color = Color::Rgb(250, 204, 21); +const CHART_ORANGE: Color = Color::Rgb(251, 146, 60); + +const STORAGE_EXPRESS: Color = ORANGE; +const STORAGE_STANDARD: Color = Color::Rgb(147, 197, 253); + +const TIME_RECENT: Color = GREEN_LIGHT; +const TIME_MODERATE: Color = YELLOW; +const TIME_OLD: Color = GRAY_200; -// Additional gray shades for consistent styling -const GRAY_600: Color = Color::Rgb(80, 80, 80); -const GRAY_650: Color = Color::Rgb(60, 60, 60); -const GRAY_700: Color = Color::Rgb(100, 100, 100); -const GRAY_750: Color = Color::Rgb(50, 50, 50); -const GRAY_800: Color = Color::Rgb(40, 40, 40); - -// Consistent cursor character const CURSOR: &str = "▎"; - -// Consistent selection indicator const SELECTED_INDICATOR: &str = " ▸ "; const UNSELECTED_INDICATOR: &str = " "; @@ -102,7 +140,7 @@ fn render_toggle(on: bool, is_selected: bool) -> Vec> { vec![ Span::styled( "", - Style::default().fg(if is_selected { GREEN } else { GRAY_650 }), + Style::default().fg(if is_selected { GREEN } else { GRAY_800 }), ), Span::styled(" ON ", Style::default().fg(BG_DARK).bg(GREEN).bold()), Span::styled("", Style::default().fg(GREEN)), @@ -111,10 +149,10 @@ fn render_toggle(on: bool, is_selected: bool) -> Vec> { vec![ Span::styled( "", - Style::default().fg(if is_selected { TEXT_MUTED } else { GRAY_650 }), + Style::default().fg(if is_selected { TEXT_MUTED } else { GRAY_800 }), ), - Span::styled(" OFF ", Style::default().fg(TEXT_MUTED).bg(GRAY_650)), - Span::styled("", Style::default().fg(GRAY_650)), + Span::styled(" OFF ", Style::default().fg(TEXT_MUTED).bg(GRAY_800)), + Span::styled("", Style::default().fg(GRAY_800)), ] } } @@ -251,7 +289,7 @@ fn render_search_bar( Line::from(vec![ Span::styled(" [/] ", Style::default().fg(GREEN)), Span::styled(filter.to_string(), Style::default().fg(TEXT_PRIMARY)), - Span::styled("_", Style::default().fg(GREEN)), + Span::styled(CURSOR, Style::default().fg(GREEN)), ]) } else if filter.is_empty() { Line::from(vec![Span::styled( @@ -395,7 +433,7 @@ fn draw_setup(f: &mut Frame, area: Rect, state: &SetupState) { vec![ Span::styled("Token ", Style::default().fg(TEXT_MUTED)), Span::styled("› ", Style::default().fg(BORDER)), - Span::styled("_", Style::default().fg(CYAN)), + Span::styled(CURSOR, Style::default().fg(CYAN)), ] } else { let display = truncate_str(&state.access_token, 40, "..."); @@ -403,7 +441,7 @@ fn draw_setup(f: &mut Frame, area: Rect, state: &SetupState) { Span::styled("Token ", Style::default().fg(TEXT_MUTED)), Span::styled("› ", Style::default().fg(GREEN)), Span::styled(display, Style::default().fg(WHITE)), - Span::styled("_", Style::default().fg(CYAN)), + Span::styled(CURSOR, Style::default().fg(CYAN)), ] }; lines.push(Line::from(token_display)); @@ -1301,17 +1339,17 @@ fn draw_metrics_view(f: &mut Frame, area: Rect, state: &MetricsViewState) { Span::styled("min ", Style::default().fg(TEXT_MUTED)), Span::styled( format_metric_value_f64(min_val, metric_unit), - Style::default().fg(Color::Rgb(96, 165, 250)), + Style::default().fg(STAT_MIN), ), Span::styled(" max ", Style::default().fg(TEXT_MUTED)), Span::styled( format_metric_value_f64(max_val, metric_unit), - Style::default().fg(Color::Rgb(251, 191, 36)), + Style::default().fg(STAT_MAX), ), Span::styled(" avg ", Style::default().fg(TEXT_MUTED)), Span::styled( format_metric_value_f64(avg_val, metric_unit), - Style::default().fg(Color::Rgb(167, 139, 250)), + Style::default().fg(STAT_AVG), ), Span::styled( format!(" | {} pts", all_values.len()), @@ -1414,15 +1452,15 @@ fn draw_metrics_view(f: &mut Frame, area: Rect, state: &MetricsViewState) { /// Convert intensity (0.0-1.0) to a green gradient color fn intensity_to_color(intensity: f64) -> Color { if intensity > 0.8 { - Color::Rgb(34, 197, 94) // bright green + GREEN_BRIGHT } else if intensity > 0.6 { - Color::Rgb(74, 222, 128) + GREEN_LIGHT } else if intensity > 0.4 { - Color::Rgb(134, 239, 172) + GREEN_LIGHTER } else if intensity > 0.2 { - Color::Rgb(187, 247, 208) + GREEN_PALE } else { - Color::Rgb(220, 252, 231) // pale green + GREEN_PALEST } } @@ -1435,16 +1473,16 @@ fn render_multi_metric( ) { use std::collections::BTreeMap; let colors = [ - Color::Rgb(139, 92, 246), // Purple (primary) - Color::Rgb(124, 58, 237), // Violet - Color::Rgb(99, 102, 241), // Indigo - Color::Rgb(79, 70, 229), // Deep indigo - Color::Rgb(59, 130, 246), // Blue - Color::Rgb(37, 99, 235), // Royal blue - Color::Rgb(96, 165, 250), // Light blue - Color::Rgb(147, 197, 253), // Pale blue - Color::Rgb(250, 204, 21), // Yellow (for highlights) - Color::Rgb(251, 146, 60), // Orange + CHART_PURPLE, + CHART_VIOLET, + CHART_INDIGO, + CHART_DEEP_INDIGO, + CHART_BLUE, + CHART_ROYAL_BLUE, + CHART_LIGHT_BLUE, + CHART_PALE_BLUE, + CHART_YELLOW, + CHART_ORANGE, ]; let mut metric_totals: Vec<(String, f64, usize)> = metrics .iter() @@ -1517,17 +1555,17 @@ fn render_multi_metric( Span::styled("min ", Style::default().fg(TEXT_MUTED)), Span::styled( format_count(min_val as u64), - Style::default().fg(Color::Rgb(96, 165, 250)), + Style::default().fg(STAT_MIN), ), Span::styled(" max ", Style::default().fg(TEXT_MUTED)), Span::styled( format_count(max_val as u64), - Style::default().fg(Color::Rgb(251, 191, 36)), + Style::default().fg(STAT_MAX), ), Span::styled(" avg ", Style::default().fg(TEXT_MUTED)), Span::styled( format_count(avg_val as u64), - Style::default().fg(Color::Rgb(167, 139, 250)), + Style::default().fg(STAT_AVG), ), Span::styled( format!(" | {} pts", all_values.len()), @@ -2307,7 +2345,7 @@ fn draw_basins(f: &mut Frame, area: Rect, state: &BasinsState) { if is_selected { f.render_widget( - Block::default().style(Style::default().bg(Color::Rgb(39, 39, 42))), + Block::default().style(Style::default().bg(BG_SELECTED)), row_area, ); } @@ -2317,9 +2355,9 @@ fn draw_basins(f: &mut Frame, area: Rect, state: &BasinsState) { let display_name = truncate_str(&name, max_name_len, "…"); let (state_text, state_bg) = match basin.state { - s2_sdk::types::BasinState::Active => ("Active", Color::Rgb(22, 101, 52)), - s2_sdk::types::BasinState::Creating => ("Creating", Color::Rgb(113, 63, 18)), - s2_sdk::types::BasinState::Deleting => ("Deleting", Color::Rgb(127, 29, 29)), + s2_sdk::types::BasinState::Active => ("Active", BADGE_ACTIVE), + s2_sdk::types::BasinState::Creating => ("Creating", BADGE_PENDING), + s2_sdk::types::BasinState::Deleting => ("Deleting", BADGE_DANGER), }; let scope = basin @@ -2498,7 +2536,7 @@ fn draw_streams(f: &mut Frame, area: Rect, state: &StreamsState) { if is_selected { f.render_widget( - Block::default().style(Style::default().bg(Color::Rgb(39, 39, 42))), + Block::default().style(Style::default().bg(BG_SELECTED)), row_area, ); } @@ -2543,16 +2581,16 @@ fn draw_stream_detail(f: &mut Frame, area: Rect, state: &StreamDetailState) { let header_lines = vec![ Line::from(""), Line::from(vec![ - Span::styled(" ← ", Style::default().fg(GRAY_700)), - Span::styled(&basin_str, Style::default().fg(Color::Rgb(150, 150, 150))), - Span::styled(" / ", Style::default().fg(Color::Rgb(80, 80, 80))), + Span::styled(" ← ", Style::default().fg(GRAY_600)), + Span::styled(&basin_str, Style::default().fg(GRAY_300)), + Span::styled(" / ", Style::default().fg(GRAY_700)), Span::styled(&stream_str, Style::default().fg(GREEN).bold()), ]), ]; let header = Paragraph::new(header_lines).block( Block::default() .borders(Borders::BOTTOM) - .border_style(Style::default().fg(GRAY_800)), + .border_style(Style::default().fg(BORDER_TITLE)), ); f.render_widget(header, chunks[0]); @@ -2588,7 +2626,7 @@ fn draw_stream_detail(f: &mut Frame, area: Rect, state: &StreamDetailState) { Span::styled(icon, Style::default().fg(value_color)), Span::styled( format!(" {}", label), - Style::default().fg(Color::Rgb(120, 120, 120)), + Style::default().fg(GRAY_400), ), ]), Line::from(vec![ @@ -2599,7 +2637,7 @@ fn draw_stream_detail(f: &mut Frame, area: Rect, state: &StreamDetailState) { let widget = Paragraph::new(lines).block( Block::default() .borders(Borders::ALL) - .border_style(Style::default().fg(GRAY_750)) + .border_style(Style::default().fg(BORDER_DIM)) .border_type(ratatui::widgets::BorderType::Rounded), ); f.render_widget(widget, area); @@ -2608,14 +2646,14 @@ fn draw_stream_detail(f: &mut Frame, area: Rect, state: &StreamDetailState) { // Tail Position let (tail_val, tail_color) = if let Some(pos) = &state.tail_position { if pos.seq_num > 0 { - (format!("{}", pos.seq_num), Color::Rgb(34, 211, 238)) + (format!("{}", pos.seq_num), CYAN) } else { - ("0".to_string(), GRAY_700) + ("0".to_string(), GRAY_600) } } else if state.loading { - ("...".to_string(), GRAY_700) + ("...".to_string(), GRAY_600) } else { - ("--".to_string(), GRAY_700) + ("--".to_string(), GRAY_600) }; render_stat_card_v2(f, stats_chunks[0], "▌", "Records", &tail_val, tail_color); @@ -2637,18 +2675,18 @@ fn draw_stream_detail(f: &mut Frame, area: Rect, state: &StreamDetailState) { format!("{}d ago", age_secs / 86400) }; let color = if age_secs < 60 { - Color::Rgb(74, 222, 128) + TIME_RECENT } else if age_secs < 3600 { - Color::Rgb(250, 204, 21) + TIME_MODERATE } else { - Color::Rgb(180, 180, 180) + TIME_OLD }; (val, color) } else { - ("never".to_string(), GRAY_700) + ("never".to_string(), GRAY_600) } } else { - ("--".to_string(), GRAY_700) + ("--".to_string(), GRAY_600) }; render_stat_card_v2(f, stats_chunks[1], "◷", "Last Write", &ts_val, ts_color); @@ -2660,13 +2698,13 @@ fn draw_stream_detail(f: &mut Frame, area: Rect, state: &StreamDetailState) { .map(|s| format!("{:?}", s).to_lowercase()) .unwrap_or_else(|| "default".to_string()); let color = match val.as_str() { - "express" => Color::Rgb(251, 146, 60), - "standard" => Color::Rgb(147, 197, 253), - _ => Color::Rgb(180, 180, 180), + "express" => STORAGE_EXPRESS, + "standard" => STORAGE_STANDARD, + _ => GRAY_200, }; (val, color) } else { - ("--".to_string(), GRAY_700) + ("--".to_string(), GRAY_600) }; render_stat_card_v2( f, @@ -2697,13 +2735,13 @@ fn draw_stream_detail(f: &mut Frame, area: Rect, state: &StreamDetailState) { }) .unwrap_or_else(|| "∞".to_string()); let color = if val == "∞" { - Color::Rgb(167, 139, 250) + PURPLE } else { - Color::Rgb(180, 180, 180) + GRAY_200 }; (val, color) } else { - ("--".to_string(), GRAY_700) + ("--".to_string(), GRAY_600) }; render_stat_card_v2( f, @@ -2754,9 +2792,9 @@ fn draw_stream_detail(f: &mut Frame, area: Rect, state: &StreamDetailState) { Line::from(vec![ Span::styled( format!(" {} ", title), - Style::default().fg(Color::Rgb(34, 211, 238)).bold(), + Style::default().fg(CYAN).bold(), ), - Span::styled("─".repeat(line_width), Style::default().fg(GRAY_800)), + Span::styled("─".repeat(line_width), Style::default().fg(BORDER_TITLE)), ]), Line::from(""), ]; @@ -2780,21 +2818,21 @@ fn draw_stream_detail(f: &mut Frame, area: Rect, state: &StreamDetailState) { Span::styled(" ", Style::default()), Span::styled( *desc, - Style::default().fg(Color::Rgb(180, 180, 180)).italic(), + Style::default().fg(GRAY_200).italic(), ), ])); } else { // Unselected action - dimmed lines.push(Line::from(vec![ Span::styled(" ", Style::default()), - Span::styled(*icon, Style::default().fg(Color::Rgb(80, 80, 80))), + Span::styled(*icon, Style::default().fg(GRAY_700)), Span::styled( format!(" {} ", name), - Style::default().fg(Color::Rgb(140, 140, 140)), + Style::default().fg(GRAY_400), ), Span::styled( format!("[{}]", key), - Style::default().fg(Color::Rgb(80, 80, 80)), + Style::default().fg(GRAY_700), ), ])); } @@ -2860,10 +2898,10 @@ fn draw_read_view(f: &mut Frame, area: Rect, state: &ReadViewState) { let record_count = format!(" {} records", state.records.len()); let mut header_spans = vec![ - Span::styled(" ← ", Style::default().fg(GRAY_700)), - Span::styled(&basin_str, Style::default().fg(Color::Rgb(150, 150, 150))), - Span::styled(" / ", Style::default().fg(Color::Rgb(80, 80, 80))), - Span::styled(&stream_str, Style::default().fg(Color::Rgb(180, 180, 180))), + Span::styled(" ← ", Style::default().fg(GRAY_600)), + Span::styled(&basin_str, Style::default().fg(GRAY_300)), + Span::styled(" / ", Style::default().fg(GRAY_700)), + Span::styled(&stream_str, Style::default().fg(GRAY_200)), Span::styled(" ", Style::default()), Span::styled( format!(" {} ", mode_text), @@ -2975,7 +3013,7 @@ fn draw_read_view(f: &mut Frame, area: Rect, state: &ReadViewState) { if is_selected { f.render_widget( - Block::default().style(Style::default().bg(Color::Rgb(39, 39, 42))), + Block::default().style(Style::default().bg(BG_SELECTED)), row_area, ); } @@ -3303,10 +3341,10 @@ fn draw_append_view(f: &mut Frame, area: Rect, state: &AppendViewState) { let header_lines = vec![ Line::from(""), Line::from(vec![ - Span::styled(" ← ", Style::default().fg(GRAY_700)), - Span::styled(&basin_str, Style::default().fg(Color::Rgb(150, 150, 150))), - Span::styled(" / ", Style::default().fg(Color::Rgb(80, 80, 80))), - Span::styled(&stream_str, Style::default().fg(Color::Rgb(180, 180, 180))), + Span::styled(" ← ", Style::default().fg(GRAY_600)), + Span::styled(&basin_str, Style::default().fg(GRAY_300)), + Span::styled(" / ", Style::default().fg(GRAY_700)), + Span::styled(&stream_str, Style::default().fg(GRAY_200)), Span::styled(" ", Style::default()), Span::styled(" APPEND ", Style::default().fg(BG_DARK).bg(GREEN).bold()), ]), @@ -3314,7 +3352,7 @@ fn draw_append_view(f: &mut Frame, area: Rect, state: &AppendViewState) { let header = Paragraph::new(header_lines).block( Block::default() .borders(Borders::BOTTOM) - .border_style(Style::default().fg(GRAY_800)), + .border_style(Style::default().fg(BORDER_TITLE)), ); f.render_widget(header, outer_chunks[0]); @@ -3507,9 +3545,9 @@ fn draw_append_view(f: &mut Frame, area: Rect, state: &AppendViewState) { // Separator between single record and batch mode lines.push(Line::from(vec![ - Span::styled(" ─── ", Style::default().fg(GRAY_650)), + Span::styled(" ─── ", Style::default().fg(GRAY_800)), Span::styled("or batch from file", Style::default().fg(TEXT_MUTED)), - Span::styled(" ───────────────", Style::default().fg(GRAY_650)), + Span::styled(" ───────────────", Style::default().fg(GRAY_800)), ])); lines.push(Line::from("")); @@ -3832,14 +3870,14 @@ fn get_responsive_hints(screen: &Screen, width: usize) -> String { MetricsType::Basin { .. } | MetricsType::Account ) { if wide { - "←→ category | jk scroll | r refresh | esc back | q quit".to_string() + "←→ category | jk scroll | t time range | r refresh | esc back | q quit".to_string() } else { - "←→ cat | jk | r | esc q".to_string() + "←→ cat | jk | t time | r | esc q".to_string() } } else if wide { - "jk scroll | r refresh | esc back | q quit".to_string() + "jk scroll | t time range | r refresh | esc back | q quit".to_string() } else { - "jk | r | esc q".to_string() + "jk | t time | r | esc q".to_string() } } Screen::BenchView(state) => { @@ -3872,7 +3910,7 @@ fn draw_help_overlay(f: &mut Frame, screen: &Screen) { Line::from(vec![ Span::styled(" ", Style::default()), Span::styled(format!("─── {} ", title), Style::default().fg(CYAN).bold()), - Span::styled("─".repeat(20), Style::default().fg(GRAY_650)), + Span::styled("─".repeat(20), Style::default().fg(GRAY_800)), ]) } @@ -5082,7 +5120,7 @@ fn draw_input_dialog(f: &mut Frame, mode: &InputMode) { let mut seq_spans = vec![ind]; seq_spans.push(Span::styled( if is_seq { "● " } else { "○ " }, - Style::default().fg(if is_seq { GREEN } else { GRAY_650 }), + Style::default().fg(if is_seq { GREEN } else { GRAY_800 }), )); seq_spans.push(lbl); seq_spans.push(Span::raw(" ")); @@ -5100,7 +5138,7 @@ fn draw_input_dialog(f: &mut Frame, mode: &InputMode) { let mut ts_spans = vec![ind]; ts_spans.push(Span::styled( if is_ts { "● " } else { "○ " }, - Style::default().fg(if is_ts { GREEN } else { GRAY_650 }), + Style::default().fg(if is_ts { GREEN } else { GRAY_800 }), )); ts_spans.push(lbl); ts_spans.push(Span::raw(" ")); @@ -5119,7 +5157,7 @@ fn draw_input_dialog(f: &mut Frame, mode: &InputMode) { let mut ago_spans = vec![ind]; ago_spans.push(Span::styled( if is_ago { "● " } else { "○ " }, - Style::default().fg(if is_ago { GREEN } else { GRAY_650 }), + Style::default().fg(if is_ago { GREEN } else { GRAY_800 }), )); ago_spans.push(lbl); ago_spans.push(Span::raw(" ")); @@ -5145,7 +5183,7 @@ fn draw_input_dialog(f: &mut Frame, mode: &InputMode) { let mut off_spans = vec![ind]; off_spans.push(Span::styled( if is_off { "● " } else { "○ " }, - Style::default().fg(if is_off { GREEN } else { GRAY_650 }), + Style::default().fg(if is_off { GREEN } else { GRAY_800 }), )); off_spans.push(lbl); off_spans.push(Span::raw(" ")); @@ -5824,7 +5862,7 @@ fn render_hint_with_keys(hint: &str) -> Vec> { for (i, part) in parts.iter().enumerate() { if i > 0 { - spans.push(Span::styled(" · ", Style::default().fg(GRAY_650))); + spans.push(Span::styled(" · ", Style::default().fg(GRAY_800))); } // Split into key and description (first word is the key) From e3549bb1e100c9cbe7354c5330e65f35902409e1 Mon Sep 17 00:00:00 2001 From: Mehul Arora Date: Mon, 26 Jan 2026 17:10:47 -0500 Subject: [PATCH 25/31] . --- src/tui/ui.rs | 94 +++++++++++++++++++++++++++++++++------------------ 1 file changed, 61 insertions(+), 33 deletions(-) diff --git a/src/tui/ui.rs b/src/tui/ui.rs index 207757e..24c2d69 100644 --- a/src/tui/ui.rs +++ b/src/tui/ui.rs @@ -890,43 +890,63 @@ fn draw_access_tokens(f: &mut Frame, area: Rect, state: &AccessTokensState) { ))); f.render_widget(empty, chunks[3]); } else { - let list_height = chunks[3].height as usize; - let start = state.selected.saturating_sub(list_height / 2); - let visible_tokens = filtered_tokens.iter().skip(start).take(list_height); + let table_area = chunks[3]; + let visible_height = table_area.height as usize; + let total = filtered_tokens.len(); + let selected = state.selected.min(total.saturating_sub(1)); - let lines: Vec = visible_tokens + let scroll_offset = if selected >= visible_height { + selected - visible_height + 1 + } else { + 0 + }; + + for (view_idx, token) in filtered_tokens + .iter() .enumerate() - .map(|(i, token)| { - let actual_index = start + i; - let is_selected = actual_index == state.selected; - let scope_summary = format_scope_summary(token); + .skip(scroll_offset) + .take(visible_height) + { + let y = table_area.y + (view_idx - scroll_offset) as u16; + if y >= table_area.y + table_area.height { + break; + } - let style = if is_selected { - Style::default().fg(GREEN).bold() - } else { - Style::default().fg(TEXT_PRIMARY) - }; + let is_selected = view_idx == selected; + let row_area = Rect::new(table_area.x, y, table_area.width, 1); + + if is_selected { + f.render_widget( + Block::default().style(Style::default().bg(BG_SELECTED)), + row_area, + ); + } - let prefix = if is_selected { "▶ " } else { " " }; - let token_id_str = token.id.to_string(); - let token_id_display = truncate_str(&token_id_str, 28, "…"); - let expires_str = token.expires_at.to_string(); - let expires_display = truncate_str(&expires_str, 26, "…"); + let scope_summary = format_scope_summary(token); + let prefix = if is_selected { "▸ " } else { " " }; + let token_id_str = token.id.to_string(); + let token_id_display = truncate_str(&token_id_str, 28, "…"); + let expires_str = token.expires_at.to_string(); + let expires_display = truncate_str(&expires_str, 26, "…"); - Line::from(vec![ - Span::styled(prefix, style), - Span::styled(format!("{:<30}", token_id_display), style), - Span::styled( - format!("{:<28}", expires_display), - Style::default().fg(TEXT_MUTED), - ), - Span::styled(scope_summary, Style::default().fg(TEXT_MUTED)), - ]) - }) - .collect(); + let name_style = if is_selected { + Style::default().fg(TEXT_PRIMARY).bold() + } else { + Style::default().fg(TEXT_PRIMARY) + }; + + let line = Line::from(vec![ + Span::styled(prefix, Style::default().fg(if is_selected { GREEN } else { TEXT_PRIMARY })), + Span::styled(format!("{:<30}", token_id_display), name_style), + Span::styled( + format!("{:<28}", expires_display), + Style::default().fg(TEXT_MUTED), + ), + Span::styled(scope_summary, Style::default().fg(TEXT_MUTED)), + ]); - let list = Paragraph::new(lines); - f.render_widget(list, chunks[3]); + f.render_widget(Paragraph::new(line), row_area); + } } } @@ -2368,13 +2388,17 @@ fn draw_basins(f: &mut Frame, area: Rect, state: &BasinsState) { }) .unwrap_or("—"); + let prefix = if is_selected { "▸ " } else { " " }; let name_style = if is_selected { Style::default().fg(TEXT_PRIMARY).bold() } else { Style::default().fg(TEXT_SECONDARY) }; f.render_widget( - Paragraph::new(Span::styled(format!(" {}", display_name), name_style)), + Paragraph::new(Line::from(vec![ + Span::styled(prefix, Style::default().fg(if is_selected { GREEN } else { TEXT_SECONDARY })), + Span::styled(display_name, name_style), + ])), Rect::new(row_area.x, y, name_col as u16, 1), ); @@ -2547,13 +2571,17 @@ fn draw_streams(f: &mut Frame, area: Rect, state: &StreamsState) { let created = stream.created_at.to_string(); + let prefix = if is_selected { "▸ " } else { " " }; let name_style = if is_selected { Style::default().fg(TEXT_PRIMARY).bold() } else { Style::default().fg(TEXT_SECONDARY) }; f.render_widget( - Paragraph::new(Span::styled(format!(" {}", display_name), name_style)), + Paragraph::new(Line::from(vec![ + Span::styled(prefix, Style::default().fg(if is_selected { GREEN } else { TEXT_SECONDARY })), + Span::styled(display_name, name_style), + ])), Rect::new(row_area.x, y, name_col as u16, 1), ); From 11a8ab0f75cd46189d260730660a9e9f92544f15 Mon Sep 17 00:00:00 2001 From: Mehul Arora Date: Mon, 26 Jan 2026 17:12:14 -0500 Subject: [PATCH 26/31] . --- src/tui/ui.rs | 64 ++++++++++++++++++--------------------------------- 1 file changed, 23 insertions(+), 41 deletions(-) diff --git a/src/tui/ui.rs b/src/tui/ui.rs index 24c2d69..92d6599 100644 --- a/src/tui/ui.rs +++ b/src/tui/ui.rs @@ -936,7 +936,10 @@ fn draw_access_tokens(f: &mut Frame, area: Rect, state: &AccessTokensState) { }; let line = Line::from(vec![ - Span::styled(prefix, Style::default().fg(if is_selected { GREEN } else { TEXT_PRIMARY })), + Span::styled( + prefix, + Style::default().fg(if is_selected { GREEN } else { TEXT_PRIMARY }), + ), Span::styled(format!("{:<30}", token_id_display), name_style), Span::styled( format!("{:<28}", expires_display), @@ -1573,20 +1576,11 @@ fn render_multi_metric( Span::styled(format!("{} ", trend_text), Style::default().fg(trend_color)), Span::styled(" | ", Style::default().fg(BORDER)), Span::styled("min ", Style::default().fg(TEXT_MUTED)), - Span::styled( - format_count(min_val as u64), - Style::default().fg(STAT_MIN), - ), + Span::styled(format_count(min_val as u64), Style::default().fg(STAT_MIN)), Span::styled(" max ", Style::default().fg(TEXT_MUTED)), - Span::styled( - format_count(max_val as u64), - Style::default().fg(STAT_MAX), - ), + Span::styled(format_count(max_val as u64), Style::default().fg(STAT_MAX)), Span::styled(" avg ", Style::default().fg(TEXT_MUTED)), - Span::styled( - format_count(avg_val as u64), - Style::default().fg(STAT_AVG), - ), + Span::styled(format_count(avg_val as u64), Style::default().fg(STAT_AVG)), Span::styled( format!(" | {} pts", all_values.len()), Style::default().fg(TEXT_MUTED), @@ -2396,7 +2390,10 @@ fn draw_basins(f: &mut Frame, area: Rect, state: &BasinsState) { }; f.render_widget( Paragraph::new(Line::from(vec![ - Span::styled(prefix, Style::default().fg(if is_selected { GREEN } else { TEXT_SECONDARY })), + Span::styled( + prefix, + Style::default().fg(if is_selected { GREEN } else { TEXT_SECONDARY }), + ), Span::styled(display_name, name_style), ])), Rect::new(row_area.x, y, name_col as u16, 1), @@ -2579,7 +2576,10 @@ fn draw_streams(f: &mut Frame, area: Rect, state: &StreamsState) { }; f.render_widget( Paragraph::new(Line::from(vec![ - Span::styled(prefix, Style::default().fg(if is_selected { GREEN } else { TEXT_SECONDARY })), + Span::styled( + prefix, + Style::default().fg(if is_selected { GREEN } else { TEXT_SECONDARY }), + ), Span::styled(display_name, name_style), ])), Rect::new(row_area.x, y, name_col as u16, 1), @@ -2652,10 +2652,7 @@ fn draw_stream_detail(f: &mut Frame, area: Rect, state: &StreamDetailState) { let lines = vec![ Line::from(vec![ Span::styled(icon, Style::default().fg(value_color)), - Span::styled( - format!(" {}", label), - Style::default().fg(GRAY_400), - ), + Span::styled(format!(" {}", label), Style::default().fg(GRAY_400)), ]), Line::from(vec![ Span::styled(" ", Style::default()), @@ -2762,11 +2759,7 @@ fn draw_stream_detail(f: &mut Frame, area: Rect, state: &StreamDetailState) { crate::types::RetentionPolicy::Infinite => "∞".to_string(), }) .unwrap_or_else(|| "∞".to_string()); - let color = if val == "∞" { - PURPLE - } else { - GRAY_200 - }; + let color = if val == "∞" { PURPLE } else { GRAY_200 }; (val, color) } else { ("--".to_string(), GRAY_600) @@ -2818,10 +2811,7 @@ fn draw_stream_detail(f: &mut Frame, area: Rect, state: &StreamDetailState) { let mut lines = vec![ Line::from(vec![ - Span::styled( - format!(" {} ", title), - Style::default().fg(CYAN).bold(), - ), + Span::styled(format!(" {} ", title), Style::default().fg(CYAN).bold()), Span::styled("─".repeat(line_width), Style::default().fg(BORDER_TITLE)), ]), Line::from(""), @@ -2844,24 +2834,15 @@ fn draw_stream_detail(f: &mut Frame, area: Rect, state: &StreamDetailState) { ])); lines.push(Line::from(vec![ Span::styled(" ", Style::default()), - Span::styled( - *desc, - Style::default().fg(GRAY_200).italic(), - ), + Span::styled(*desc, Style::default().fg(GRAY_200).italic()), ])); } else { // Unselected action - dimmed lines.push(Line::from(vec![ Span::styled(" ", Style::default()), Span::styled(*icon, Style::default().fg(GRAY_700)), - Span::styled( - format!(" {} ", name), - Style::default().fg(GRAY_400), - ), - Span::styled( - format!("[{}]", key), - Style::default().fg(GRAY_700), - ), + Span::styled(format!(" {} ", name), Style::default().fg(GRAY_400)), + Span::styled(format!("[{}]", key), Style::default().fg(GRAY_700)), ])); } lines.push(Line::from("")); @@ -3898,7 +3879,8 @@ fn get_responsive_hints(screen: &Screen, width: usize) -> String { MetricsType::Basin { .. } | MetricsType::Account ) { if wide { - "←→ category | jk scroll | t time range | r refresh | esc back | q quit".to_string() + "←→ category | jk scroll | t time range | r refresh | esc back | q quit" + .to_string() } else { "←→ cat | jk | t time | r | esc q".to_string() } From 1164386372e4337d449301ba7f5ebcb2f6470f49 Mon Sep 17 00:00:00 2001 From: Mehul Arora Date: Mon, 26 Jan 2026 18:19:13 -0500 Subject: [PATCH 27/31] . --- Cargo.toml | 8 +++----- 1 file changed, 3 insertions(+), 5 deletions(-) diff --git a/Cargo.toml b/Cargo.toml index 9c17b70..7d64d31 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -16,10 +16,12 @@ path = "src/main.rs" async-stream = "0.3.6" base64ct = { version = "1.8.3", features = ["alloc"] } bytes = "1.11.0" +chrono = "0.4" clap = { version = "4.5.54", features = ["derive"] } color-print = "0.3.7" colored = "3.1.1" config = "0.15.19" +crossterm = "0.28" dirs = "6.0.0" futures = "0.3.31" http = "1.4.0" @@ -28,6 +30,7 @@ indicatif = "0.18.3" json_to_table = "0.12.0" miette = { version = "7.6.0", features = ["fancy"] } rand = "0.9.2" +ratatui = "0.29" s2-sdk = { version = "0.23.0", features = ["_hidden"] } serde = { version = "1.0.228", features = ["derive"] } serde_json = { version = "1.0.149", features = ["preserve_order"] } @@ -42,11 +45,6 @@ tracing-subscriber = { version = "0.3.22", features = ["env-filter"] } uuid = { version = "1.20.0", features = ["v4"] } xxhash-rust = { version = "0.8.15", features = ["xxh3"] } -# TUI -ratatui = "0.29" -crossterm = "0.28" -chrono = "0.4" - [dev-dependencies] assert_cmd = "2.1" predicates = "3.1" From c8ba975e5091cdafac66dd8abe9c44ed64d14bd8 Mon Sep 17 00:00:00 2001 From: Mehul Arora Date: Mon, 26 Jan 2026 18:30:42 -0500 Subject: [PATCH 28/31] .. --- src/tui/app.rs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/tui/app.rs b/src/tui/app.rs index 7411932..d28267a 100644 --- a/src/tui/app.rs +++ b/src/tui/app.rs @@ -6930,7 +6930,7 @@ impl App { let result = run_bench_with_events( stream, record_size as usize, - NonZeroU64::new(target_mibps).unwrap_or(NonZeroU64::new(1).unwrap()), + NonZeroU64::new(target_mibps).unwrap_or(NonZeroU64::MIN), Duration::from_secs(duration_secs), Duration::from_secs(catchup_delay_secs), user_stop, From 2720fd0ceb14577460c063acbb050b728587d136 Mon Sep 17 00:00:00 2001 From: Mehul Arora Date: Wed, 28 Jan 2026 12:00:07 -0500 Subject: [PATCH 29/31] . --- src/bench.rs | 41 +++++++++++++++++++++++++++------------ src/tui/app.rs | 52 ++++++++++++++++++++++++++++---------------------- 2 files changed, 58 insertions(+), 35 deletions(-) diff --git a/src/bench.rs b/src/bench.rs index 6cbf50b..397d3c0 100644 --- a/src/bench.rs +++ b/src/bench.rs @@ -656,19 +656,28 @@ pub async fn run( let mut catchup_chain_hash: Option = None; let catchup_stream = bench_read_catchup(stream.clone(), record_size, bench_start); let mut catchup_stream = std::pin::pin!(catchup_stream); - while let Some(result) = catchup_stream.next().await { - match result { - Ok(sample) => { + let catchup_timeout = Duration::from_secs(300); + let catchup_deadline = tokio::time::Instant::now() + catchup_timeout; + loop { + match tokio::time::timeout_at(catchup_deadline, catchup_stream.next()).await { + Ok(Some(Ok(sample))) => { update_bench_bar(&catchup_bar, &sample); if let Some(hash) = sample.chain_hash { catchup_chain_hash = Some(hash); } catchup_sample = Some(sample); } - Err(e) => { + Ok(Some(Err(e))) => { catchup_bar.finish_and_clear(); return Err(e); } + Ok(None) => break, + Err(_) => { + catchup_bar.finish_and_clear(); + return Err(CliError::BenchVerification( + "catchup read timed out after 5 minutes".to_string(), + )); + } } } @@ -690,14 +699,22 @@ pub async fn run( ); } - if let (Some(write_sample), Some(catchup_sample)) = - (write_sample.as_ref(), catchup_sample.as_ref()) - && write_sample.records != catchup_sample.records - { - return Err(CliError::BenchVerification(format!( - "catchup read record count mismatch: expected {}, got {}", - write_sample.records, catchup_sample.records - ))); + match (write_sample.as_ref(), catchup_sample.as_ref()) { + (Some(write_sample), Some(catchup_sample)) + if write_sample.records != catchup_sample.records => + { + return Err(CliError::BenchVerification(format!( + "catchup read record count mismatch: expected {}, got {}", + write_sample.records, catchup_sample.records + ))); + } + (Some(write_sample), None) if write_sample.records > 0 => { + return Err(CliError::BenchVerification(format!( + "catchup read returned no records but write produced {}", + write_sample.records + ))); + } + _ => {} } if let (Some(expected), Some(actual)) = (write_chain_hash, catchup_chain_hash) diff --git a/src/tui/app.rs b/src/tui/app.rs index d28267a..77a0324 100644 --- a/src/tui/app.rs +++ b/src/tui/app.rs @@ -6606,17 +6606,20 @@ impl App { .calendar_end .expect("calendar_end set before should_apply"); - // Ensure start <= end let (start, end) = if start_date <= end_date { (start_date, end_date) } else { (end_date, start_date) }; - // Convert to unix timestamps (start of day for start, end of day for end) let start_ts = Self::date_to_timestamp(start.0, start.1, start.2, true); let end_ts = Self::date_to_timestamp(end.0, end.1, end.2, false); + let Some((start_ts, end_ts)) = start_ts.zip(end_ts) else { + state.calendar_open = false; + return; + }; + let time_range = TimeRangeOption::Custom { start: start_ts, end: end_ts, @@ -6659,28 +6662,24 @@ impl App { } } - /// Get the number of days in a month fn days_in_month(year: i32, month: u32) -> u32 { - NaiveDate::from_ymd_opt(year, month + 1, 1) - .unwrap_or_else(|| NaiveDate::from_ymd_opt(year + 1, 1, 1).unwrap()) - .pred_opt() + let next_month_start = if month == 12 { + NaiveDate::from_ymd_opt(year + 1, 1, 1) + } else { + NaiveDate::from_ymd_opt(year, month + 1, 1) + }; + next_month_start + .and_then(|d| d.pred_opt()) .map(|d| d.day()) .unwrap_or(28) } - /// Convert a date to unix timestamp - fn date_to_timestamp(year: i32, month: u32, day: u32, start_of_day: bool) -> u32 { + fn date_to_timestamp(year: i32, month: u32, day: u32, start_of_day: bool) -> Option { use chrono::{TimeZone, Utc}; - let dt = if start_of_day { - Utc.with_ymd_and_hms(year, month, day, 0, 0, 0) - .single() - .expect("invalid date selected in calendar") - } else { - Utc.with_ymd_and_hms(year, month, day, 23, 59, 59) - .single() - .expect("invalid date selected in calendar") - }; - dt.timestamp() as u32 + let (h, m, s) = if start_of_day { (0, 0, 0) } else { (23, 59, 59) }; + Utc.with_ymd_and_hms(year, month, day, h, m, s) + .single() + .map(|dt| dt.timestamp() as u32) } fn handle_bench_view_key(&mut self, key: KeyEvent, tx: mpsc::UnboundedSender) { @@ -7135,13 +7134,14 @@ async fn run_bench_with_events( let catchup_stream = bench_read_catchup(stream.clone(), record_size, bench_start); let mut catchup_stream = std::pin::pin!(catchup_stream); - while let Some(result) = catchup_stream.next().await { - // Check stop signal during catchup + let catchup_timeout = Duration::from_secs(300); + let catchup_deadline = tokio::time::Instant::now() + catchup_timeout; + loop { if stop.load(Ordering::Relaxed) { break; } - match result { - Ok(sample) => { + match tokio::time::timeout_at(catchup_deadline, catchup_stream.next()).await { + Ok(Some(Ok(sample))) => { let mibps = sample.bytes as f64 / (1024.0 * 1024.0) / sample.elapsed.as_secs_f64().max(0.001); @@ -7154,9 +7154,15 @@ async fn run_bench_with_events( records_per_sec: recps, })); } - Err(e) => { + Ok(Some(Err(e))) => { return Err(e); } + Ok(None) => break, + Err(_) => { + return Err(CliError::BenchVerification( + "catchup read timed out after 5 minutes".to_string(), + )); + } } } let _ = tx.send(Event::BenchPhaseComplete(BenchPhase::Catchup)); From 5b006c23e163e46b44402eb4d66ff22c4f23b95a Mon Sep 17 00:00:00 2001 From: Mehul Arora Date: Wed, 28 Jan 2026 12:42:40 -0500 Subject: [PATCH 30/31] . --- src/tui/app.rs | 57 +++-- src/tui/ui.rs | 638 +++++++++++++++++++++++++++++++++++++++++++++++-- 2 files changed, 657 insertions(+), 38 deletions(-) diff --git a/src/tui/app.rs b/src/tui/app.rs index 77a0324..e664645 100644 --- a/src/tui/app.rs +++ b/src/tui/app.rs @@ -37,6 +37,24 @@ use super::ui; /// Maximum records to keep in read view buffer const MAX_RECORDS_BUFFER: usize = 1000; +/// Maximum throughput history samples to keep (60 seconds at 1 sample/sec) +const MAX_THROUGHPUT_HISTORY: usize = 60; + +/// Splash screen display duration in milliseconds +const SPLASH_DURATION_MS: u64 = 1200; + +/// Target frame interval in milliseconds (~60fps) +const FRAME_INTERVAL_MS: u64 = 16; + +/// Calculate throughput rates from accumulated bytes/records over elapsed time. +/// Returns (MiB/s, records/s). +#[inline] +fn calculate_throughput(bytes: u64, records: u64, elapsed_secs: f64) -> (f64, f64) { + let mibps = (bytes as f64) / (1024.0 * 1024.0) / elapsed_secs; + let recps = (records as f64) / elapsed_secs; + (mibps, recps) +} + /// Top-level navigation tabs #[derive(Debug, Clone, Copy, PartialEq, Eq, Default)] pub enum Tab { @@ -1187,7 +1205,7 @@ impl App { pub async fn run(mut self, terminal: &mut Terminal) -> Result<(), CliError> { let (tx, mut rx) = mpsc::unbounded_channel(); let splash_start = std::time::Instant::now(); - let splash_duration = Duration::from_millis(1200); + let splash_duration = Duration::from_millis(SPLASH_DURATION_MS); if self.s2.is_some() { self.load_basins(tx.clone()); } @@ -1267,7 +1285,7 @@ impl App { } self.handle_event(event); } - _ = tokio::time::sleep(Duration::from_millis(16)) => {} + _ = tokio::time::sleep(Duration::from_millis(FRAME_INTERVAL_MS)) => {} } if self.should_quit { @@ -1380,23 +1398,23 @@ impl App { if let Some(last_tick) = state.last_tick { let elapsed = last_tick.elapsed(); if elapsed >= std::time::Duration::from_secs(1) { - let secs = elapsed.as_secs_f64(); - let mibps = (state.bytes_this_second as f64) - / (1024.0 * 1024.0) - / secs; - let recps = (state.records_this_second as f64) / secs; + let (mibps, recps) = calculate_throughput( + state.bytes_this_second, + state.records_this_second, + elapsed.as_secs_f64(), + ); state.current_mibps = mibps; state.current_recps = recps; state.throughput_history.push(mibps); state.records_per_sec_history.push(recps); - // Keep only last 60 samples - const MAX_HISTORY: usize = 60; - if state.throughput_history.len() > MAX_HISTORY { + if state.throughput_history.len() > MAX_THROUGHPUT_HISTORY { state.throughput_history.remove(0); } - if state.records_per_sec_history.len() > MAX_HISTORY { + if state.records_per_sec_history.len() + > MAX_THROUGHPUT_HISTORY + { state.records_per_sec_history.remove(0); } @@ -1468,10 +1486,13 @@ impl App { if let Some(last_tick) = pip.last_tick { let elapsed = last_tick.elapsed(); if elapsed >= std::time::Duration::from_secs(1) { - let secs = elapsed.as_secs_f64(); - pip.current_mibps = - (pip.bytes_this_second as f64) / (1024.0 * 1024.0) / secs; - pip.current_recps = (pip.records_this_second as f64) / secs; + let (mibps, recps) = calculate_throughput( + pip.bytes_this_second, + pip.records_this_second, + elapsed.as_secs_f64(), + ); + pip.current_mibps = mibps; + pip.current_recps = recps; pip.bytes_this_second = 0; pip.records_this_second = 0; pip.last_tick = Some(std::time::Instant::now()); @@ -6676,7 +6697,11 @@ impl App { fn date_to_timestamp(year: i32, month: u32, day: u32, start_of_day: bool) -> Option { use chrono::{TimeZone, Utc}; - let (h, m, s) = if start_of_day { (0, 0, 0) } else { (23, 59, 59) }; + let (h, m, s) = if start_of_day { + (0, 0, 0) + } else { + (23, 59, 59) + }; Utc.with_ymd_and_hms(year, month, day, h, m, s) .single() .map(|dt| dt.timestamp() as u32) diff --git a/src/tui/ui.rs b/src/tui/ui.rs index 92d6599..5c1eb5e 100644 --- a/src/tui/ui.rs +++ b/src/tui/ui.rs @@ -1,6 +1,6 @@ use ratatui::{ Frame, - layout::{Alignment, Constraint, Direction, Layout, Rect}, + layout::{Alignment, Constraint, Direction, Layout, Margin, Rect}, style::{Color, Style, Stylize}, text::{Line, Span}, widgets::{Block, Borders, Clear, List, ListItem, Padding, Paragraph}, @@ -87,6 +87,66 @@ const CURSOR: &str = "▎"; const SELECTED_INDICATOR: &str = " ▸ "; const UNSELECTED_INDICATOR: &str = " "; +/// Minimum dialog dimensions to ensure readability +const MIN_DIALOG_WIDTH: u16 = 60; +const MIN_DIALOG_HEIGHT: u16 = 20; + +/// Help descriptions for TUI options. +/// Source: https://github.com/s2-streamstore/s2-specs/blob/main/s2/v1/openapi.json +mod help_text { + // Record format (s2-format header) - exact from spec + pub const FORMAT_TEXT: &str = "Plain text, one record per line. No headers."; + pub const FORMAT_JSON: &str = "Efficient transmission and storage of Unicode data (UTF-8)."; // "raw" format + pub const FORMAT_JSON_BASE64: &str = "Safe transmission with efficient storage of binary data."; // "base64" format + + // Storage class - spec has no descriptions, these are functional + pub const STORAGE_DEFAULT: &str = "Use basin's default storage class."; + pub const STORAGE_STANDARD: &str = "Standard storage class for recent writes."; + pub const STORAGE_EXPRESS: &str = "Express storage class, optimized for performance."; + + // Timestamping mode - spec has no descriptions, these are functional + pub const TS_DEFAULT: &str = "Use basin's default timestamping mode."; + pub const TS_CLIENT_PREFER: &str = "Use client timestamp if provided, else arrival time."; + pub const TS_CLIENT_REQUIRE: &str = "Require client timestamp, reject if missing."; + pub const TS_ARRIVAL: &str = "Always use server arrival time."; + + // Uncapped - exact from spec + pub const TS_UNCAPPED: &str = "Allow client timestamps to exceed arrival time."; + pub const TS_CAPPED: &str = "Client timestamps capped at arrival time."; + + // Retention - exact from spec + pub const RETENTION_INFINITE: &str = "Retain records unless explicitly trimmed."; + pub const RETENTION_AGE: &str = "Auto-trim records older than threshold (seconds)."; + + // Delete on empty - exact from spec + pub const DELETE_NEVER: &str = "Set to 0 to disable delete-on-empty."; + pub const DELETE_THRESHOLD: &str = "Minimum age (seconds) before empty stream can be deleted."; + + // Clamp - functional description + pub const CLAMP_ON: &str = "Clamp start position to stream bounds."; + pub const CLAMP_OFF: &str = "Error if start position out of bounds."; + + // Auto-create streams - exact from spec + pub const AUTO_CREATE_APPEND: &str = "Create stream on append if it doesn't exist."; + pub const AUTO_CREATE_READ: &str = "Create stream on read if it doesn't exist."; + + // Fencing - exact from spec + pub const FENCE_TOKEN: &str = "Fencing token which can be overridden by a fence command."; + pub const FENCE_CURRENT: &str = "Current fencing token (empty string if unfenced)."; + + // Trim - functional description + pub const TRIM_SEQ_NUM: &str = "Remove all records before this sequence number."; + + // Match seq_num - exact from spec + pub const MATCH_SEQ_NUM: &str = "Enforce that the first record's sequence number matches."; + + // Access tokens + pub const TOKEN_EXPIRY: &str = "When the token becomes invalid."; + + // Append fencing - exact from spec + pub const APPEND_FENCING: &str = "Enforce fencing token match for this operation."; +} + /// Safely truncate a string to max_len characters, adding suffix if truncated. /// Returns the original string if it fits, otherwise truncates and adds suffix. fn truncate_str(s: &str, max_len: usize, suffix: &str) -> String { @@ -709,7 +769,9 @@ fn draw_settings_field( .borders(Borders::ALL) .border_style(border_style) .style(Style::default().bg(BG_DARK)); - let value_para = Paragraph::new(Span::styled(value_display, value_style)).block(value_block); + let value_para = Paragraph::new(Span::styled(value_display, value_style)) + .block(value_block) + .wrap(ratatui::widgets::Wrap { trim: false }); f.render_widget(value_para, Rect::new(area.x, area.y + 1, area.width, 3)); } @@ -3334,7 +3396,9 @@ fn draw_headers_popup(f: &mut Frame, record: &s2_sdk::types::SequencedRecord) { .style(Style::default().bg(BG_DARK)); f.render_widget(Clear, area); - let para = Paragraph::new(lines).block(block); + let para = Paragraph::new(lines) + .block(block) + .wrap(ratatui::widgets::Wrap { trim: false }); f.render_widget(para, area); } @@ -3520,6 +3584,15 @@ fn draw_append_view(f: &mut Frame, area: Rect, state: &AppendViewState) { }), ), ])); + if match_selected { + lines.push(Line::from(vec![ + Span::styled(" ", Style::default()), + Span::styled( + help_text::MATCH_SEQ_NUM, + Style::default().fg(GRAY_600).italic(), + ), + ])); + } lines.push(Line::from("")); let fence_selected = state.selected == 3; @@ -3550,6 +3623,15 @@ fn draw_append_view(f: &mut Frame, area: Rect, state: &AppendViewState) { }), ), ])); + if fence_selected { + lines.push(Line::from(vec![ + Span::styled(" ", Style::default()), + Span::styled( + help_text::APPEND_FENCING, + Style::default().fg(GRAY_600).italic(), + ), + ])); + } lines.push(Line::from("")); // Separator between single record and batch mode @@ -3617,6 +3699,17 @@ fn draw_append_view(f: &mut Frame, area: Rect, state: &AppendViewState) { Span::raw(" "), render_pill(format_opts[2].0, format_selected, format_opts[2].1), ])); + if format_selected { + let format_help = match state.input_format { + super::app::InputFormat::Text => help_text::FORMAT_TEXT, + super::app::InputFormat::Json => help_text::FORMAT_JSON, + super::app::InputFormat::JsonBase64 => help_text::FORMAT_JSON_BASE64, + }; + lines.push(Line::from(vec![ + Span::styled(" ", Style::default()), + Span::styled(format_help, Style::default().fg(GRAY_600).italic()), + ])); + } // Show progress if appending from file if let Some((done, total)) = state.file_append_progress { @@ -3656,7 +3749,7 @@ fn draw_append_view(f: &mut Frame, area: Rect, state: &AppendViewState) { Span::styled(btn_text, Style::default().fg(btn_fg).bg(btn_bg).bold()), ])); - let form_para = Paragraph::new(lines); + let form_para = Paragraph::new(lines).wrap(ratatui::widgets::Wrap { trim: false }); f.render_widget(form_para, form_inner); let history_block = Block::default() @@ -3704,7 +3797,8 @@ fn draw_append_view(f: &mut Frame, area: Rect, state: &AppendViewState) { history_lines.push(Line::from(spans)); } - let history_para = Paragraph::new(history_lines); + let history_para = + Paragraph::new(history_lines).wrap(ratatui::widgets::Wrap { trim: false }); f.render_widget(history_para, history_inner); } } @@ -4247,23 +4341,109 @@ fn draw_help_overlay(f: &mut Frame, screen: &Screen) { } fn centered_rect(percent_x: u16, percent_y: u16, area: Rect) -> Rect { - let popup_layout = Layout::default() - .direction(Direction::Vertical) - .constraints([ - Constraint::Percentage((100 - percent_y) / 2), - Constraint::Percentage(percent_y), - Constraint::Percentage((100 - percent_y) / 2), - ]) - .split(area); + // Calculate target size from percentage + let target_width = (area.width as u32 * percent_x as u32 / 100) as u16; + let target_height = (area.height as u32 * percent_y as u32 / 100) as u16; - Layout::default() - .direction(Direction::Horizontal) - .constraints([ - Constraint::Percentage((100 - percent_x) / 2), - Constraint::Percentage(percent_x), - Constraint::Percentage((100 - percent_x) / 2), - ]) - .split(popup_layout[1])[1] + // Apply minimum sizes to ensure readability on small terminals + let width = target_width.max(MIN_DIALOG_WIDTH).min(area.width); + let height = target_height.max(MIN_DIALOG_HEIGHT).min(area.height); + + // Center the rectangle + let x = area.x + area.width.saturating_sub(width) / 2; + let y = area.y + area.height.saturating_sub(height) / 2; + + Rect::new(x, y, width, height) +} + +fn get_selected_line_hint(mode: &InputMode) -> usize { + match mode { + InputMode::Normal => 0, + InputMode::CreateBasin { selected, .. } => match selected { + 0 => 3, // Name + 1 => 7, // Region + 2 => 12, // Storage + 3 => 16, // Retention + 4 => 19, // Duration + 5 => 22, // Timestamps + 6 => 26, // Uncapped + 7 => 30, // Delete on empty + 8 => 34, // Threshold + 9 => 40, // On append + 10 => 44, // On read + _ => 48, // Button + } + InputMode::CreateStream { selected, .. } => match selected { + 0 => 5, // Name + 1 => 12, // Storage + 2 => 16, // Retention + 3 => 19, // Duration + 4 => 22, // Timestamps + 5 => 26, // Uncapped + 6 => 30, // Delete on empty + 7 => 34, // Threshold + _ => 38, // Button + }, + InputMode::ReconfigureBasin { selected, .. } => match selected { + 0 => 6, // Storage + 1 => 10, // Retention + 2 => 14, // Duration + 3 => 18, // Timestamps + 4 => 22, // Uncapped + 5 => 28, // On append + 6 => 32, // On read + _ => 36, + }, + InputMode::ReconfigureStream { selected, .. } => match selected { + 0 => 6, // Storage + 1 => 10, // Retention + 2 => 14, // Duration + 3 => 18, // Timestamps + 4 => 22, // Uncapped + 5 => 26, // Delete on empty + 6 => 30, // Threshold + _ => 34, + }, + InputMode::CustomRead { selected, .. } => match selected { + 0 => 6, // Seq num + 1 => 9, // Timestamp + 2 => 12, // Time ago + 3 => 15, // Tail offset + 4 => 20, // Max records + 5 => 23, // Max bytes + 6 => 26, // Until + 7 => 31, // Clamp + 8 => 35, // Format + 9 => 40, // Output file + _ => 45, // Button + }, + InputMode::IssueAccessToken { selected, .. } => match selected { + 0 => 2, // Token ID + 1 => 5, // Expiration + 2 => 9, // Custom duration + 3 => 14, // Basins scope + 4 => 17, // Basins pattern + 5 => 20, // Streams scope + 6 => 23, // Streams pattern + 7 => 26, // Tokens scope + 8 => 29, // Tokens pattern + 9 => 34, // Account read + 10 => 36, // Account write + 11 => 38, // Basin read + 12 => 40, // Basin write + 13 => 42, // Stream read + 14 => 44, // Stream write + 15 => 48, // Auto prefix + _ => 52, // Button + } + InputMode::Fence { selected, .. } => *selected * 4 + 5, + InputMode::Trim { selected, .. } => *selected * 4 + 5, + InputMode::ConfirmDeleteBasin { .. } + | InputMode::ConfirmDeleteStream { .. } + | InputMode::ConfirmRevokeToken { .. } + | InputMode::ShowIssuedToken { .. } + | InputMode::ViewTokenDetail { .. } => 0, + } } fn draw_input_dialog(f: &mut Frame, mode: &InputMode) { @@ -4398,6 +4578,19 @@ fn draw_input_dialog(f: &mut Frame, mode: &InputMode) { } lines.push(Line::from(storage_spans)); + // Storage class help text + if *selected == 2 { + let storage_help = match storage_class { + None => help_text::STORAGE_DEFAULT, + Some(StorageClass::Standard) => help_text::STORAGE_STANDARD, + Some(StorageClass::Express) => help_text::STORAGE_EXPRESS, + }; + lines.push(Line::from(vec![ + Span::raw(" "), + Span::styled(storage_help, Style::default().fg(GRAY_600).italic()), + ])); + } + lines.push(Line::from("")); let (ind, lbl) = render_field_row_bold(3, "Retention", *selected); let mut ret_spans = vec![ind, lbl, Span::raw(" ")]; @@ -4407,6 +4600,18 @@ fn draw_input_dialog(f: &mut Frame, mode: &InputMode) { } lines.push(Line::from(ret_spans)); + // Retention help text + if *selected == 3 { + let ret_help = match retention_policy { + RetentionPolicyOption::Infinite => help_text::RETENTION_INFINITE, + RetentionPolicyOption::Age => help_text::RETENTION_AGE, + }; + lines.push(Line::from(vec![ + Span::raw(" "), + Span::styled(ret_help, Style::default().fg(GRAY_600).italic()), + ])); + } + if *retention_policy == RetentionPolicyOption::Age { let (ind, lbl) = render_field_row_bold(4, " Duration", *selected); let mut duration_spans = vec![ind, lbl, Span::raw(" ")]; @@ -4433,12 +4638,39 @@ fn draw_input_dialog(f: &mut Frame, mode: &InputMode) { } lines.push(Line::from(ts_spans)); + // Timestamping mode help text + if *selected == 5 { + let ts_help = match timestamping_mode { + None => help_text::TS_DEFAULT, + Some(TimestampingMode::ClientPrefer) => help_text::TS_CLIENT_PREFER, + Some(TimestampingMode::ClientRequire) => help_text::TS_CLIENT_REQUIRE, + Some(TimestampingMode::Arrival) => help_text::TS_ARRIVAL, + }; + lines.push(Line::from(vec![ + Span::raw(" "), + Span::styled(ts_help, Style::default().fg(GRAY_600).italic()), + ])); + } + // Uncapped Timestamps let (ind, lbl) = render_field_row_bold(6, " Uncapped", *selected); let mut uncapped_spans = vec![ind, lbl, Span::raw(" ")]; uncapped_spans.extend(render_toggle(*timestamping_uncapped, *selected == 6)); lines.push(Line::from(uncapped_spans)); + // Uncapped help text + if *selected == 6 { + let uncapped_help = if *timestamping_uncapped { + help_text::TS_UNCAPPED + } else { + help_text::TS_CAPPED + }; + lines.push(Line::from(vec![ + Span::raw(" "), + Span::styled(uncapped_help, Style::default().fg(GRAY_600).italic()), + ])); + } + // Delete on Empty let delete_opts = [ ("Never", !*delete_on_empty_enabled), @@ -4453,6 +4685,19 @@ fn draw_input_dialog(f: &mut Frame, mode: &InputMode) { } lines.push(Line::from(del_spans)); + // Delete on empty help text + if *selected == 7 { + let del_help = if *delete_on_empty_enabled { + help_text::DELETE_THRESHOLD + } else { + help_text::DELETE_NEVER + }; + lines.push(Line::from(vec![ + Span::raw(" "), + Span::styled(del_help, Style::default().fg(GRAY_600).italic()), + ])); + } + // Delete on Empty Threshold (conditional) if *delete_on_empty_enabled { let (ind, lbl) = render_field_row_bold(8, " Threshold", *selected); @@ -4481,6 +4726,17 @@ fn draw_input_dialog(f: &mut Frame, mode: &InputMode) { append_spans.extend(render_toggle(*create_stream_on_append, *selected == 9)); lines.push(Line::from(append_spans)); + // On append help text + if *selected == 9 { + lines.push(Line::from(vec![ + Span::raw(" "), + Span::styled( + help_text::AUTO_CREATE_APPEND, + Style::default().fg(GRAY_600).italic(), + ), + ])); + } + // On Read lines.push(Line::from("")); let (ind, lbl) = render_field_row_bold(10, "On read", *selected); @@ -4488,6 +4744,17 @@ fn draw_input_dialog(f: &mut Frame, mode: &InputMode) { read_spans.extend(render_toggle(*create_stream_on_read, *selected == 10)); lines.push(Line::from(read_spans)); + // On read help text + if *selected == 10 { + lines.push(Line::from(vec![ + Span::raw(" "), + Span::styled( + help_text::AUTO_CREATE_READ, + Style::default().fg(GRAY_600).italic(), + ), + ])); + } + // Create button section lines.push(Line::from("")); lines.push(Line::from(vec![Span::styled( @@ -4609,6 +4876,19 @@ fn draw_input_dialog(f: &mut Frame, mode: &InputMode) { } lines.push(Line::from(storage_spans)); + // Storage class help text + if *selected == 1 { + let storage_help = match storage_class { + None => help_text::STORAGE_DEFAULT, + Some(StorageClass::Standard) => help_text::STORAGE_STANDARD, + Some(StorageClass::Express) => help_text::STORAGE_EXPRESS, + }; + lines.push(Line::from(vec![ + Span::raw(" "), + Span::styled(storage_help, Style::default().fg(GRAY_600).italic()), + ])); + } + lines.push(Line::from("")); let (ind, lbl) = render_field_row(2, "Retention", *selected); let mut ret_spans = vec![ind, lbl, Span::raw(" ")]; @@ -4618,6 +4898,18 @@ fn draw_input_dialog(f: &mut Frame, mode: &InputMode) { } lines.push(Line::from(ret_spans)); + // Retention help text + if *selected == 2 { + let ret_help = match retention_policy { + RetentionPolicyOption::Infinite => help_text::RETENTION_INFINITE, + RetentionPolicyOption::Age => help_text::RETENTION_AGE, + }; + lines.push(Line::from(vec![ + Span::raw(" "), + Span::styled(ret_help, Style::default().fg(GRAY_600).italic()), + ])); + } + if *retention_policy == RetentionPolicyOption::Age { let (ind, lbl) = render_field_row(3, " Duration", *selected); let mut duration_spans = vec![ind, lbl, Span::raw(" ")]; @@ -4644,12 +4936,39 @@ fn draw_input_dialog(f: &mut Frame, mode: &InputMode) { } lines.push(Line::from(ts_spans)); + // Timestamping mode help text + if *selected == 4 { + let ts_help = match timestamping_mode { + None => help_text::TS_DEFAULT, + Some(TimestampingMode::ClientPrefer) => help_text::TS_CLIENT_PREFER, + Some(TimestampingMode::ClientRequire) => help_text::TS_CLIENT_REQUIRE, + Some(TimestampingMode::Arrival) => help_text::TS_ARRIVAL, + }; + lines.push(Line::from(vec![ + Span::raw(" "), + Span::styled(ts_help, Style::default().fg(GRAY_600).italic()), + ])); + } + // Uncapped Timestamps let (ind, lbl) = render_field_row(5, " Uncapped", *selected); let mut uncapped_spans = vec![ind, lbl, Span::raw(" ")]; uncapped_spans.extend(render_toggle(*timestamping_uncapped, *selected == 5)); lines.push(Line::from(uncapped_spans)); + // Uncapped help text + if *selected == 5 { + let uncapped_help = if *timestamping_uncapped { + help_text::TS_UNCAPPED + } else { + help_text::TS_CAPPED + }; + lines.push(Line::from(vec![ + Span::raw(" "), + Span::styled(uncapped_help, Style::default().fg(GRAY_600).italic()), + ])); + } + // Delete on Empty let delete_opts = [ ("Never", !*delete_on_empty_enabled), @@ -4664,6 +4983,19 @@ fn draw_input_dialog(f: &mut Frame, mode: &InputMode) { } lines.push(Line::from(del_spans)); + // Delete on empty help text + if *selected == 6 { + let del_help = if *delete_on_empty_enabled { + help_text::DELETE_THRESHOLD + } else { + help_text::DELETE_NEVER + }; + lines.push(Line::from(vec![ + Span::raw(" "), + Span::styled(del_help, Style::default().fg(GRAY_600).italic()), + ])); + } + // Delete on Empty Threshold (conditional) if *delete_on_empty_enabled { let (ind, lbl) = render_field_row(7, " Threshold", *selected); @@ -4828,6 +5160,19 @@ fn draw_input_dialog(f: &mut Frame, mode: &InputMode) { } lines.push(Line::from(storage_spans)); + // Storage class help text + if *selected == 0 { + let storage_help = match storage_class { + None => help_text::STORAGE_DEFAULT, + Some(StorageClass::Standard) => help_text::STORAGE_STANDARD, + Some(StorageClass::Express) => help_text::STORAGE_EXPRESS, + }; + lines.push(Line::from(vec![ + Span::raw(" "), + Span::styled(storage_help, Style::default().fg(GRAY_600).italic()), + ])); + } + lines.push(Line::from("")); let (ind, lbl) = render_field_row_bold(1, "Retention", *selected); let mut ret_spans = vec![ind, lbl, Span::raw(" ")]; @@ -4837,6 +5182,18 @@ fn draw_input_dialog(f: &mut Frame, mode: &InputMode) { } lines.push(Line::from(ret_spans)); + // Retention help text + if *selected == 1 { + let ret_help = match retention_policy { + RetentionPolicyOption::Infinite => help_text::RETENTION_INFINITE, + RetentionPolicyOption::Age => help_text::RETENTION_AGE, + }; + lines.push(Line::from(vec![ + Span::raw(" "), + Span::styled(ret_help, Style::default().fg(GRAY_600).italic()), + ])); + } + if *retention_policy == RetentionPolicyOption::Age { let (ind, lbl) = render_field_row_bold(2, " Duration", *selected); let age_display = if *editing_age { @@ -4868,6 +5225,20 @@ fn draw_input_dialog(f: &mut Frame, mode: &InputMode) { } lines.push(Line::from(ts_spans)); + // Timestamping mode help text + if *selected == 3 { + let ts_help = match timestamping_mode { + None => help_text::TS_DEFAULT, + Some(TimestampingMode::ClientPrefer) => help_text::TS_CLIENT_PREFER, + Some(TimestampingMode::ClientRequire) => help_text::TS_CLIENT_REQUIRE, + Some(TimestampingMode::Arrival) => help_text::TS_ARRIVAL, + }; + lines.push(Line::from(vec![ + Span::raw(" "), + Span::styled(ts_help, Style::default().fg(GRAY_600).italic()), + ])); + } + // Uncapped Timestamps let (ind, lbl) = render_field_row_bold(4, " Uncapped", *selected); let mut uncapped_spans = vec![ind, lbl, Span::raw(" ")]; @@ -4877,6 +5248,19 @@ fn draw_input_dialog(f: &mut Frame, mode: &InputMode) { )); lines.push(Line::from(uncapped_spans)); + // Uncapped help text + if *selected == 4 { + let uncapped_help = if timestamping_uncapped.unwrap_or(false) { + help_text::TS_UNCAPPED + } else { + help_text::TS_CAPPED + }; + lines.push(Line::from(vec![ + Span::raw(" "), + Span::styled(uncapped_help, Style::default().fg(GRAY_600).italic()), + ])); + } + // Create streams automatically section lines.push(Line::from("")); lines.push(render_section_header("Create streams automatically", 48)); @@ -4891,6 +5275,17 @@ fn draw_input_dialog(f: &mut Frame, mode: &InputMode) { )); lines.push(Line::from(append_spans)); + // On append help text + if *selected == 5 { + lines.push(Line::from(vec![ + Span::raw(" "), + Span::styled( + help_text::AUTO_CREATE_APPEND, + Style::default().fg(GRAY_600).italic(), + ), + ])); + } + // On Read lines.push(Line::from("")); let (ind, lbl) = render_field_row_bold(6, "On read", *selected); @@ -4901,6 +5296,17 @@ fn draw_input_dialog(f: &mut Frame, mode: &InputMode) { )); lines.push(Line::from(read_spans)); + // On read help text + if *selected == 6 { + lines.push(Line::from(vec![ + Span::raw(" "), + Span::styled( + help_text::AUTO_CREATE_READ, + Style::default().fg(GRAY_600).italic(), + ), + ])); + } + lines.push(Line::from("")); ( @@ -4985,6 +5391,19 @@ fn draw_input_dialog(f: &mut Frame, mode: &InputMode) { } lines.push(Line::from(storage_spans)); + // Storage class help text + if *selected == 0 { + let storage_help = match storage_class { + None => help_text::STORAGE_DEFAULT, + Some(StorageClass::Standard) => help_text::STORAGE_STANDARD, + Some(StorageClass::Express) => help_text::STORAGE_EXPRESS, + }; + lines.push(Line::from(vec![ + Span::raw(" "), + Span::styled(storage_help, Style::default().fg(GRAY_600).italic()), + ])); + } + lines.push(Line::from("")); let (ind, lbl) = render_field_row(1, "Retention", *selected); let mut ret_spans = vec![ind, lbl, Span::raw(" ")]; @@ -4994,6 +5413,18 @@ fn draw_input_dialog(f: &mut Frame, mode: &InputMode) { } lines.push(Line::from(ret_spans)); + // Retention help text + if *selected == 1 { + let ret_help = match retention_policy { + RetentionPolicyOption::Infinite => help_text::RETENTION_INFINITE, + RetentionPolicyOption::Age => help_text::RETENTION_AGE, + }; + lines.push(Line::from(vec![ + Span::raw(" "), + Span::styled(ret_help, Style::default().fg(GRAY_600).italic()), + ])); + } + if *retention_policy == RetentionPolicyOption::Age { let (ind, lbl) = render_field_row(2, " Duration", *selected); let age_display = if *selected == 2 && *editing_age { @@ -5025,6 +5456,20 @@ fn draw_input_dialog(f: &mut Frame, mode: &InputMode) { } lines.push(Line::from(ts_spans)); + // Timestamping mode help text + if *selected == 3 { + let ts_help = match timestamping_mode { + None => help_text::TS_DEFAULT, + Some(TimestampingMode::ClientPrefer) => help_text::TS_CLIENT_PREFER, + Some(TimestampingMode::ClientRequire) => help_text::TS_CLIENT_REQUIRE, + Some(TimestampingMode::Arrival) => help_text::TS_ARRIVAL, + }; + lines.push(Line::from(vec![ + Span::raw(" "), + Span::styled(ts_help, Style::default().fg(GRAY_600).italic()), + ])); + } + // Uncapped Timestamps let (ind, lbl) = render_field_row(4, " Uncapped", *selected); let mut uncapped_spans = vec![ind, lbl, Span::raw(" ")]; @@ -5034,6 +5479,19 @@ fn draw_input_dialog(f: &mut Frame, mode: &InputMode) { )); lines.push(Line::from(uncapped_spans)); + // Uncapped help text + if *selected == 4 { + let uncapped_help = if timestamping_uncapped.unwrap_or(false) { + help_text::TS_UNCAPPED + } else { + help_text::TS_CAPPED + }; + lines.push(Line::from(vec![ + Span::raw(" "), + Span::styled(uncapped_help, Style::default().fg(GRAY_600).italic()), + ])); + } + // Delete on Empty let delete_opts = [ ("Never", !*delete_on_empty_enabled), @@ -5048,6 +5506,19 @@ fn draw_input_dialog(f: &mut Frame, mode: &InputMode) { } lines.push(Line::from(del_spans)); + // Delete on empty help text + if *selected == 5 { + let del_help = if *delete_on_empty_enabled { + help_text::DELETE_THRESHOLD + } else { + help_text::DELETE_NEVER + }; + lines.push(Line::from(vec![ + Span::raw(" "), + Span::styled(del_help, Style::default().fg(GRAY_600).italic()), + ])); + } + // Delete on Empty Threshold (conditional) if *delete_on_empty_enabled { let (ind, lbl) = render_field_row(6, " Threshold", *selected); @@ -5256,6 +5727,19 @@ fn draw_input_dialog(f: &mut Frame, mode: &InputMode) { clamp_spans.extend(render_toggle(*clamp, *selected == 7)); lines.push(Line::from(clamp_spans)); + // Clamp help text + if *selected == 7 { + let clamp_help = if *clamp { + help_text::CLAMP_ON + } else { + help_text::CLAMP_OFF + }; + lines.push(Line::from(vec![ + Span::raw(" "), + Span::styled(clamp_help, Style::default().fg(GRAY_600).italic()), + ])); + } + // Row 8: Format let (ind, lbl) = render_field_row(8, "Format", *selected); let mut format_spans = vec![ind, lbl, Span::raw(" ")]; @@ -5265,6 +5749,17 @@ fn draw_input_dialog(f: &mut Frame, mode: &InputMode) { } lines.push(Line::from(format_spans)); + // Format help text - shows description of currently selected format + let format_help = match format.as_str() { + "text" => help_text::FORMAT_TEXT, + "json" => help_text::FORMAT_JSON, + _ => help_text::FORMAT_JSON_BASE64, + }; + lines.push(Line::from(vec![ + Span::raw(" "), + Span::styled(format_help, Style::default().fg(GRAY_600).italic()), + ])); + // Row 9: Output file lines.push(Line::from("")); let (ind, lbl) = render_field_row(9, "Output file", *selected); @@ -5333,6 +5828,17 @@ fn draw_input_dialog(f: &mut Frame, mode: &InputMode) { )); lines.push(Line::from(new_spans)); + // New token help text + if *selected == 0 { + lines.push(Line::from(vec![ + Span::raw(" "), + Span::styled( + help_text::FENCE_TOKEN, + Style::default().fg(GRAY_600).italic(), + ), + ])); + } + // Row 1: Current token lines.push(Line::from("")); let (ind, lbl) = render_field_row_bold(1, "Current", *selected); @@ -5345,6 +5851,17 @@ fn draw_input_dialog(f: &mut Frame, mode: &InputMode) { )); lines.push(Line::from(cur_spans)); + // Current token help text + if *selected == 1 { + lines.push(Line::from(vec![ + Span::raw(" "), + Span::styled( + help_text::FENCE_CURRENT, + Style::default().fg(GRAY_600).italic(), + ), + ])); + } + // Divider and button lines.push(Line::from("")); lines.push(Line::from(vec![Span::styled( @@ -5410,6 +5927,17 @@ fn draw_input_dialog(f: &mut Frame, mode: &InputMode) { )); lines.push(Line::from(trim_spans)); + // Trim point help text + if *selected == 0 { + lines.push(Line::from(vec![ + Span::raw(" "), + Span::styled( + help_text::TRIM_SEQ_NUM, + Style::default().fg(GRAY_600).italic(), + ), + ])); + } + // Row 1: Fencing token lines.push(Line::from("")); let (ind, lbl) = render_field_row_bold(1, "Fence Token", *selected); @@ -5422,6 +5950,17 @@ fn draw_input_dialog(f: &mut Frame, mode: &InputMode) { )); lines.push(Line::from(fence_spans)); + // Fence token help text + if *selected == 1 { + lines.push(Line::from(vec![ + Span::raw(" "), + Span::styled( + help_text::APPEND_FENCING, + Style::default().fg(GRAY_600).italic(), + ), + ])); + } + // Divider and button lines.push(Line::from("")); lines.push(Line::from(vec![Span::styled( @@ -5508,6 +6047,17 @@ fn draw_input_dialog(f: &mut Frame, mode: &InputMode) { } lines.push(Line::from(expiry_spans)); + // Expiration help text + if *selected == 1 { + lines.push(Line::from(vec![ + Span::raw(" "), + Span::styled( + help_text::TOKEN_EXPIRY, + Style::default().fg(GRAY_600).italic(), + ), + ])); + } + // Row 2: Custom expiration (only if Custom selected) if *expiry == ExpiryOption::Custom { let (ind, lbl) = render_field_row_bold(2, " Duration", *selected); @@ -5854,9 +6404,53 @@ fn draw_input_dialog(f: &mut Frame, mode: &InputMode) { f.render_widget(Clear, area); - let dialog = Paragraph::new(content).block(block); + // Calculate scroll offset to keep selected item visible + // Inner height = area height - borders (2) - padding (0) - hint line (1) + let inner_height = chunks[0].height.saturating_sub(4) as usize; + let content_height = content.len(); + let scroll_offset = if content_height > inner_height { + // Find the selected field's approximate line position + let selected_line = get_selected_line_hint(mode); + // Scroll so selected item is in the middle-ish of visible area + let half_visible = inner_height / 2; + if selected_line > half_visible { + (selected_line - half_visible).min(content_height.saturating_sub(inner_height)) + } else { + 0 + } + } else { + 0 + }; + + let dialog = Paragraph::new(content.clone()) + .block(block) + .wrap(ratatui::widgets::Wrap { trim: false }) + .scroll((scroll_offset as u16, 0)); f.render_widget(dialog, chunks[0]); + if content_height > inner_height { + let inner_area = chunks[0].inner(Margin::new(1, 1)); + if scroll_offset > 0 { + let up_indicator = Paragraph::new("▲") + .style(Style::default().fg(GRAY_600)) + .alignment(Alignment::Right); + let up_area = Rect::new(inner_area.x, inner_area.y, inner_area.width, 1); + f.render_widget(up_indicator, up_area); + } + if scroll_offset + inner_height < content_height { + let down_indicator = Paragraph::new("▼") + .style(Style::default().fg(GRAY_600)) + .alignment(Alignment::Right); + let down_area = Rect::new( + inner_area.x, + inner_area.y + inner_area.height.saturating_sub(1), + inner_area.width, + 1, + ); + f.render_widget(down_indicator, down_area); + } + } + // Parse and render hint with highlighted keys for better accessibility let hint_spans = render_hint_with_keys(hint); let hint_line = Line::from(hint_spans); From 2950355acfbe041c69f01aa6efe79d4fd3e5f8c6 Mon Sep 17 00:00:00 2001 From: Mehul Arora Date: Wed, 28 Jan 2026 13:15:39 -0500 Subject: [PATCH 31/31] . --- src/tui/app.rs | 36 ++++++++++++++++++------------------ src/tui/ui.rs | 42 +++++++++++++++++++++++++----------------- 2 files changed, 43 insertions(+), 35 deletions(-) diff --git a/src/tui/app.rs b/src/tui/app.rs index e664645..2b9c756 100644 --- a/src/tui/app.rs +++ b/src/tui/app.rs @@ -134,8 +134,8 @@ pub struct ReadViewState { pub hide_list: bool, pub output_file: Option, // Throughput tracking for live sparklines - pub throughput_history: Vec, // MiB/s samples - pub records_per_sec_history: Vec, // records/s samples + pub throughput_history: VecDeque, // MiB/s samples + pub records_per_sec_history: VecDeque, // records/s samples pub current_mibps: f64, pub current_recps: f64, pub bytes_this_second: u64, @@ -539,12 +539,12 @@ pub struct BenchViewState { pub write_recps: f64, pub write_bytes: u64, pub write_records: u64, - pub write_history: Vec, + pub write_history: VecDeque, pub read_mibps: f64, pub read_recps: f64, pub read_bytes: u64, pub read_records: u64, - pub read_history: Vec, + pub read_history: VecDeque, pub catchup_mibps: f64, pub catchup_recps: f64, pub catchup_bytes: u64, @@ -576,12 +576,12 @@ impl BenchViewState { write_recps: 0.0, write_bytes: 0, write_records: 0, - write_history: Vec::new(), + write_history: VecDeque::new(), read_mibps: 0.0, read_recps: 0.0, read_bytes: 0, read_records: 0, - read_history: Vec::new(), + read_history: VecDeque::new(), catchup_mibps: 0.0, catchup_recps: 0.0, catchup_bytes: 0, @@ -1406,16 +1406,16 @@ impl App { state.current_mibps = mibps; state.current_recps = recps; - state.throughput_history.push(mibps); - state.records_per_sec_history.push(recps); + state.throughput_history.push_back(mibps); + state.records_per_sec_history.push_back(recps); if state.throughput_history.len() > MAX_THROUGHPUT_HISTORY { - state.throughput_history.remove(0); + state.throughput_history.pop_front(); } if state.records_per_sec_history.len() > MAX_THROUGHPUT_HISTORY { - state.records_per_sec_history.remove(0); + state.records_per_sec_history.pop_front(); } state.bytes_this_second = 0; @@ -1987,9 +1987,9 @@ impl App { state.progress_pct = ((state.elapsed_secs / state.duration_secs as f64) * 100.0).min(100.0); - state.write_history.push(sample.mib_per_sec); + state.write_history.push_back(sample.mib_per_sec); if state.write_history.len() > 60 { - state.write_history.remove(0); + state.write_history.pop_front(); } } } @@ -2002,9 +2002,9 @@ impl App { state.read_records = sample.records; state.elapsed_secs = sample.elapsed.as_secs_f64(); - state.read_history.push(sample.mib_per_sec); + state.read_history.push_back(sample.mib_per_sec); if state.read_history.len() > 60 { - state.read_history.remove(0); + state.read_history.pop_front(); } } } @@ -4093,8 +4093,8 @@ impl App { show_detail: false, hide_list: false, output_file: None, - throughput_history: Vec::new(), - records_per_sec_history: Vec::new(), + throughput_history: VecDeque::new(), + records_per_sec_history: VecDeque::new(), current_mibps: 0.0, current_recps: 0.0, bytes_this_second: 0, @@ -4284,8 +4284,8 @@ impl App { } else { None }, - throughput_history: Vec::new(), - records_per_sec_history: Vec::new(), + throughput_history: VecDeque::new(), + records_per_sec_history: VecDeque::new(), current_mibps: 0.0, current_recps: 0.0, bytes_this_second: 0, diff --git a/src/tui/ui.rs b/src/tui/ui.rs index 5c1eb5e..2e209eb 100644 --- a/src/tui/ui.rs +++ b/src/tui/ui.rs @@ -1,3 +1,5 @@ +use std::collections::VecDeque; + use ratatui::{ Frame, layout::{Alignment, Constraint, Direction, Layout, Margin, Rect}, @@ -3112,14 +3114,15 @@ fn draw_read_view(f: &mut Frame, area: Rect, state: &ReadViewState) { f.render_widget(Paragraph::new(line), row_area); } - // Vertical separator + // Vertical separator - single widget instead of per-row loop let sep_x = panes[1].x.saturating_sub(1); - for y in 0..inner_area.height { - f.render_widget( - Paragraph::new(Span::styled("│", Style::default().fg(BORDER))), - Rect::new(sep_x, inner_area.y + y, 1, 1), - ); - } + let sep_lines: Vec = (0..inner_area.height) + .map(|_| Line::from(Span::styled("│", Style::default().fg(BORDER)))) + .collect(); + f.render_widget( + Paragraph::new(sep_lines), + Rect::new(sep_x, inner_area.y, 1, inner_area.height), + ); panes[1] }; @@ -3350,8 +3353,12 @@ fn draw_headers_popup(f: &mut Frame, record: &s2_sdk::types::SequencedRecord) { } else { record.headers.len() }; - let height = (content_lines + 5).min(20) as u16; - let area = centered_rect(50, height * 100 / f.area().height.max(1), f.area()); + // Compact sizing: 5 lines overhead (title, record#, spacing, border) + content + let height = ((content_lines + 5) as u16).min(20).min(f.area().height); + let width = 45_u16.min(f.area().width); + let x = f.area().x + f.area().width.saturating_sub(width) / 2; + let y = f.area().y + f.area().height.saturating_sub(height) / 2; + let area = Rect::new(x, y, width, height); let mut lines = vec![ Line::from(""), @@ -6740,6 +6747,7 @@ fn draw_bench_running(f: &mut Frame, area: Rect, state: &BenchViewState) { .alignment(Alignment::Center); f.render_widget(wait_text, wait_inner); } else { + let empty_history = VecDeque::new(); draw_bench_stat_box( f, chunks[3], @@ -6749,7 +6757,7 @@ fn draw_bench_running(f: &mut Frame, area: Rect, state: &BenchViewState) { state.catchup_recps, state.catchup_bytes, state.catchup_records, - &[], + &empty_history, ); } @@ -6787,7 +6795,7 @@ fn draw_bench_stat_box( recps: f64, bytes: u64, records: u64, - history: &[f64], + history: &VecDeque, ) { let block = Block::default() .title(Line::from(Span::styled( @@ -6842,8 +6850,8 @@ fn draw_bench_stat_box( fn draw_throughput_sparklines( f: &mut Frame, area: Rect, - write_history: &[f64], - read_history: &[f64], + write_history: &VecDeque, + read_history: &VecDeque, ) { let block = Block::default() .title(Line::from(Span::styled( @@ -6967,8 +6975,8 @@ fn draw_latency_box( fn draw_tail_sparklines( f: &mut Frame, area: Rect, - throughput_history: &[f64], - records_history: &[f64], + throughput_history: &VecDeque, + records_history: &VecDeque, ) { let chunks = Layout::default() .direction(Direction::Horizontal) @@ -6988,7 +6996,7 @@ fn draw_tail_sparklines( .map(|v| ((v / max_val) * 100.0) as u64) .collect(); - let current = throughput_history.last().copied().unwrap_or(0.0); + let current = throughput_history.back().copied().unwrap_or(0.0); let block = Block::default() .title(Line::from(vec![ Span::styled(" ▲ ", Style::default().fg(CYAN)), @@ -7017,7 +7025,7 @@ fn draw_tail_sparklines( .map(|v| ((v / max_val) * 100.0) as u64) .collect(); - let current = records_history.last().copied().unwrap_or(0.0); + let current = records_history.back().copied().unwrap_or(0.0); let block = Block::default() .title(Line::from(vec![ Span::styled(" ◆ ", Style::default().fg(GREEN)),