From 18e22b2207c29975c62d81cd87d38be44308facc Mon Sep 17 00:00:00 2001 From: Nino Date: Mon, 13 Apr 2026 22:17:10 -0600 Subject: [PATCH] first commit --- Cargo.lock | 1736 ++++++++++++++++++++++++++++++++++++++++++++++++++ Cargo.toml | 9 + src/app.rs | 515 +++++++++++++++ src/main.rs | 93 +++ src/netio.rs | 94 +++ src/pools.rs | 100 +++ src/procs.rs | 49 ++ src/smart.rs | 304 +++++++++ src/ui.rs | 656 +++++++++++++++++++ src/zfs.rs | 44 ++ 10 files changed, 3600 insertions(+) create mode 100644 Cargo.lock create mode 100644 Cargo.toml create mode 100644 src/app.rs create mode 100644 src/main.rs create mode 100644 src/netio.rs create mode 100644 src/pools.rs create mode 100644 src/procs.rs create mode 100644 src/smart.rs create mode 100644 src/ui.rs create mode 100644 src/zfs.rs diff --git a/Cargo.lock b/Cargo.lock new file mode 100644 index 0000000..9ca6200 --- /dev/null +++ b/Cargo.lock @@ -0,0 +1,1736 @@ +# This file is automatically @generated by Cargo. +# It is not intended for manual editing. +version = 4 + +[[package]] +name = "aho-corasick" +version = "1.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ddd31a130427c27518df266943a5308ed92d4b226cc639f5a8f1002816174301" +dependencies = [ + "memchr", +] + +[[package]] +name = "allocator-api2" +version = "0.2.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "683d7910e743518b0e34f1186f92494becacb047c7b6bf616c96772180fef923" + +[[package]] +name = "anyhow" +version = "1.0.102" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7f202df86484c868dbad7eaa557ef785d5c66295e41b460ef922eca0723b842c" + +[[package]] +name = "atomic" +version = "0.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a89cbf775b137e9b968e67227ef7f775587cde3fd31b0d8599dbd0f598a48340" +dependencies = [ + "bytemuck", +] + +[[package]] +name = "autocfg" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c08606f8c3cbf4ce6ec8e28fb0014a2c086708fe954eaa885384a6165172e7e8" + +[[package]] +name = "base64" +version = "0.22.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "72b3254f16251a8381aa12e40e3c4d2f0199f8c6508fbecb9d91f575e0fbb8c6" + +[[package]] +name = "bit-set" +version = "0.5.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0700ddab506f33b20a03b13996eccd309a48e5ff77d0d95926aa0210fb4e95f1" +dependencies = [ + "bit-vec", +] + +[[package]] +name = "bit-vec" +version = "0.6.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "349f9b6a179ed607305526ca489b34ad0a41aed5f7980fa90eb03160b69598fb" + +[[package]] +name = "bitflags" +version = "1.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bef38d45163c2f1dde094a7dfd33ccf595c92905c8f8f4fdc18d06fb1037718a" + +[[package]] +name = "bitflags" +version = "2.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "843867be96c8daad0d758b57df9392b6d8d271134fce549de6ce169ff98a92af" + +[[package]] +name = "block-buffer" +version = "0.10.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3078c7629b62d3f0439517fa394996acacc5cbc91c5a20d8c658e77abd503a71" +dependencies = [ + "generic-array", +] + +[[package]] +name = "bumpalo" +version = "3.20.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5d20789868f4b01b2f2caec9f5c4e0213b41e3e5702a50157d699ae31ced2fcb" + +[[package]] +name = "bytemuck" +version = "1.25.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c8efb64bd706a16a1bdde310ae86b351e4d21550d98d056f22f8a7f7a2183fec" + +[[package]] +name = "castaway" +version = "0.2.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dec551ab6e7578819132c713a93c022a05d60159dc86e7a7050223577484c55a" +dependencies = [ + "rustversion", +] + +[[package]] +name = "cfg-if" +version = "1.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9330f8b2ff13f34540b44e946ef35111825727b38d33286ef986142615121801" + +[[package]] +name = "cfg_aliases" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "613afe47fcd5fac7ccf1db93babcb082c5994d996f20b8b159f2ad1658eb5724" + +[[package]] +name = "compact_str" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3fdb1325a1cece981e8a296ab8f0f9b63ae357bd0784a9faaf548cc7b480707a" +dependencies = [ + "castaway", + "cfg-if", + "itoa", + "rustversion", + "ryu", + "static_assertions", +] + +[[package]] +name = "convert_case" +version = "0.10.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "633458d4ef8c78b72454de2d54fd6ab2e60f9e02be22f3c6104cdc8a4e0fceb9" +dependencies = [ + "unicode-segmentation", +] + +[[package]] +name = "cpufeatures" +version = "0.2.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "59ed5838eebb26a2bb2e58f6d5b5316989ae9d08bab10e0e6d103e656d1b0280" +dependencies = [ + "libc", +] + +[[package]] +name = "crossterm" +version = "0.29.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d8b9f2e4c67f833b660cdb0a3523065869fb35570177239812ed4c905aeff87b" +dependencies = [ + "bitflags 2.11.0", + "crossterm_winapi", + "derive_more", + "document-features", + "mio", + "parking_lot", + "rustix", + "signal-hook", + "signal-hook-mio", + "winapi", +] + +[[package]] +name = "crossterm_winapi" +version = "0.9.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "acdd7c62a3665c7f6830a51635d9ac9b23ed385797f70a83bb8bafe9c572ab2b" +dependencies = [ + "winapi", +] + +[[package]] +name = "crypto-common" +version = "0.1.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "78c8292055d1c1df0cce5d180393dc8cce0abec0a7102adb6c7b1eef6016d60a" +dependencies = [ + "generic-array", + "typenum", +] + +[[package]] +name = "csscolorparser" +version = "0.6.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "eb2a7d3066da2de787b7f032c736763eb7ae5d355f81a68bab2675a96008b0bf" +dependencies = [ + "lab", + "phf", +] + +[[package]] +name = "darling" +version = "0.23.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "25ae13da2f202d56bd7f91c25fba009e7717a1e4a1cc98a76d844b65ae912e9d" +dependencies = [ + "darling_core", + "darling_macro", +] + +[[package]] +name = "darling_core" +version = "0.23.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9865a50f7c335f53564bb694ef660825eb8610e0a53d3e11bf1b0d3df31e03b0" +dependencies = [ + "ident_case", + "proc-macro2", + "quote", + "strsim", + "syn 2.0.117", +] + +[[package]] +name = "darling_macro" +version = "0.23.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ac3984ec7bd6cfa798e62b4a642426a5be0e68f9401cfc2a01e3fa9ea2fcdb8d" +dependencies = [ + "darling_core", + "quote", + "syn 2.0.117", +] + +[[package]] +name = "deltae" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5729f5117e208430e437df2f4843f5e5952997175992d1414f94c57d61e270b4" + +[[package]] +name = "deranged" +version = "0.5.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7cd812cc2bc1d69d4764bd80df88b4317eaef9e773c75226407d9bc0876b211c" +dependencies = [ + "powerfmt", +] + +[[package]] +name = "derive_more" +version = "2.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d751e9e49156b02b44f9c1815bcb94b984cdcc4396ecc32521c739452808b134" +dependencies = [ + "derive_more-impl", +] + +[[package]] +name = "derive_more-impl" +version = "2.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "799a97264921d8623a957f6c3b9011f3b5492f557bbb7a5a19b7fa6d06ba8dcb" +dependencies = [ + "convert_case", + "proc-macro2", + "quote", + "rustc_version", + "syn 2.0.117", +] + +[[package]] +name = "digest" +version = "0.10.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9ed9a281f7bc9b7576e61468ba615a66a5c8cfdff42420a70aa82701a3b1e292" +dependencies = [ + "block-buffer", + "crypto-common", +] + +[[package]] +name = "document-features" +version = "0.2.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d4b8a88685455ed29a21542a33abd9cb6510b6b129abadabdcef0f4c55bc8f61" +dependencies = [ + "litrs", +] + +[[package]] +name = "either" +version = "1.15.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "48c757948c5ede0e46177b7add2e67155f70e33c07fea8284df6576da70b3719" + +[[package]] +name = "equivalent" +version = "1.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "877a4ace8713b0bcf2a4e7eec82529c029f1d0619886d18145fea96c3ffe5c0f" + +[[package]] +name = "errno" +version = "0.3.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "39cab71617ae0d63f51a36d69f866391735b51691dbda63cf6f96d042b63efeb" +dependencies = [ + "libc", + "windows-sys", +] + +[[package]] +name = "euclid" +version = "0.22.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f1a05365e3b1c6d1650318537c7460c6923f1abdd272ad6842baa2b509957a06" +dependencies = [ + "num-traits", +] + +[[package]] +name = "fancy-regex" +version = "0.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b95f7c0680e4142284cf8b22c14a476e87d61b004a3a0861872b32ef7ead40a2" +dependencies = [ + "bit-set", + "regex", +] + +[[package]] +name = "filedescriptor" +version = "0.8.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e40758ed24c9b2eeb76c35fb0aebc66c626084edd827e07e1552279814c6682d" +dependencies = [ + "libc", + "thiserror 1.0.69", + "winapi", +] + +[[package]] +name = "finl_unicode" +version = "1.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9844ddc3a6e533d62bba727eb6c28b5d360921d5175e9ff0f1e621a5c590a4d5" + +[[package]] +name = "fixedbitset" +version = "0.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0ce7134b9999ecaf8bcd65542e436736ef32ddca1b3e06094cb6ec5755203b80" + +[[package]] +name = "fnv" +version = "1.0.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3f9eec918d3f24069decb9af1554cad7c880e2da24a9afd88aca000531ab82c1" + +[[package]] +name = "foldhash" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d9c4f5dac5e15c24eb999c26181a6ca40b39fe946cbe4c263c7209467bc83af2" + +[[package]] +name = "foldhash" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "77ce24cb58228fbb8aa041425bb1050850ac19177686ea6e0f41a70416f56fdb" + +[[package]] +name = "generic-array" +version = "0.14.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "85649ca51fd72272d7821adaf274ad91c288277713d9c18820d8499a7ff69e9a" +dependencies = [ + "typenum", + "version_check", +] + +[[package]] +name = "getrandom" +version = "0.3.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "899def5c37c4fd7b2664648c28120ecec138e4d395b459e5ca34f9cce2dd77fd" +dependencies = [ + "cfg-if", + "libc", + "r-efi 5.3.0", + "wasip2", +] + +[[package]] +name = "getrandom" +version = "0.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0de51e6874e94e7bf76d726fc5d13ba782deca734ff60d5bb2fb2607c7406555" +dependencies = [ + "cfg-if", + "libc", + "r-efi 6.0.0", + "wasip2", + "wasip3", +] + +[[package]] +name = "hashbrown" +version = "0.15.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9229cfe53dfd69f0609a49f65461bd93001ea1ef889cd5529dd176593f5338a1" +dependencies = [ + "foldhash 0.1.5", +] + +[[package]] +name = "hashbrown" +version = "0.16.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "841d1cc9bed7f9236f321df977030373f4a4163ae1a7dbfe1a51a2c1a51d9100" +dependencies = [ + "allocator-api2", + "equivalent", + "foldhash 0.2.0", +] + +[[package]] +name = "hashbrown" +version = "0.17.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4f467dd6dccf739c208452f8014c75c18bb8301b050ad1cfb27153803edb0f51" + +[[package]] +name = "heck" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2304e00983f87ffb38b55b444b5e3b60a884b5d30c0fca7d82fe33449bbe55ea" + +[[package]] +name = "hex" +version = "0.4.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7f24254aa9a54b5c858eaee2f5bccdb46aaf0e486a595ed5fd8f86ba55232a70" + +[[package]] +name = "id-arena" +version = "2.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3d3067d79b975e8844ca9eb072e16b31c3c1c36928edf9c6789548c524d0d954" + +[[package]] +name = "ident_case" +version = "1.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b9e0384b61958566e926dc50660321d12159025e767c18e043daf26b70104c39" + +[[package]] +name = "indexmap" +version = "2.14.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d466e9454f08e4a911e14806c24e16fba1b4c121d1ea474396f396069cf949d9" +dependencies = [ + "equivalent", + "hashbrown 0.17.0", + "serde", + "serde_core", +] + +[[package]] +name = "indoc" +version = "2.0.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "79cf5c93f93228cf8efb3ba362535fb11199ac548a09ce117c9b1adc3030d706" +dependencies = [ + "rustversion", +] + +[[package]] +name = "instability" +version = "0.3.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5eb2d60ef19920a3a9193c3e371f726ec1dafc045dac788d0fb3704272458971" +dependencies = [ + "darling", + "indoc", + "proc-macro2", + "quote", + "syn 2.0.117", +] + +[[package]] +name = "itertools" +version = "0.14.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2b192c782037fadd9cfa75548310488aabdbf3d2da73885b31bd0abd03351285" +dependencies = [ + "either", +] + +[[package]] +name = "itoa" +version = "1.0.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8f42a60cbdf9a97f5d2305f08a87dc4e09308d1276d28c869c684d7777685682" + +[[package]] +name = "js-sys" +version = "0.3.95" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2964e92d1d9dc3364cae4d718d93f227e3abb088e747d92e0395bfdedf1c12ca" +dependencies = [ + "once_cell", + "wasm-bindgen", +] + +[[package]] +name = "kasuari" +version = "0.4.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bde5057d6143cc94e861d90f591b9303d6716c6b9602309150bd068853c10899" +dependencies = [ + "hashbrown 0.16.1", + "portable-atomic", + "thiserror 2.0.18", +] + +[[package]] +name = "lab" +version = "0.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bf36173d4167ed999940f804952e6b08197cae5ad5d572eb4db150ce8ad5d58f" + +[[package]] +name = "lazy_static" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bbd2bcb4c963f2ddae06a2efc7e9f3591312473c50c6685e1f298068316e66fe" + +[[package]] +name = "leb128fmt" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "09edd9e8b54e49e587e4f6295a7d29c3ea94d469cb40ab8ca70b288248a81db2" + +[[package]] +name = "libc" +version = "0.2.185" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "52ff2c0fe9bc6cb6b14a0592c2ff4fa9ceb83eea9db979b0487cd054946a2b8f" + +[[package]] +name = "line-clipping" +version = "0.3.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3f50e8f47623268b5407192d26876c4d7f89d686ca130fdc53bced4814cd29f8" +dependencies = [ + "bitflags 2.11.0", +] + +[[package]] +name = "linux-raw-sys" +version = "0.12.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32a66949e030da00e8c7d4434b251670a91556f4144941d37452769c25d58a53" + +[[package]] +name = "litrs" +version = "1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "11d3d7f243d5c5a8b9bb5d6dd2b1602c0cb0b9db1621bafc7ed66e35ff9fe092" + +[[package]] +name = "lock_api" +version = "0.4.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "224399e74b87b5f3557511d98dff8b14089b3dadafcab6bb93eab67d3aace965" +dependencies = [ + "scopeguard", +] + +[[package]] +name = "log" +version = "0.4.29" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5e5032e24019045c762d3c0f28f5b6b8bbf38563a65908389bf7978758920897" + +[[package]] +name = "lru" +version = "0.16.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7f66e8d5d03f609abc3a39e6f08e4164ebf1447a732906d39eb9b99b7919ef39" +dependencies = [ + "hashbrown 0.16.1", +] + +[[package]] +name = "mac_address" +version = "1.1.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c0aeb26bf5e836cc1c341c8106051b573f1766dfa05aa87f0b98be5e51b02303" +dependencies = [ + "nix", + "winapi", +] + +[[package]] +name = "memchr" +version = "2.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f8ca58f447f06ed17d5fc4043ce1b10dd205e060fb3ce5b979b8ed8e59ff3f79" + +[[package]] +name = "memmem" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a64a92489e2744ce060c349162be1c5f33c6969234104dbd99ddb5feb08b8c15" + +[[package]] +name = "memoffset" +version = "0.9.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "488016bfae457b036d996092f6cb448677611ce4449e970ceaf42695203f218a" +dependencies = [ + "autocfg", +] + +[[package]] +name = "minimal-lexical" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "68354c5c6bd36d73ff3feceb05efa59b6acb7626617f4962be322a825e61f79a" + +[[package]] +name = "mio" +version = "1.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "50b7e5b27aa02a74bac8c3f23f448f8d87ff11f92d3aac1a6ed369ee08cc56c1" +dependencies = [ + "libc", + "log", + "wasi", + "windows-sys", +] + +[[package]] +name = "nix" +version = "0.29.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "71e2746dc3a24dd78b3cfcb7be93368c6de9963d30f43a6a73998a9cf4b17b46" +dependencies = [ + "bitflags 2.11.0", + "cfg-if", + "cfg_aliases", + "libc", + "memoffset", +] + +[[package]] +name = "nom" +version = "7.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d273983c5a657a70a3e8f2a01329822f3b8c8172b73826411a55751e404a0a4a" +dependencies = [ + "memchr", + "minimal-lexical", +] + +[[package]] +name = "num-conv" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c6673768db2d862beb9b39a78fdcb1a69439615d5794a1be50caa9bc92c81967" + +[[package]] +name = "num-derive" +version = "0.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ed3955f1a9c7c0c15e092f9c887db08b1fc683305fdf6eb6684f22555355e202" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.117", +] + +[[package]] +name = "num-traits" +version = "0.2.19" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "071dfc062690e90b734c0b2273ce72ad0ffa95f0c74596bc250dcfd960262841" +dependencies = [ + "autocfg", +] + +[[package]] +name = "num_threads" +version = "0.1.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5c7398b9c8b70908f6371f47ed36737907c87c52af34c268fed0bf0ceb92ead9" +dependencies = [ + "libc", +] + +[[package]] +name = "once_cell" +version = "1.21.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9f7c3e4beb33f85d45ae3e3a1792185706c8e16d043238c593331cc7cd313b50" + +[[package]] +name = "ordered-float" +version = "4.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7bb71e1b3fa6ca1c61f383464aaf2bb0e2f8e772a1f01d486832464de363b951" +dependencies = [ + "num-traits", +] + +[[package]] +name = "parking_lot" +version = "0.12.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "93857453250e3077bd71ff98b6a65ea6621a19bb0f559a85248955ac12c45a1a" +dependencies = [ + "lock_api", + "parking_lot_core", +] + +[[package]] +name = "parking_lot_core" +version = "0.9.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2621685985a2ebf1c516881c026032ac7deafcda1a2c9b7850dc81e3dfcb64c1" +dependencies = [ + "cfg-if", + "libc", + "redox_syscall", + "smallvec", + "windows-link", +] + +[[package]] +name = "pest" +version = "2.8.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e0848c601009d37dfa3430c4666e147e49cdcf1b92ecd3e63657d8a5f19da662" +dependencies = [ + "memchr", + "ucd-trie", +] + +[[package]] +name = "pest_derive" +version = "2.8.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "11f486f1ea21e6c10ed15d5a7c77165d0ee443402f0780849d1768e7d9d6fe77" +dependencies = [ + "pest", + "pest_generator", +] + +[[package]] +name = "pest_generator" +version = "2.8.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8040c4647b13b210a963c1ed407c1ff4fdfa01c31d6d2a098218702e6664f94f" +dependencies = [ + "pest", + "pest_meta", + "proc-macro2", + "quote", + "syn 2.0.117", +] + +[[package]] +name = "pest_meta" +version = "2.8.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "89815c69d36021a140146f26659a81d6c2afa33d216d736dd4be5381a7362220" +dependencies = [ + "pest", + "sha2", +] + +[[package]] +name = "phf" +version = "0.11.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1fd6780a80ae0c52cc120a26a1a42c1ae51b247a253e4e06113d23d2c2edd078" +dependencies = [ + "phf_macros", + "phf_shared", +] + +[[package]] +name = "phf_codegen" +version = "0.11.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "aef8048c789fa5e851558d709946d6d79a8ff88c0440c587967f8e94bfb1216a" +dependencies = [ + "phf_generator", + "phf_shared", +] + +[[package]] +name = "phf_generator" +version = "0.11.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3c80231409c20246a13fddb31776fb942c38553c51e871f8cbd687a4cfb5843d" +dependencies = [ + "phf_shared", + "rand", +] + +[[package]] +name = "phf_macros" +version = "0.11.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f84ac04429c13a7ff43785d75ad27569f2951ce0ffd30a3321230db2fc727216" +dependencies = [ + "phf_generator", + "phf_shared", + "proc-macro2", + "quote", + "syn 2.0.117", +] + +[[package]] +name = "phf_shared" +version = "0.11.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "67eabc2ef2a60eb7faa00097bd1ffdb5bd28e62bf39990626a582201b7a754e5" +dependencies = [ + "siphasher", +] + +[[package]] +name = "portable-atomic" +version = "1.13.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c33a9471896f1c69cecef8d20cbe2f7accd12527ce60845ff44c153bb2a21b49" + +[[package]] +name = "powerfmt" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "439ee305def115ba05938db6eb1644ff94165c5ab5e9420d1c1bcedbba909391" + +[[package]] +name = "prettyplease" +version = "0.2.37" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "479ca8adacdd7ce8f1fb39ce9ecccbfe93a3f1344b3d0d97f20bc0196208f62b" +dependencies = [ + "proc-macro2", + "syn 2.0.117", +] + +[[package]] +name = "proc-macro2" +version = "1.0.106" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8fd00f0bb2e90d81d1044c2b32617f68fcb9fa3bb7640c23e9c748e53fb30934" +dependencies = [ + "unicode-ident", +] + +[[package]] +name = "quote" +version = "1.0.45" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "41f2619966050689382d2b44f664f4bc593e129785a36d6ee376ddf37259b924" +dependencies = [ + "proc-macro2", +] + +[[package]] +name = "r-efi" +version = "5.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "69cdb34c158ceb288df11e18b4bd39de994f6657d83847bdffdbd7f346754b0f" + +[[package]] +name = "r-efi" +version = "6.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f8dcc9c7d52a811697d2151c701e0d08956f92b0e24136cf4cf27b57a6a0d9bf" + +[[package]] +name = "rand" +version = "0.8.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "34af8d1a0e25924bc5b7c43c079c942339d8f0a8b57c39049bef581b46327404" +dependencies = [ + "rand_core", +] + +[[package]] +name = "rand_core" +version = "0.6.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ec0be4795e2f6a28069bec0b5ff3e2ac9bafc99e6a9a7dc3547996c5c816922c" + +[[package]] +name = "ratatui" +version = "0.30.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d1ce67fb8ba4446454d1c8dbaeda0557ff5e94d39d5e5ed7f10a65eb4c8266bc" +dependencies = [ + "instability", + "ratatui-core", + "ratatui-crossterm", + "ratatui-macros", + "ratatui-termwiz", + "ratatui-widgets", +] + +[[package]] +name = "ratatui-core" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5ef8dea09a92caaf73bff7adb70b76162e5937524058a7e5bff37869cbbec293" +dependencies = [ + "bitflags 2.11.0", + "compact_str", + "hashbrown 0.16.1", + "indoc", + "itertools", + "kasuari", + "lru", + "strum", + "thiserror 2.0.18", + "unicode-segmentation", + "unicode-truncate", + "unicode-width", +] + +[[package]] +name = "ratatui-crossterm" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "577c9b9f652b4c121fb25c6a391dd06406d3b092ba68827e6d2f09550edc54b3" +dependencies = [ + "cfg-if", + "crossterm", + "instability", + "ratatui-core", +] + +[[package]] +name = "ratatui-macros" +version = "0.7.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a7f1342a13e83e4bb9d0b793d0ea762be633f9582048c892ae9041ef39c936f4" +dependencies = [ + "ratatui-core", + "ratatui-widgets", +] + +[[package]] +name = "ratatui-termwiz" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0f76fe0bd0ed4295f0321b1676732e2454024c15a35d01904ddb315afd3d545c" +dependencies = [ + "ratatui-core", + "termwiz", +] + +[[package]] +name = "ratatui-widgets" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d7dbfa023cd4e604c2553483820c5fe8aa9d71a42eea5aa77c6e7f35756612db" +dependencies = [ + "bitflags 2.11.0", + "hashbrown 0.16.1", + "indoc", + "instability", + "itertools", + "line-clipping", + "ratatui-core", + "strum", + "time", + "unicode-segmentation", + "unicode-width", +] + +[[package]] +name = "redox_syscall" +version = "0.5.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ed2bf2547551a7053d6fdfafda3f938979645c44812fbfcda098faae3f1a362d" +dependencies = [ + "bitflags 2.11.0", +] + +[[package]] +name = "regex" +version = "1.12.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e10754a14b9137dd7b1e3e5b0493cc9171fdd105e0ab477f51b72e7f3ac0e276" +dependencies = [ + "aho-corasick", + "memchr", + "regex-automata", + "regex-syntax", +] + +[[package]] +name = "regex-automata" +version = "0.4.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6e1dd4122fc1595e8162618945476892eefca7b88c52820e74af6262213cae8f" +dependencies = [ + "aho-corasick", + "memchr", + "regex-syntax", +] + +[[package]] +name = "regex-syntax" +version = "0.8.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dc897dd8d9e8bd1ed8cdad82b5966c3e0ecae09fb1907d58efaa013543185d0a" + +[[package]] +name = "rustc_version" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cfcb3a22ef46e85b45de6ee7e79d063319ebb6594faafcf1c225ea92ab6e9b92" +dependencies = [ + "semver", +] + +[[package]] +name = "rustix" +version = "1.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6fe4565b9518b83ef4f91bb47ce29620ca828bd32cb7e408f0062e9930ba190" +dependencies = [ + "bitflags 2.11.0", + "errno", + "libc", + "linux-raw-sys", + "windows-sys", +] + +[[package]] +name = "rustversion" +version = "1.0.22" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b39cdef0fa800fc44525c84ccb54a029961a8215f9619753635a9c0d2538d46d" + +[[package]] +name = "ryu" +version = "1.0.23" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9774ba4a74de5f7b1c1451ed6cd5285a32eddb5cccb8cc655a4e50009e06477f" + +[[package]] +name = "scopeguard" +version = "1.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "94143f37725109f92c262ed2cf5e59bce7498c01bcc1502d7b9afe439a4e9f49" + +[[package]] +name = "semver" +version = "1.0.28" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8a7852d02fc848982e0c167ef163aaff9cd91dc640ba85e263cb1ce46fae51cd" + +[[package]] +name = "serde" +version = "1.0.228" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9a8e94ea7f378bd32cbbd37198a4a91436180c5bb472411e48b5ec2e2124ae9e" +dependencies = [ + "serde_core", + "serde_derive", +] + +[[package]] +name = "serde_core" +version = "1.0.228" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "41d385c7d4ca58e59fc732af25c3983b67ac852c1a25000afe1175de458b67ad" +dependencies = [ + "serde_derive", +] + +[[package]] +name = "serde_derive" +version = "1.0.228" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d540f220d3187173da220f885ab66608367b6574e925011a9353e4badda91d79" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.117", +] + +[[package]] +name = "serde_json" +version = "1.0.149" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "83fc039473c5595ace860d8c4fafa220ff474b3fc6bfdb4293327f1a37e94d86" +dependencies = [ + "itoa", + "memchr", + "serde", + "serde_core", + "zmij", +] + +[[package]] +name = "sha2" +version = "0.10.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a7507d819769d01a365ab707794a4084392c824f54a7a6a7862f8c3d0892b283" +dependencies = [ + "cfg-if", + "cpufeatures", + "digest", +] + +[[package]] +name = "signal-hook" +version = "0.3.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d881a16cf4426aa584979d30bd82cb33429027e42122b169753d6ef1085ed6e2" +dependencies = [ + "libc", + "signal-hook-registry", +] + +[[package]] +name = "signal-hook-mio" +version = "0.2.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b75a19a7a740b25bc7944bdee6172368f988763b744e3d4dfe753f6b4ece40cc" +dependencies = [ + "libc", + "mio", + "signal-hook", +] + +[[package]] +name = "signal-hook-registry" +version = "1.4.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c4db69cba1110affc0e9f7bcd48bbf87b3f4fc7c61fc9155afd4c469eb3d6c1b" +dependencies = [ + "errno", + "libc", +] + +[[package]] +name = "siphasher" +version = "1.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b2aa850e253778c88a04c3d7323b043aeda9d3e30d5971937c1855769763678e" + +[[package]] +name = "smallvec" +version = "1.15.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "67b1b7a3b5fe4f1376887184045fcf45c69e92af734b7aaddc05fb777b6fbd03" + +[[package]] +name = "static_assertions" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a2eb9349b6444b326872e140eb1cf5e7c522154d69e7a0ffb0fb81c06b37543f" + +[[package]] +name = "strsim" +version = "0.11.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7da8b5736845d9f2fcb837ea5d9e2628564b3b043a70948a3f0b778838c5fb4f" + +[[package]] +name = "strum" +version = "0.27.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "af23d6f6c1a224baef9d3f61e287d2761385a5b88fdab4eb4c6f11aeb54c4bcf" +dependencies = [ + "strum_macros", +] + +[[package]] +name = "strum_macros" +version = "0.27.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7695ce3845ea4b33927c055a39dc438a45b059f7c1b3d91d38d10355fb8cbca7" +dependencies = [ + "heck", + "proc-macro2", + "quote", + "syn 2.0.117", +] + +[[package]] +name = "syn" +version = "1.0.109" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "72b64191b275b66ffe2469e8af2c1cfe3bafa67b529ead792a6d0160888b4237" +dependencies = [ + "proc-macro2", + "quote", + "unicode-ident", +] + +[[package]] +name = "syn" +version = "2.0.117" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e665b8803e7b1d2a727f4023456bbbbe74da67099c585258af0ad9c5013b9b99" +dependencies = [ + "proc-macro2", + "quote", + "unicode-ident", +] + +[[package]] +name = "terminfo" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d4ea810f0692f9f51b382fff5893887bb4580f5fa246fde546e0b13e7fcee662" +dependencies = [ + "fnv", + "nom", + "phf", + "phf_codegen", +] + +[[package]] +name = "termios" +version = "0.3.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "411c5bf740737c7918b8b1fe232dca4dc9f8e754b8ad5e20966814001ed0ac6b" +dependencies = [ + "libc", +] + +[[package]] +name = "termwiz" +version = "0.23.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4676b37242ccbd1aabf56edb093a4827dc49086c0ffd764a5705899e0f35f8f7" +dependencies = [ + "anyhow", + "base64", + "bitflags 2.11.0", + "fancy-regex", + "filedescriptor", + "finl_unicode", + "fixedbitset", + "hex", + "lazy_static", + "libc", + "log", + "memmem", + "nix", + "num-derive", + "num-traits", + "ordered-float", + "pest", + "pest_derive", + "phf", + "sha2", + "signal-hook", + "siphasher", + "terminfo", + "termios", + "thiserror 1.0.69", + "ucd-trie", + "unicode-segmentation", + "vtparse", + "wezterm-bidi", + "wezterm-blob-leases", + "wezterm-color-types", + "wezterm-dynamic", + "wezterm-input-types", + "winapi", +] + +[[package]] +name = "thiserror" +version = "1.0.69" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6aaf5339b578ea85b50e080feb250a3e8ae8cfcdff9a461c9ec2904bc923f52" +dependencies = [ + "thiserror-impl 1.0.69", +] + +[[package]] +name = "thiserror" +version = "2.0.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4288b5bcbc7920c07a1149a35cf9590a2aa808e0bc1eafaade0b80947865fbc4" +dependencies = [ + "thiserror-impl 2.0.18", +] + +[[package]] +name = "thiserror-impl" +version = "1.0.69" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4fee6c4efc90059e10f81e6d42c60a18f76588c3d74cb83a0b242a2b6c7504c1" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.117", +] + +[[package]] +name = "thiserror-impl" +version = "2.0.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ebc4ee7f67670e9b64d05fa4253e753e016c6c95ff35b89b7941d6b856dec1d5" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.117", +] + +[[package]] +name = "time" +version = "0.3.47" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "743bd48c283afc0388f9b8827b976905fb217ad9e647fae3a379a9283c4def2c" +dependencies = [ + "deranged", + "libc", + "num-conv", + "num_threads", + "powerfmt", + "serde_core", + "time-core", +] + +[[package]] +name = "time-core" +version = "0.1.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7694e1cfe791f8d31026952abf09c69ca6f6fa4e1a1229e18988f06a04a12dca" + +[[package]] +name = "typenum" +version = "1.19.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "562d481066bde0658276a35467c4af00bdc6ee726305698a55b86e61d7ad82bb" + +[[package]] +name = "ucd-trie" +version = "0.1.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2896d95c02a80c6d6a5d6e953d479f5ddf2dfdb6a244441010e373ac0fb88971" + +[[package]] +name = "unicode-ident" +version = "1.0.24" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e6e4313cd5fcd3dad5cafa179702e2b244f760991f45397d14d4ebf38247da75" + +[[package]] +name = "unicode-segmentation" +version = "1.13.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9629274872b2bfaf8d66f5f15725007f635594914870f65218920345aa11aa8c" + +[[package]] +name = "unicode-truncate" +version = "2.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "16b380a1238663e5f8a691f9039c73e1cdae598a30e9855f541d29b08b53e9a5" +dependencies = [ + "itertools", + "unicode-segmentation", + "unicode-width", +] + +[[package]] +name = "unicode-width" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1fc81956842c57dac11422a97c3b8195a1ff727f06e85c84ed2e8aa277c9a0fd" + +[[package]] +name = "unicode-xid" +version = "0.2.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ebc1c04c71510c7f702b52b7c350734c9ff1295c464a03335b00bb84fc54f853" + +[[package]] +name = "utf8parse" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "06abde3611657adf66d383f00b093d7faecc7fa57071cce2578660c9f1010821" + +[[package]] +name = "uuid" +version = "1.23.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5ac8b6f42ead25368cf5b098aeb3dc8a1a2c05a3eee8a9a1a68c640edbfc79d9" +dependencies = [ + "atomic", + "getrandom 0.4.2", + "js-sys", + "wasm-bindgen", +] + +[[package]] +name = "version_check" +version = "0.9.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0b928f33d975fc6ad9f86c8f283853ad26bdd5b10b7f1542aa2fa15e2289105a" + +[[package]] +name = "vtparse" +version = "0.6.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6d9b2acfb050df409c972a37d3b8e08cdea3bddb0c09db9d53137e504cfabed0" +dependencies = [ + "utf8parse", +] + +[[package]] +name = "wasi" +version = "0.11.1+wasi-snapshot-preview1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ccf3ec651a847eb01de73ccad15eb7d99f80485de043efb2f370cd654f4ea44b" + +[[package]] +name = "wasip2" +version = "1.0.2+wasi-0.2.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9517f9239f02c069db75e65f174b3da828fe5f5b945c4dd26bd25d89c03ebcf5" +dependencies = [ + "wit-bindgen", +] + +[[package]] +name = "wasip3" +version = "0.4.0+wasi-0.3.0-rc-2026-01-06" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5428f8bf88ea5ddc08faddef2ac4a67e390b88186c703ce6dbd955e1c145aca5" +dependencies = [ + "wit-bindgen", +] + +[[package]] +name = "wasm-bindgen" +version = "0.2.118" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0bf938a0bacb0469e83c1e148908bd7d5a6010354cf4fb73279b7447422e3a89" +dependencies = [ + "cfg-if", + "once_cell", + "rustversion", + "wasm-bindgen-macro", + "wasm-bindgen-shared", +] + +[[package]] +name = "wasm-bindgen-macro" +version = "0.2.118" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "eeff24f84126c0ec2db7a449f0c2ec963c6a49efe0698c4242929da037ca28ed" +dependencies = [ + "quote", + "wasm-bindgen-macro-support", +] + +[[package]] +name = "wasm-bindgen-macro-support" +version = "0.2.118" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9d08065faf983b2b80a79fd87d8254c409281cf7de75fc4b773019824196c904" +dependencies = [ + "bumpalo", + "proc-macro2", + "quote", + "syn 2.0.117", + "wasm-bindgen-shared", +] + +[[package]] +name = "wasm-bindgen-shared" +version = "0.2.118" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5fd04d9e306f1907bd13c6361b5c6bfc7b3b3c095ed3f8a9246390f8dbdee129" +dependencies = [ + "unicode-ident", +] + +[[package]] +name = "wasm-encoder" +version = "0.244.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "990065f2fe63003fe337b932cfb5e3b80e0b4d0f5ff650e6985b1048f62c8319" +dependencies = [ + "leb128fmt", + "wasmparser", +] + +[[package]] +name = "wasm-metadata" +version = "0.244.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bb0e353e6a2fbdc176932bbaab493762eb1255a7900fe0fea1a2f96c296cc909" +dependencies = [ + "anyhow", + "indexmap", + "wasm-encoder", + "wasmparser", +] + +[[package]] +name = "wasmparser" +version = "0.244.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "47b807c72e1bac69382b3a6fb3dbe8ea4c0ed87ff5629b8685ae6b9a611028fe" +dependencies = [ + "bitflags 2.11.0", + "hashbrown 0.15.5", + "indexmap", + "semver", +] + +[[package]] +name = "wezterm-bidi" +version = "0.2.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0c0a6e355560527dd2d1cf7890652f4f09bb3433b6aadade4c9b5ed76de5f3ec" +dependencies = [ + "log", + "wezterm-dynamic", +] + +[[package]] +name = "wezterm-blob-leases" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "692daff6d93d94e29e4114544ef6d5c942a7ed998b37abdc19b17136ea428eb7" +dependencies = [ + "getrandom 0.3.4", + "mac_address", + "sha2", + "thiserror 1.0.69", + "uuid", +] + +[[package]] +name = "wezterm-color-types" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7de81ef35c9010270d63772bebef2f2d6d1f2d20a983d27505ac850b8c4b4296" +dependencies = [ + "csscolorparser", + "deltae", + "lazy_static", + "wezterm-dynamic", +] + +[[package]] +name = "wezterm-dynamic" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5f2ab60e120fd6eaa68d9567f3226e876684639d22a4219b313ff69ec0ccd5ac" +dependencies = [ + "log", + "ordered-float", + "strsim", + "thiserror 1.0.69", + "wezterm-dynamic-derive", +] + +[[package]] +name = "wezterm-dynamic-derive" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "46c0cf2d539c645b448eaffec9ec494b8b19bd5077d9e58cb1ae7efece8d575b" +dependencies = [ + "proc-macro2", + "quote", + "syn 1.0.109", +] + +[[package]] +name = "wezterm-input-types" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7012add459f951456ec9d6c7e6fc340b1ce15d6fc9629f8c42853412c029e57e" +dependencies = [ + "bitflags 1.3.2", + "euclid", + "lazy_static", + "serde", + "wezterm-dynamic", +] + +[[package]] +name = "winapi" +version = "0.3.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5c839a674fcd7a98952e593242ea400abe93992746761e38641405d28b00f419" +dependencies = [ + "winapi-i686-pc-windows-gnu", + "winapi-x86_64-pc-windows-gnu", +] + +[[package]] +name = "winapi-i686-pc-windows-gnu" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ac3b87c63620426dd9b991e5ce0329eff545bccbbb34f3be09ff6fb6ab51b7b6" + +[[package]] +name = "winapi-x86_64-pc-windows-gnu" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "712e227841d057c1ee1cd2fb22fa7e5a5461ae8e48fa2ca79ec42cfc1931183f" + +[[package]] +name = "windows-link" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f0805222e57f7521d6a62e36fa9163bc891acd422f971defe97d64e70d0a4fe5" + +[[package]] +name = "windows-sys" +version = "0.61.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ae137229bcbd6cdf0f7b80a31df61766145077ddf49416a728b02cb3921ff3fc" +dependencies = [ + "windows-link", +] + +[[package]] +name = "wit-bindgen" +version = "0.51.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d7249219f66ced02969388cf2bb044a09756a083d0fab1e566056b04d9fbcaa5" +dependencies = [ + "wit-bindgen-rust-macro", +] + +[[package]] +name = "wit-bindgen-core" +version = "0.51.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ea61de684c3ea68cb082b7a88508a8b27fcc8b797d738bfc99a82facf1d752dc" +dependencies = [ + "anyhow", + "heck", + "wit-parser", +] + +[[package]] +name = "wit-bindgen-rust" +version = "0.51.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b7c566e0f4b284dd6561c786d9cb0142da491f46a9fbed79ea69cdad5db17f21" +dependencies = [ + "anyhow", + "heck", + "indexmap", + "prettyplease", + "syn 2.0.117", + "wasm-metadata", + "wit-bindgen-core", + "wit-component", +] + +[[package]] +name = "wit-bindgen-rust-macro" +version = "0.51.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0c0f9bfd77e6a48eccf51359e3ae77140a7f50b1e2ebfe62422d8afdaffab17a" +dependencies = [ + "anyhow", + "prettyplease", + "proc-macro2", + "quote", + "syn 2.0.117", + "wit-bindgen-core", + "wit-bindgen-rust", +] + +[[package]] +name = "wit-component" +version = "0.244.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9d66ea20e9553b30172b5e831994e35fbde2d165325bec84fc43dbf6f4eb9cb2" +dependencies = [ + "anyhow", + "bitflags 2.11.0", + "indexmap", + "log", + "serde", + "serde_derive", + "serde_json", + "wasm-encoder", + "wasm-metadata", + "wasmparser", + "wit-parser", +] + +[[package]] +name = "wit-parser" +version = "0.244.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ecc8ac4bc1dc3381b7f59c34f00b67e18f910c2c0f50015669dde7def656a736" +dependencies = [ + "anyhow", + "id-arena", + "indexmap", + "log", + "semver", + "serde", + "serde_derive", + "serde_json", + "unicode-xid", + "wasmparser", +] + +[[package]] +name = "zfs-stats" +version = "0.1.0" +dependencies = [ + "anyhow", + "crossterm", + "ratatui", +] + +[[package]] +name = "zmij" +version = "1.0.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b8848ee67ecc8aedbaf3e4122217aff892639231befc6a1b58d29fff4c2cabaa" diff --git a/Cargo.toml b/Cargo.toml new file mode 100644 index 0000000..ca29ae0 --- /dev/null +++ b/Cargo.toml @@ -0,0 +1,9 @@ +[package] +name = "zfs-stats" +version = "0.1.0" +edition = "2021" + +[dependencies] +ratatui = "0.30" +crossterm = "0.29" +anyhow = "1" diff --git a/src/app.rs b/src/app.rs new file mode 100644 index 0000000..53134bc --- /dev/null +++ b/src/app.rs @@ -0,0 +1,515 @@ +use std::collections::{HashMap, VecDeque}; +use std::time::Instant; + +use crate::netio::{classify, is_chart_worthy, read_net_dev, IfaceKind, NetIfaceRaw}; +use crate::pools::{list_pool_names, read_all_pool_space, read_pool_io, PoolIoRaw, PoolSpace}; +use crate::procs::{read_all_proc_io, ProcIoRaw}; +use crate::smart::{ + read_hwmon, read_smart, read_smart_sudo, scan_drives, scan_drives_sudo, test_sudo_password, +}; +use crate::zfs::{read_arcstats, ArcStats}; + +pub const HISTORY_SIZE: usize = 120; + +/// Indices in the left-panel list +pub const _ARC_METRICS_COUNT: usize = 6; +pub const POOL_IO_IDX: usize = 6; +pub const NETWORK_IDX: usize = 7; +pub const SMART_IDX: usize = 8; +pub const TOTAL_ITEMS: usize = 9; + +// ── ARC metric definitions ──────────────────────────────────────────────────── + +#[derive(Debug, Clone, Copy, PartialEq)] +pub enum MetricKind { + ArcHitRatio, + L2HitRatio, + ArcL2Rate, + ArcL2CacheUsage, + ArcSize, + ArcL2Size, +} + +pub struct MetricDef { + pub kind: MetricKind, + pub name: &'static str, + pub unit: &'static str, + pub description: &'static str, +} + +pub const METRICS: &[MetricDef] = &[ + MetricDef { + kind: MetricKind::ArcHitRatio, + name: "ARC Hit Ratio", + unit: "%", + description: "ARC cache hits as a percentage of all ARC requests.\nA high value means most reads are served from RAM.", + }, + MetricDef { + kind: MetricKind::L2HitRatio, + name: "L2 Hit Ratio", + unit: "%", + description: "L2ARC hits as a percentage of all L2ARC lookups.\nShows how effective the L2 SSD cache is.", + }, + MetricDef { + kind: MetricKind::ArcL2Rate, + name: "ARC L2 Rate", + unit: "MB/s", + description: "Read throughput from the L2ARC (SSD) cache.\nHigher means more data is being served from L2.", + }, + MetricDef { + kind: MetricKind::ArcL2CacheUsage, + name: "L2 Cache Usage", + unit: "%", + description: "L2ARC used size as a percentage of the peak L2 size\nobserved since startup.", + }, + MetricDef { + kind: MetricKind::ArcSize, + name: "ARC Size", + unit: "MB / GB", + description: "Current size of the Adaptive Replacement Cache (ARC)\nstored in RAM.", + }, + MetricDef { + kind: MetricKind::ArcL2Size, + name: "ARC L2 Size", + unit: "MB / GB", + description: "Current logical size of the L2ARC stored on\nthe SSD/NVMe cache device.", + }, +]; + +// ── Display types ───────────────────────────────────────────────────────────── + +#[derive(Debug, Clone, Default)] +pub struct PoolDisplay { + pub name: String, + pub size_bytes: u64, + pub alloc_bytes: u64, + pub health: String, + pub read_mb_s: f64, + pub write_mb_s: f64, + pub read_iops: f64, + pub write_iops: f64, +} + +#[derive(Debug, Clone, Default)] +pub struct ProcDisplay { + pub pid: u32, + pub name: String, + pub read_mb_s: f64, + pub write_mb_s: f64, +} + +#[derive(Debug, Clone, Default)] +pub struct NetIfaceDisplay { + pub name: String, + pub kind: IfaceKind, + pub rx_mb_s: f64, + pub tx_mb_s: f64, + pub rx_pps: f64, + pub tx_pps: f64, + pub rx_errors: u64, + pub tx_errors: u64, +} + +impl Default for IfaceKind { + fn default() -> Self { + IfaceKind::Other + } +} + +pub use crate::smart::{DriveType, HwmonSensor, SmartDrive}; + +// ── Password popup ──────────────────────────────────────────────────────────── + +#[derive(Debug, Default)] +pub struct PasswordPopup { + pub input: String, + pub error: Option, +} + +impl PasswordPopup { + pub fn push(&mut self, c: char) { + self.input.push(c); + self.error = None; // clear error on new input + } + pub fn backspace(&mut self) { + self.input.pop(); + self.error = None; + } +} + +// ── App state ───────────────────────────────────────────────────────────────── + +pub struct App { + pub selected: usize, + + // ARC metrics + pub history: Vec>, + pub current_values: Vec, + pub error: Option, + + // Pool I/O + pub pools: Vec, + pub pool_error: Option, + + // Top I/O processes + pub top_procs: Vec, + + // Network I/O + pub net_ifaces: Vec, + pub net_rx_history: VecDeque, + pub net_tx_history: VecDeque, + + // SMART / hwmon + pub hwmon: Vec, + pub smart_drives: Vec>, + pub smart_permission_error: bool, + + // Sudo password popup + pub popup: Option, + sudo_password: Option, + + // Delta-tracking state (private) + last_stats: Option, + last_time: Option, + peak_l2_size_mb: f64, + last_pool_io: HashMap, + last_proc_io: HashMap, + last_net_raw: HashMap, + pool_space_cache: Vec, + space_tick: u32, + smart_tick: u32, + smart_devices: Vec, +} + +impl App { + pub fn new() -> Self { + Self { + selected: 0, + history: vec![VecDeque::new(); METRICS.len()], + current_values: vec![0.0; METRICS.len()], + error: None, + pools: Vec::new(), + pool_error: None, + top_procs: Vec::new(), + net_ifaces: Vec::new(), + net_rx_history: VecDeque::new(), + net_tx_history: VecDeque::new(), + hwmon: Vec::new(), + smart_drives: Vec::new(), + smart_permission_error: false, + popup: None, + sudo_password: None, + last_stats: None, + last_time: None, + peak_l2_size_mb: 1.0, + last_pool_io: HashMap::new(), + last_proc_io: HashMap::new(), + last_net_raw: HashMap::new(), + pool_space_cache: Vec::new(), + space_tick: 0, + smart_tick: 0, + smart_devices: Vec::new(), + } + } + + pub fn next(&mut self) { + if self.selected + 1 < TOTAL_ITEMS { + self.selected += 1; + } + } + + pub fn prev(&mut self) { + if self.selected > 0 { + self.selected -= 1; + } + } + + pub fn is_popup_open(&self) -> bool { + self.popup.is_some() + } + + pub fn open_password_popup(&mut self) { + self.popup = Some(PasswordPopup::default()); + } + + pub fn close_popup(&mut self) { + self.popup = None; + } + + pub fn popup_push(&mut self, c: char) { + if let Some(p) = &mut self.popup { + p.push(c); + } + } + + pub fn popup_backspace(&mut self) { + if let Some(p) = &mut self.popup { + p.backspace(); + } + } + + /// Try the typed password. On success closes the popup and schedules a + /// SMART refresh. On failure stores the error message in the popup. + /// Returns true if the password was accepted. + pub fn submit_password(&mut self) -> bool { + let password = match &self.popup { + Some(p) => p.input.clone(), + None => return false, + }; + + match test_sudo_password(&password) { + Ok(()) => { + self.sudo_password = Some(password); + self.popup = None; + self.smart_permission_error = false; + // Force an immediate SMART re-read on next tick + self.smart_tick = 0; + self.smart_devices.clear(); + true + } + Err(e) => { + if let Some(p) = &mut self.popup { + p.input.clear(); + p.error = Some(e); + } + false + } + } + } + + pub fn update_stats(&mut self) { + let now = Instant::now(); + + self.update_arc(now); + self.update_pool_io(now); + self.update_proc_io(now); + self.update_network(now); + self.update_smart(); + + self.last_time = Some(now); + } + + // ── ARC ─────────────────────────────────────────────────────────────────── + + fn update_arc(&mut self, now: Instant) { + match read_arcstats() { + Ok(stats) => { + self.error = None; + let vals = self.compute_arc(&stats, now); + for (i, v) in vals.iter().enumerate() { + self.current_values[i] = *v; + self.history[i].push_back(*v); + if self.history[i].len() > HISTORY_SIZE { + self.history[i].pop_front(); + } + } + self.last_stats = Some(stats); + } + Err(e) => self.error = Some(e.to_string()), + } + } + + fn compute_arc(&mut self, s: &ArcStats, now: Instant) -> Vec { + let mut v = vec![0.0f64; METRICS.len()]; + + let total = s.hits + s.misses; + v[0] = if total > 0 { s.hits as f64 / total as f64 * 100.0 } else { 0.0 }; + + let l2_total = s.l2_hits + s.l2_misses; + v[1] = if l2_total > 0 { s.l2_hits as f64 / l2_total as f64 * 100.0 } else { 0.0 }; + + if let (Some(last), Some(lt)) = (&self.last_stats, self.last_time) { + let elapsed = now.duration_since(lt).as_secs_f64(); + if elapsed > 0.0 { + let delta = s.l2_read_bytes.saturating_sub(last.l2_read_bytes); + v[2] = delta as f64 / elapsed / 1_048_576.0; + } + } + + let l2_mb = s.l2_size as f64 / 1_048_576.0; + if l2_mb > self.peak_l2_size_mb { self.peak_l2_size_mb = l2_mb; } + v[3] = if self.peak_l2_size_mb > 0.0 { l2_mb / self.peak_l2_size_mb * 100.0 } else { 0.0 }; + + v[4] = s.size as f64 / 1_048_576.0; + v[5] = s.l2_size as f64 / 1_048_576.0; + v + } + + // ── Pool I/O ────────────────────────────────────────────────────────────── + + fn update_pool_io(&mut self, now: Instant) { + let elapsed = self.last_time.map(|t| now.duration_since(t).as_secs_f64()).unwrap_or(1.0).max(0.001); + + if self.space_tick == 0 { self.pool_space_cache = read_all_pool_space(); } + self.space_tick = (self.space_tick + 1) % 10; + + let names = list_pool_names(); + let mut new_io: HashMap = HashMap::new(); + let mut displays: Vec = Vec::new(); + let space_map: HashMap<&str, &PoolSpace> = self.pool_space_cache.iter().map(|s| (s.name.as_str(), s)).collect(); + let mut had_error = false; + + for name in &names { + match read_pool_io(name) { + Ok(raw) => { + let prev = self.last_pool_io.get(name.as_str()); + let read_mb_s = prev.map(|p| raw.arc_read_bytes.saturating_sub(p.arc_read_bytes) as f64 / elapsed / 1_048_576.0).unwrap_or(0.0); + let write_mb_s = prev.map(|p| raw.arc_write_bytes.saturating_sub(p.arc_write_bytes) as f64 / elapsed / 1_048_576.0).unwrap_or(0.0); + let read_iops = prev.map(|p| raw.arc_read_count.saturating_sub(p.arc_read_count) as f64 / elapsed).unwrap_or(0.0); + let write_iops = prev.map(|p| raw.arc_write_count.saturating_sub(p.arc_write_count) as f64 / elapsed).unwrap_or(0.0); + let sp = space_map.get(name.as_str()); + displays.push(PoolDisplay { + name: name.clone(), + size_bytes: sp.map(|s| s.size_bytes).unwrap_or(0), + alloc_bytes: sp.map(|s| s.alloc_bytes).unwrap_or(0), + health: sp.map(|s| s.health.clone()).unwrap_or_else(|| "?".into()), + read_mb_s, write_mb_s, read_iops, write_iops, + }); + new_io.insert(name.clone(), raw); + } + Err(_) => had_error = true, + } + } + displays.sort_by(|a, b| (b.read_mb_s + b.write_mb_s).partial_cmp(&(a.read_mb_s + a.write_mb_s)).unwrap_or(std::cmp::Ordering::Equal)); + self.pools = displays; + self.last_pool_io = new_io; + self.pool_error = had_error.then(|| "Some pools could not be read".into()); + } + + // ── Process I/O ─────────────────────────────────────────────────────────── + + fn update_proc_io(&mut self, now: Instant) { + let elapsed = self.last_time.map(|t| now.duration_since(t).as_secs_f64()).unwrap_or(1.0).max(0.001); + let current = read_all_proc_io(); + let mut new_map: HashMap = HashMap::new(); + let mut rates: Vec = Vec::new(); + + for proc in current { + let prev = self.last_proc_io.get(&proc.pid); + let rmb = prev.map(|p| proc.read_bytes.saturating_sub(p.read_bytes) as f64 / elapsed / 1_048_576.0).unwrap_or(0.0); + let wmb = prev.map(|p| proc.write_bytes.saturating_sub(p.write_bytes) as f64 / elapsed / 1_048_576.0).unwrap_or(0.0); + if rmb > 0.0 || wmb > 0.0 { + rates.push(ProcDisplay { pid: proc.pid, name: proc.name.clone(), read_mb_s: rmb, write_mb_s: wmb }); + } + new_map.insert(proc.pid, proc); + } + rates.sort_by(|a, b| (b.read_mb_s + b.write_mb_s).partial_cmp(&(a.read_mb_s + a.write_mb_s)).unwrap_or(std::cmp::Ordering::Equal)); + rates.truncate(20); + self.top_procs = rates; + self.last_proc_io = new_map; + } + + // ── Network I/O ─────────────────────────────────────────────────────────── + + fn update_network(&mut self, now: Instant) { + let elapsed = self.last_time.map(|t| now.duration_since(t).as_secs_f64()).unwrap_or(1.0).max(0.001); + let raw_list = read_net_dev(); + let mut new_raw: HashMap = HashMap::new(); + let mut displays: Vec = Vec::new(); + let mut agg_rx = 0.0f64; + let mut agg_tx = 0.0f64; + + for r in &raw_list { + let kind = classify(&r.name); + let prev = self.last_net_raw.get(&r.name); + let rx_mb_s = prev.map(|p| r.rx_bytes.saturating_sub(p.rx_bytes) as f64 / elapsed / 1_048_576.0).unwrap_or(0.0); + let tx_mb_s = prev.map(|p| r.tx_bytes.saturating_sub(p.tx_bytes) as f64 / elapsed / 1_048_576.0).unwrap_or(0.0); + let rx_pps = prev.map(|p| r.rx_packets.saturating_sub(p.rx_packets) as f64 / elapsed).unwrap_or(0.0); + let tx_pps = prev.map(|p| r.tx_packets.saturating_sub(p.tx_packets) as f64 / elapsed).unwrap_or(0.0); + + if is_chart_worthy(kind) { + agg_rx += rx_mb_s; + agg_tx += tx_mb_s; + } + + // Include in table: loopback excluded; containers only if active + let include = match kind { + IfaceKind::Loopback => false, + IfaceKind::Container => rx_mb_s > 0.0 || tx_mb_s > 0.0, + _ => true, + }; + if include { + displays.push(NetIfaceDisplay { + name: r.name.clone(), kind, + rx_mb_s, tx_mb_s, rx_pps, tx_pps, + rx_errors: r.rx_errors, tx_errors: r.tx_errors, + }); + } + new_raw.insert(r.name.clone(), r.clone()); + } + + // Sort: physical/vpn first, then by total rate + displays.sort_by(|a, b| { + let pri_a = kind_priority(a.kind); + let pri_b = kind_priority(b.kind); + if pri_a != pri_b { return pri_a.cmp(&pri_b); } + (b.rx_mb_s + b.tx_mb_s).partial_cmp(&(a.rx_mb_s + a.tx_mb_s)).unwrap_or(std::cmp::Ordering::Equal) + }); + + self.net_ifaces = displays; + self.last_net_raw = new_raw; + + // Append to history (aggregate of non-container interfaces) + self.net_rx_history.push_back(agg_rx); + self.net_tx_history.push_back(agg_tx); + if self.net_rx_history.len() > HISTORY_SIZE { self.net_rx_history.pop_front(); } + if self.net_tx_history.len() > HISTORY_SIZE { self.net_tx_history.pop_front(); } + } + + // ── SMART / hwmon ───────────────────────────────────────────────────────── + + fn update_smart(&mut self) { + // hwmon: fast, always readable without privileges + self.hwmon = read_hwmon(); + + // SMART: expensive — poll every 60 s + if self.smart_tick == 0 { + let pwd = self.sudo_password.clone(); + + // Discover devices (use sudo path if password is known) + if self.smart_devices.is_empty() { + self.smart_devices = match &pwd { + Some(p) => scan_drives_sudo(p).unwrap_or_else(|_| scan_drives()), + None => scan_drives(), + }; + } + + self.smart_permission_error = false; + self.smart_drives = self.smart_devices.iter().map(|d| { + let result = match &pwd { + Some(p) => read_smart_sudo(d, p), + None => read_smart(d), + }; + if let Err(ref e) = result { + if e.contains("Permission denied") || e.contains("sudo password") { + self.smart_permission_error = true; + } + } + result + }).collect(); + } + self.smart_tick = (self.smart_tick + 1) % 60; + } + + // ── Helpers ─────────────────────────────────────────────────────────────── + + pub fn history_stats(&self) -> (f64, f64, f64) { + let h = &self.history[self.selected]; + if h.is_empty() { return (0.0, 0.0, 0.0); } + let min = h.iter().cloned().fold(f64::MAX, f64::min); + let max = h.iter().cloned().fold(f64::MIN, f64::max); + let avg = h.iter().sum::() / h.len() as f64; + (min, max, avg) + } +} + +fn kind_priority(k: IfaceKind) -> u8 { + match k { + IfaceKind::Physical => 0, + IfaceKind::Vpn => 1, + IfaceKind::Virtual => 2, + IfaceKind::Other => 3, + IfaceKind::Container => 4, + IfaceKind::Loopback => 5, + } +} diff --git a/src/main.rs b/src/main.rs new file mode 100644 index 0000000..673676e --- /dev/null +++ b/src/main.rs @@ -0,0 +1,93 @@ +use std::io; +use std::sync::{Arc, Mutex}; +use std::thread; +use std::time::Duration; + +use crossterm::event::{self, Event, KeyCode, KeyEventKind}; +use crossterm::execute; +use crossterm::terminal::{ + disable_raw_mode, enable_raw_mode, EnterAlternateScreen, LeaveAlternateScreen, +}; +use ratatui::backend::CrosstermBackend; +use ratatui::Terminal; + +mod app; +mod netio; +mod pools; +mod procs; +mod smart; +mod ui; +mod zfs; + +use app::{App, SMART_IDX}; + +fn main() -> anyhow::Result<()> { + // Set up terminal + enable_raw_mode()?; + let mut stdout = io::stdout(); + execute!(stdout, EnterAlternateScreen)?; + let backend = CrosstermBackend::new(stdout); + let mut terminal = Terminal::new(backend)?; + + // Shared app state — updated by a background thread, read on the main thread + let app = Arc::new(Mutex::new(App::new())); + + // Background stats collector: reads arcstats every second + { + let app = Arc::clone(&app); + thread::spawn(move || loop { + { + if let Ok(mut a) = app.lock() { + a.update_stats(); + } + } + thread::sleep(Duration::from_secs(1)); + }); + } + + // Main event + render loop + loop { + { + let app = app.lock().unwrap(); + terminal.draw(|f| ui::draw(f, &app))?; + } + + // Poll for input with a short timeout so the UI refreshes smoothly + if event::poll(Duration::from_millis(250))? { + if let Event::Key(key) = event::read()? { + if key.kind == KeyEventKind::Press { + let mut app = app.lock().unwrap(); + + if app.is_popup_open() { + // All keys are consumed by the popup while it is open + match key.code { + KeyCode::Esc => app.close_popup(), + KeyCode::Enter => { app.submit_password(); } + KeyCode::Backspace => app.popup_backspace(), + KeyCode::Char(c) => app.popup_push(c), + _ => {} + } + } else { + match key.code { + KeyCode::Char('q') | KeyCode::Esc => break, + KeyCode::Down | KeyCode::Char('j') => app.next(), + KeyCode::Up | KeyCode::Char('k') => app.prev(), + // 'p' opens the sudo password popup from the SMART panel + KeyCode::Char('p') if app.selected == SMART_IDX => { + app.open_password_popup(); + } + _ => {} + } + } + } + } + } + } + + // Restore terminal + disable_raw_mode()?; + execute!(terminal.backend_mut(), LeaveAlternateScreen)?; + terminal.show_cursor()?; + + Ok(()) +} diff --git a/src/netio.rs b/src/netio.rs new file mode 100644 index 0000000..8d50203 --- /dev/null +++ b/src/netio.rs @@ -0,0 +1,94 @@ +use std::fs; + +// ── Raw cumulative counters from /proc/net/dev ──────────────────────────────── + +#[derive(Debug, Clone, Default)] +pub struct NetIfaceRaw { + pub name: String, + pub rx_bytes: u64, + pub rx_packets: u64, + pub rx_errors: u64, + pub tx_bytes: u64, + pub tx_packets: u64, + pub tx_errors: u64, +} + +/// Read all interface counters. Returns empty vec on parse failure. +pub fn read_net_dev() -> Vec { + let Ok(content) = fs::read_to_string("/proc/net/dev") else { + return Vec::new(); + }; + content.lines().skip(2).filter_map(parse_line).collect() +} + +fn parse_line(line: &str) -> Option { + // " iface: rx_bytes rx_pkts rx_errs rx_drop ... tx_bytes tx_pkts tx_errs ..." + let (name_part, data_part) = line.split_once(':')?; + let name = name_part.trim().to_string(); + let nums: Vec = data_part + .split_whitespace() + .filter_map(|s| s.parse().ok()) + .collect(); + if nums.len() < 11 { + return None; + } + Some(NetIfaceRaw { + name, + rx_bytes: nums[0], + rx_packets: nums[1], + rx_errors: nums[2], + tx_bytes: nums[8], + tx_packets: nums[9], + tx_errors: nums[10], + }) +} + +// ── Interface classifier ────────────────────────────────────────────────────── + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum IfaceKind { + Loopback, + Physical, // enp*, eth*, ens*, eno*, em*, wlan*, wlp*, bond* + Vpn, // tun*, tap*, tailscale*, wg*, ipsec* + Virtual, // macvlan*, macvtap*, vlan* + Container, // br-*, veth*, docker*, virbr* + Other, +} + +pub fn classify(name: &str) -> IfaceKind { + if name == "lo" { + return IfaceKind::Loopback; + } + let prefixes_physical = ["eth", "enp", "ens", "eno", "em", "wlan", "wlp", "bond", "team"]; + let prefixes_vpn = ["tun", "tap", "tailscale", "wg", "ipsec", "ppp"]; + let prefixes_virtual = ["macvlan", "macvtap", "vlan"]; + let prefixes_container = ["br-", "veth", "docker", "virbr", "lxcbr", "lxdbr"]; + + for p in prefixes_physical { + if name.starts_with(p) { + return IfaceKind::Physical; + } + } + for p in prefixes_vpn { + if name.starts_with(p) { + return IfaceKind::Vpn; + } + } + for p in prefixes_virtual { + if name.starts_with(p) { + return IfaceKind::Virtual; + } + } + for p in prefixes_container { + if name.starts_with(p) { + return IfaceKind::Container; + } + } + IfaceKind::Other +} + +/// True for interfaces we want to include in the aggregate chart line +/// (everything except loopback and container internals). +pub fn is_chart_worthy(kind: IfaceKind) -> bool { + !matches!(kind, IfaceKind::Loopback | IfaceKind::Container) +} diff --git a/src/pools.rs b/src/pools.rs new file mode 100644 index 0000000..8a86e59 --- /dev/null +++ b/src/pools.rs @@ -0,0 +1,100 @@ +use std::collections::HashMap; +use std::fs; +use std::process::Command; + +// ── Raw kstat snapshot ──────────────────────────────────────────────────────── + +#[derive(Debug, Clone, Default)] +pub struct PoolIoRaw { + pub arc_read_bytes: u64, + pub arc_write_bytes: u64, + pub arc_read_count: u64, + pub arc_write_count: u64, +} + +// ── Space info from `zpool list` ────────────────────────────────────────────── + +#[derive(Debug, Clone)] +pub struct PoolSpace { + pub name: String, + pub size_bytes: u64, + pub alloc_bytes: u64, + pub health: String, +} + +// ── Public helpers ──────────────────────────────────────────────────────────── + +/// Find pool names by looking for `iostats` files under /proc/spl/kstat/zfs// +pub fn list_pool_names() -> Vec { + let mut pools = Vec::new(); + if let Ok(entries) = fs::read_dir("/proc/spl/kstat/zfs") { + for entry in entries.flatten() { + let path = entry.path(); + if path.is_dir() && path.join("iostats").exists() { + if let Some(name) = path.file_name() { + pools.push(name.to_string_lossy().to_string()); + } + } + } + } + pools.sort(); + pools +} + +/// Read cumulative I/O counters for one pool. +pub fn read_pool_io(pool_name: &str) -> anyhow::Result { + let content = + fs::read_to_string(format!("/proc/spl/kstat/zfs/{}/iostats", pool_name))?; + let mut map = HashMap::new(); + + for line in content.lines().skip(2) { + let mut parts = line.split_whitespace(); + let name = match parts.next() { + Some(n) => n, + None => continue, + }; + parts.next(); // skip type column + if let Some(val_str) = parts.next() { + if let Ok(val) = val_str.parse::() { + map.insert(name.to_string(), val); + } + } + } + + Ok(PoolIoRaw { + arc_read_bytes: map.get("arc_read_bytes").copied().unwrap_or(0), + arc_write_bytes: map.get("arc_write_bytes").copied().unwrap_or(0), + arc_read_count: map.get("arc_read_count").copied().unwrap_or(0), + arc_write_count: map.get("arc_write_count").copied().unwrap_or(0), + }) +} + +/// Fetch pool space info for all pools via `zpool list`. +/// Returns an empty vec if zpool is unavailable. +pub fn read_all_pool_space() -> Vec { + let out = Command::new("zpool") + .args(["list", "-Hp", "-o", "name,size,alloc,free,health"]) + .output(); + + let Ok(out) = out else { return Vec::new() }; + if !out.status.success() { + return Vec::new(); + } + + String::from_utf8_lossy(&out.stdout) + .lines() + .filter_map(|line| { + let parts: Vec<&str> = line.split('\t').collect(); + if parts.len() >= 5 { + Some(PoolSpace { + name: parts[0].to_string(), + size_bytes: parts[1].parse().unwrap_or(0), + alloc_bytes: parts[2].parse().unwrap_or(0), + health: parts[4].to_string(), + }) + } else { + None + } + }) + .collect() +} diff --git a/src/procs.rs b/src/procs.rs new file mode 100644 index 0000000..db2738c --- /dev/null +++ b/src/procs.rs @@ -0,0 +1,49 @@ +use std::fs; + +#[derive(Debug, Clone, Default)] +pub struct ProcIoRaw { + pub pid: u32, + pub name: String, + /// Actual bytes read from storage (not page-cache) + pub read_bytes: u64, + /// Actual bytes written to storage + pub write_bytes: u64, +} + +/// Read cumulative I/O for every process we have permission to see. +pub fn read_all_proc_io() -> Vec { + let Ok(entries) = fs::read_dir("/proc") else { + return Vec::new(); + }; + + entries + .flatten() + .filter_map(|entry| { + let fname = entry.file_name(); + let pid: u32 = fname.to_string_lossy().parse().ok()?; + + let name = fs::read_to_string(format!("/proc/{}/comm", pid)) + .unwrap_or_default() + .trim() + .to_string(); + + let io_content = fs::read_to_string(format!("/proc/{}/io", pid)).ok()?; + let (read_bytes, write_bytes) = parse_io(&io_content); + + Some(ProcIoRaw { pid, name, read_bytes, write_bytes }) + }) + .collect() +} + +fn parse_io(content: &str) -> (u64, u64) { + let mut rb = 0u64; + let mut wb = 0u64; + for line in content.lines() { + if let Some(v) = line.strip_prefix("read_bytes: ") { + rb = v.trim().parse().unwrap_or(0); + } else if let Some(v) = line.strip_prefix("write_bytes: ") { + wb = v.trim().parse().unwrap_or(0); + } + } + (rb, wb) +} diff --git a/src/smart.rs b/src/smart.rs new file mode 100644 index 0000000..61fdce9 --- /dev/null +++ b/src/smart.rs @@ -0,0 +1,304 @@ +use std::fs; +use std::io::Write; +use std::process::{Command, Stdio}; + +// ── hwmon temperatures (no root needed) ────────────────────────────────────── + +#[derive(Debug, Clone)] +pub struct HwmonSensor { + pub source: String, // e.g. "coretemp", "acpitz" + pub label: String, // e.g. "Core 0", "Package id 0" + pub temp_c: i32, + pub crit_c: Option, +} + +pub fn read_hwmon() -> Vec { + let Ok(entries) = fs::read_dir("/sys/class/hwmon") else { + return Vec::new(); + }; + + let mut sensors = Vec::new(); + + for entry in entries.flatten() { + let dir = entry.path(); + let source = fs::read_to_string(dir.join("name")) + .unwrap_or_default() + .trim() + .to_string(); + + // Walk temp*_input files + let Ok(files) = fs::read_dir(&dir) else { continue }; + let mut inputs: Vec<_> = files + .flatten() + .filter(|e| { + e.file_name() + .to_string_lossy() + .starts_with("temp") + && e.file_name().to_string_lossy().ends_with("_input") + }) + .collect(); + inputs.sort_by_key(|e| e.file_name()); + + for input_entry in inputs { + let input_path = input_entry.path(); + let Ok(raw_str) = fs::read_to_string(&input_path) else { continue }; + let Ok(raw_mc) = raw_str.trim().parse::() else { continue }; + let temp_c = raw_mc / 1000; + + // Label (e.g. "Core 0") + let label_path = input_path + .to_string_lossy() + .replace("_input", "_label"); + let label = fs::read_to_string(&label_path) + .unwrap_or_default() + .trim() + .to_string(); + let label = if label.is_empty() { + input_path + .file_name() + .unwrap_or_default() + .to_string_lossy() + .replace("_input", "") + .to_string() + } else { + label + }; + + // Critical threshold (optional) + let crit_path = input_path + .to_string_lossy() + .replace("_input", "_crit"); + let crit_c = fs::read_to_string(&crit_path) + .ok() + .and_then(|s| s.trim().parse::().ok()) + .map(|v| v / 1000); + + sensors.push(HwmonSensor { source: source.clone(), label, temp_c, crit_c }); + } + } + sensors +} + +// ── SMART drive data (needs root / elevated perms) ──────────────────────────── + +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum DriveType { + Ata, + Nvme, + Scsi, + Unknown, +} + +#[derive(Debug, Clone)] +pub struct SmartDrive { + pub device: String, + pub model: String, + pub drive_type: DriveType, + pub health: String, // "PASSED" | "FAILED" | "N/A" + pub temperature: Option, + pub power_on_hours: Option, + pub reallocated: Option, + pub pending: Option, + pub uncorrectable: Option, +} + +/// Discover SMART-capable devices via `smartctl --scan` (no privileges needed). +pub fn scan_drives() -> Vec { + let Ok(out) = Command::new("smartctl").arg("--scan").output() else { + return Vec::new(); + }; + String::from_utf8_lossy(&out.stdout) + .lines() + .filter_map(|l| l.split_whitespace().next().map(str::to_string)) + .collect() +} + +/// Read SMART data for one device (plain, no sudo). +pub fn read_smart(device: &str) -> Result { + let out = Command::new("smartctl") + .args(["-A", "-H", "-i", device]) + .output() + .map_err(|e| e.to_string())?; + + let text = String::from_utf8_lossy(&out.stdout).to_string(); + let stderr = String::from_utf8_lossy(&out.stderr).to_string(); + + if text.contains("Permission denied") || stderr.contains("Permission denied") { + return Err("Permission denied — press 'p' to enter sudo password".into()); + } + if text.contains("failed:") && text.len() < 200 { + return Err(text.lines().last().unwrap_or("smartctl error").to_string()); + } + + Ok(parse_smart(&text, device)) +} + +// ── sudo-aware variants ─────────────────────────────────────────────────────── + +/// Run `sudo -S smartctl ` feeding `password` via stdin. +/// Returns (stdout, true) on success, (stderr, false) on failure. +fn run_with_sudo(password: &str, smartctl_args: &[&str]) -> Result { + let mut child = Command::new("sudo") + .arg("-S") // read password from stdin + .arg("smartctl") + .args(smartctl_args) + .stdin(Stdio::piped()) + .stdout(Stdio::piped()) + .stderr(Stdio::piped()) + .spawn() + .map_err(|e| format!("Failed to spawn sudo: {}", e))?; + + // Write password + newline then close stdin so sudo doesn't hang + if let Some(mut stdin) = child.stdin.take() { + let _ = write!(stdin, "{}\n", password); + } + + let out = child.wait_with_output().map_err(|e| e.to_string())?; + let stdout = String::from_utf8_lossy(&out.stdout).to_string(); + let stderr = String::from_utf8_lossy(&out.stderr).to_string(); + + if stderr.to_lowercase().contains("incorrect password") + || stderr.to_lowercase().contains("sorry") + || stderr.to_lowercase().contains("authentication failure") + { + return Err("Incorrect password".into()); + } + if !out.status.success() && stdout.is_empty() { + let msg = stderr.lines().last().unwrap_or("sudo failed").trim().to_string(); + return Err(msg); + } + + Ok(stdout) +} + +/// Validate a sudo password by running `sudo -S true` (harmless). +pub fn test_sudo_password(password: &str) -> Result<(), String> { + run_with_sudo(password, &["--scan"]).map(|_| ()) +} + +/// Discover drives with sudo privileges. +pub fn scan_drives_sudo(password: &str) -> Result, String> { + let stdout = run_with_sudo(password, &["--scan"])?; + let drives = stdout + .lines() + .filter_map(|l| l.split_whitespace().next().map(str::to_string)) + .collect(); + Ok(drives) +} + +/// Read SMART data for one device using sudo. +pub fn read_smart_sudo(device: &str, password: &str) -> Result { + let text = run_with_sudo(password, &["-A", "-H", "-i", device])?; + Ok(parse_smart(&text, device)) +} + +fn parse_smart(text: &str, device: &str) -> SmartDrive { + let mut d = SmartDrive { + device: device.to_string(), + model: String::new(), + drive_type: DriveType::Unknown, + health: "N/A".to_string(), + temperature: None, + power_on_hours: None, + reallocated: None, + pending: None, + uncorrectable: None, + }; + + for line in text.lines() { + // Drive type detection + if line.contains("NVMe") || line.contains("NVME") { + d.drive_type = DriveType::Nvme; + } else if d.drive_type == DriveType::Unknown + && (line.contains("SATA") || line.contains("ATA device")) + { + d.drive_type = DriveType::Ata; + } else if d.drive_type == DriveType::Unknown && line.contains("SCSI") { + d.drive_type = DriveType::Scsi; + } + + // Model / serial + if let Some(v) = strip_any(line, &["Device Model:", "Model Number:", "Product:"]) { + if d.model.is_empty() { + d.model = v.trim().to_string(); + } + } + + // Overall health + if line.contains("SMART overall-health") { + d.health = if line.contains("PASSED") { + "PASSED".into() + } else if line.contains("FAILED") { + "FAILED".into() + } else { + "N/A".into() + }; + } + + // ── NVMe key=value style ────────────────────────────────────────── + if let Some(v) = strip_any(line, &["Temperature:", "Temperature Sensor 1:"]) { + if let Some(t) = first_number(v) { + d.temperature.get_or_insert(t); + } + } + if let Some(v) = line.strip_prefix("Power On Hours:") { + let clean: String = v.chars().filter(|c| c.is_ascii_digit()).collect(); + if let Ok(h) = clean.parse::() { + d.power_on_hours.get_or_insert(h); + } + } + + // ── ATA attribute table: " ID NAME flag val worst thresh ... RAW" ─ + let parts: Vec<&str> = line.split_whitespace().collect(); + if parts.len() >= 10 { + if let Ok(id) = parts[0].parse::() { + // Raw value may be "31" or "31 (Min/Max 25/40)" — take first token + let raw: u64 = parts[9] + .parse() + .unwrap_or(0); + match id { + 5 => { d.reallocated.get_or_insert(raw); } + 9 => { d.power_on_hours.get_or_insert(raw); } + 190 | 194 => { d.temperature.get_or_insert(raw as i32); } + 197 => { d.pending.get_or_insert(raw); } + 198 => { d.uncorrectable.get_or_insert(raw); } + _ => {} + } + } + } + + // ── SCSI / SAS temperature line: "Current Drive Temperature: 31 C" ─ + if let Some(v) = line.strip_prefix("Current Drive Temperature:") { + if let Some(t) = first_number(v) { + d.temperature.get_or_insert(t); + } + } + if let Some(v) = line.strip_prefix("Elements in grown defect list:") { + if let Ok(n) = v.trim().parse::() { + d.reallocated.get_or_insert(n); + } + } + if let Some(v) = line.strip_prefix("Accumulated power on time") { + // "Accumulated power on time, hours:minutes 12345:00" or similar + if let Some(h) = first_number(v) { + d.power_on_hours.get_or_insert(h as u64); + } + } + } + + d +} + +fn strip_any<'a>(line: &'a str, prefixes: &[&str]) -> Option<&'a str> { + for p in prefixes { + if let Some(rest) = line.strip_prefix(p) { + return Some(rest); + } + } + None +} + +fn first_number(s: &str) -> Option { + s.split_whitespace() + .find_map(|tok| tok.parse::().ok()) +} diff --git a/src/ui.rs b/src/ui.rs new file mode 100644 index 0000000..6b0e7a2 --- /dev/null +++ b/src/ui.rs @@ -0,0 +1,656 @@ +use ratatui::{ + layout::{Alignment, Constraint, Direction, Layout, Rect}, + style::{Color, Modifier, Style}, + symbols, + text::{Line, Span, Text}, + widgets::{ + Axis, Block, Borders, Cell, Chart, Clear, Dataset, GraphType, List, ListItem, ListState, + Paragraph, Row, Table, Wrap, + }, + Frame, +}; + +use crate::app::{ + App, DriveType, HwmonSensor, MetricKind, NetIfaceDisplay, PasswordPopup, PoolDisplay, + ProcDisplay, SmartDrive, METRICS, NETWORK_IDX, POOL_IO_IDX, SMART_IDX, +}; +use crate::netio::IfaceKind; + +// ── Top-level draw ──────────────────────────────────────────────────────────── + +pub fn draw(f: &mut Frame, app: &App) { + let size = f.area(); + let outer = Layout::default() + .direction(Direction::Vertical) + .constraints([Constraint::Min(0), Constraint::Length(1)]) + .split(size); + let main = Layout::default() + .direction(Direction::Horizontal) + .constraints([Constraint::Percentage(26), Constraint::Percentage(74)]) + .split(outer[0]); + + draw_left_panel(f, app, main[0]); + draw_right_panel(f, app, main[1]); + draw_status_bar(f, app, outer[1]); + + // Overlay the password popup on top of everything else + if let Some(popup) = &app.popup { + draw_password_popup(f, popup, size); + } +} + +// ── Left panel ──────────────────────────────────────────────────────────────── + +fn draw_left_panel(f: &mut Frame, app: &App, area: Rect) { + let mut items: Vec = METRICS + .iter() + .enumerate() + .map(|(i, m)| { + let val = app.current_values[i]; + let text = Text::from(vec![ + Line::from(Span::styled(m.name, Style::default().add_modifier(Modifier::BOLD))), + Line::from(Span::styled( + format!(" {}", format_value(m.kind, val)), + Style::default().fg(Color::Yellow), + )), + ]); + ListItem::new(text) + }) + .collect(); + + // Pool I/O item + let pool_summary = if app.pools.is_empty() { + " No pools detected".to_string() + } else { + let tr: f64 = app.pools.iter().map(|p| p.read_mb_s).sum(); + let tw: f64 = app.pools.iter().map(|p| p.write_mb_s).sum(); + format!(" {} pool{} R:{:.1} W:{:.1} MB/s", app.pools.len(), if app.pools.len() == 1 { "" } else { "s" }, tr, tw) + }; + items.push(left_item("Pool I/O & Processes", &pool_summary, Color::Magenta)); + + // Network I/O item + let net_summary = { + let rx: f64 = app.net_ifaces.iter() + .filter(|i| !matches!(i.kind, IfaceKind::Container | IfaceKind::Loopback)) + .map(|i| i.rx_mb_s).sum(); + let tx: f64 = app.net_ifaces.iter() + .filter(|i| !matches!(i.kind, IfaceKind::Container | IfaceKind::Loopback)) + .map(|i| i.tx_mb_s).sum(); + format!(" ↓{:.2} ↑{:.2} MB/s", rx, tx) + }; + items.push(left_item("Network I/O", &net_summary, Color::Green)); + + // Disk SMART item + let smart_summary = if app.smart_permission_error { + " Run as root for SMART".to_string() + } else if app.hwmon.is_empty() && app.smart_drives.is_empty() { + " Collecting…".to_string() + } else { + let max_temp = app.hwmon.iter().map(|s| s.temp_c).chain( + app.smart_drives.iter().filter_map(|r| r.as_ref().ok()?.temperature) + ).max(); + max_temp.map(|t| format!(" Max temp: {}°C", t)).unwrap_or_else(|| " Temps available".into()) + }; + items.push(left_item("Disk SMART & Temps", &smart_summary, Color::Yellow)); + + let mut state = ListState::default(); + state.select(Some(app.selected)); + + let list = List::new(items) + .block( + Block::default() + .title(" ZFS Stats ") + .title_style(Style::default().fg(Color::Cyan).add_modifier(Modifier::BOLD)) + .borders(Borders::ALL) + .border_style(Style::default().fg(Color::Cyan)), + ) + .highlight_style(Style::default().bg(Color::Blue).fg(Color::White).add_modifier(Modifier::BOLD)) + .highlight_symbol("▶ "); + + f.render_stateful_widget(list, area, &mut state); +} + +fn left_item(title: &str, subtitle: &str, color: Color) -> ListItem<'static> { + ListItem::new(Text::from(vec![ + Line::from(Span::styled(title.to_string(), Style::default().fg(color).add_modifier(Modifier::BOLD))), + Line::from(Span::styled(subtitle.to_string(), Style::default().fg(Color::Yellow))), + ])) +} + +// ── Right panel dispatcher ──────────────────────────────────────────────────── + +fn draw_right_panel(f: &mut Frame, app: &App, area: Rect) { + match app.selected { + POOL_IO_IDX => draw_pool_panel(f, app, area), + NETWORK_IDX => draw_network_panel(f, app, area), + SMART_IDX => draw_smart_panel(f, app, area), + _ => draw_metric_panel(f, app, area), + } +} + +// ── ARC metric panel ────────────────────────────────────────────────────────── + +fn draw_metric_panel(f: &mut Frame, app: &App, area: Rect) { + let right = Layout::default() + .direction(Direction::Vertical) + .constraints([Constraint::Length(10), Constraint::Min(0)]) + .split(area); + draw_info(f, app, right[0]); + draw_arc_chart(f, app, right[1]); +} + +fn draw_info(f: &mut Frame, app: &App, area: Rect) { + let metric = &METRICS[app.selected]; + let val = app.current_values[app.selected]; + let (min, max, avg) = app.history_stats(); + + let title = if app.error.is_some() { " Error ".to_string() } else { format!(" {} ", metric.name) }; + let mut lines: Vec = vec![Line::from("")]; + lines.push(Line::from(vec![ + Span::styled(" Current ", Style::default().fg(Color::Gray)), + Span::styled(format_value(metric.kind, val), Style::default().fg(Color::Green).add_modifier(Modifier::BOLD)), + Span::raw(" "), + Span::styled("Min ", Style::default().fg(Color::Gray)), + Span::styled(format_value(metric.kind, min), Style::default().fg(Color::Cyan)), + Span::raw(" "), + Span::styled("Max ", Style::default().fg(Color::Gray)), + Span::styled(format_value(metric.kind, max), Style::default().fg(Color::Magenta)), + Span::raw(" "), + Span::styled("Avg ", Style::default().fg(Color::Gray)), + Span::styled(format_value(metric.kind, avg), Style::default().fg(Color::Yellow)), + ])); + lines.push(Line::from("")); + for desc_line in metric.description.lines() { + lines.push(Line::from(vec![Span::raw(" "), Span::styled(desc_line.to_string(), Style::default().fg(Color::White))])); + } + if let Some(ref e) = app.error { + lines.push(Line::from(Span::styled(format!(" Error: {}", e), Style::default().fg(Color::Red)))); + } + + f.render_widget( + Paragraph::new(Text::from(lines)) + .block(Block::default().title(title).title_style(Style::default().fg(Color::Cyan).add_modifier(Modifier::BOLD)).borders(Borders::ALL).border_style(Style::default().fg(Color::Cyan))) + .wrap(Wrap { trim: false }), + area, + ); +} + +fn draw_arc_chart(f: &mut Frame, app: &App, area: Rect) { + let history = &app.history[app.selected]; + let metric = &METRICS[app.selected]; + + if history.len() < 2 { + f.render_widget( + Paragraph::new("Collecting data…") + .block(Block::default().title(format!(" {} — History ", metric.name)).title_style(Style::default().fg(Color::Cyan).add_modifier(Modifier::BOLD)).borders(Borders::ALL).border_style(Style::default().fg(Color::Cyan))) + .alignment(Alignment::Center), + area, + ); + return; + } + + let data: Vec<(f64, f64)> = history.iter().enumerate().map(|(i, &v)| (i as f64, v)).collect(); + render_line_chart(f, area, &[(&data, Color::Cyan, metric.name)], metric.unit, &format!(" {} — Last {}s ", metric.name, data.len()), Color::Cyan, |v| format_value(metric.kind, v)); +} + +// ── Pool I/O panel ──────────────────────────────────────────────────────────── + +fn draw_pool_panel(f: &mut Frame, app: &App, area: Rect) { + let pool_rows = (app.pools.len() + 3).max(5).min(12) as u16; + let split = Layout::default() + .direction(Direction::Vertical) + .constraints([Constraint::Length(pool_rows), Constraint::Min(0)]) + .split(area); + draw_pool_table(f, app, split[0]); + draw_proc_table(f, app, split[1]); +} + +fn draw_pool_table(f: &mut Frame, app: &App, area: Rect) { + let header = styled_header(&["Pool", "Health", "Used", "Size", "Use%", "Read MB/s", "Write MB/s", "R IOPS", "W IOPS"]); + let rows: Vec = app.pools.iter().map(pool_row).collect(); + let widths = [Constraint::Length(14), Constraint::Length(8), Constraint::Length(10), + Constraint::Length(10), Constraint::Length(6), Constraint::Length(10), + Constraint::Length(11), Constraint::Length(8), Constraint::Length(8)]; + + let rows_or_placeholder = if rows.is_empty() { + vec![Row::new(vec![Cell::from("No pools found")])] + } else { + rows + }; + + f.render_widget( + Table::new(rows_or_placeholder, widths).header(header) + .block(Block::default().title(" Pool I/O ").title_style(Style::default().fg(Color::Magenta).add_modifier(Modifier::BOLD)).borders(Borders::ALL).border_style(Style::default().fg(Color::Magenta))), + area, + ); +} + +fn pool_row(p: &PoolDisplay) -> Row<'static> { + let use_pct = if p.size_bytes > 0 { p.alloc_bytes as f64 / p.size_bytes as f64 * 100.0 } else { 0.0 }; + let health_style = match p.health.as_str() { + "ONLINE" => Style::default().fg(Color::Green), + "DEGRADED" => Style::default().fg(Color::Yellow), + "FAULTED" | "UNAVAIL" => Style::default().fg(Color::Red), + _ => Style::default().fg(Color::Gray), + }; + let pct_style = if use_pct > 90.0 { Style::default().fg(Color::Red) } else if use_pct > 75.0 { Style::default().fg(Color::Yellow) } else { Style::default().fg(Color::Green) }; + Row::new(vec![ + Cell::from(p.name.clone()), + Cell::from(p.health.clone()).style(health_style), + Cell::from(fmt_bytes(p.alloc_bytes)), + Cell::from(fmt_bytes(p.size_bytes)), + Cell::from(format!("{:.1}%", use_pct)).style(pct_style), + Cell::from(format!("{:.2}", p.read_mb_s)).style(Style::default().fg(Color::Cyan)), + Cell::from(format!("{:.2}", p.write_mb_s)).style(Style::default().fg(Color::Yellow)), + Cell::from(format!("{:.0}", p.read_iops)), + Cell::from(format!("{:.0}", p.write_iops)), + ]) +} + +fn draw_proc_table(f: &mut Frame, app: &App, area: Rect) { + let header = styled_header(&["PID", "Process", "Read MB/s", "Write MB/s", "Total MB/s"]); + let rows: Vec = app.top_procs.iter().map(proc_row).collect(); + let widths = [Constraint::Length(8), Constraint::Min(16), Constraint::Length(10), Constraint::Length(11), Constraint::Length(11)]; + let rows_or_placeholder = if rows.is_empty() { vec![Row::new(vec![Cell::from("No active I/O detected")])] } else { rows }; + + f.render_widget( + Table::new(rows_or_placeholder, widths).header(header) + .block(Block::default().title(" Top I/O Processes ").title_style(Style::default().fg(Color::Magenta).add_modifier(Modifier::BOLD)).borders(Borders::ALL).border_style(Style::default().fg(Color::Magenta))), + area, + ); +} + +fn proc_row(p: &ProcDisplay) -> Row<'static> { + let total = p.read_mb_s + p.write_mb_s; + let style = if total > 100.0 { Style::default().fg(Color::Red).add_modifier(Modifier::BOLD) } + else if total > 10.0 { Style::default().fg(Color::Yellow) } + else { Style::default().fg(Color::White) }; + Row::new(vec![ + Cell::from(p.pid.to_string()).style(Style::default().fg(Color::Gray)), + Cell::from(p.name.clone()), + Cell::from(format!("{:.2}", p.read_mb_s)).style(Style::default().fg(Color::Cyan)), + Cell::from(format!("{:.2}", p.write_mb_s)).style(Style::default().fg(Color::Yellow)), + Cell::from(format!("{:.2}", total)).style(style), + ]) +} + +// ── Network I/O panel ───────────────────────────────────────────────────────── + +fn draw_network_panel(f: &mut Frame, app: &App, area: Rect) { + // Table height: header + rows + borders, capped at 18 lines + let iface_count = app.net_ifaces.len().max(1); + let table_h = (iface_count + 3).min(18) as u16; + + let split = Layout::default() + .direction(Direction::Vertical) + .constraints([Constraint::Length(table_h), Constraint::Min(0)]) + .split(area); + + draw_net_table(f, app, split[0]); + draw_net_chart(f, app, split[1]); +} + +fn draw_net_table(f: &mut Frame, app: &App, area: Rect) { + let header = styled_header(&["Interface", "Type", "RX MB/s", "TX MB/s", "RX pps", "TX pps", "Errors"]); + let rows: Vec = app.net_ifaces.iter().map(net_iface_row).collect(); + let widths = [Constraint::Length(16), Constraint::Length(10), Constraint::Length(10), + Constraint::Length(10), Constraint::Length(9), Constraint::Length(9), Constraint::Length(8)]; + let rows_or_placeholder = if rows.is_empty() { vec![Row::new(vec![Cell::from("No interfaces")])] } else { rows }; + + f.render_widget( + Table::new(rows_or_placeholder, widths).header(header) + .block(Block::default().title(" Network Interfaces ").title_style(Style::default().fg(Color::Green).add_modifier(Modifier::BOLD)).borders(Borders::ALL).border_style(Style::default().fg(Color::Green))), + area, + ); +} + +fn net_iface_row(i: &NetIfaceDisplay) -> Row<'static> { + let (kind_label, kind_color) = match i.kind { + IfaceKind::Physical => ("Physical", Color::Green), + IfaceKind::Vpn => ("VPN", Color::Cyan), + IfaceKind::Virtual => ("Virtual", Color::Blue), + IfaceKind::Container => ("Container", Color::DarkGray), + IfaceKind::Other => ("Other", Color::Gray), + IfaceKind::Loopback => ("Loopback", Color::DarkGray), + }; + let err_total = i.rx_errors + i.tx_errors; + let err_style = if err_total > 0 { Style::default().fg(Color::Red) } else { Style::default().fg(Color::DarkGray) }; + Row::new(vec![ + Cell::from(i.name.clone()).style(Style::default().add_modifier(Modifier::BOLD)), + Cell::from(kind_label).style(Style::default().fg(kind_color)), + Cell::from(format!("{:.2}", i.rx_mb_s)).style(Style::default().fg(Color::Cyan)), + Cell::from(format!("{:.2}", i.tx_mb_s)).style(Style::default().fg(Color::Yellow)), + Cell::from(format!("{:.0}", i.rx_pps)), + Cell::from(format!("{:.0}", i.tx_pps)), + Cell::from(format!("{}", err_total)).style(err_style), + ]) +} + +fn draw_net_chart(f: &mut Frame, app: &App, area: Rect) { + let rx = &app.net_rx_history; + let tx = &app.net_tx_history; + + if rx.len() < 2 { + f.render_widget( + Paragraph::new("Collecting data…") + .block(Block::default().title(" Network RX / TX — History ").title_style(Style::default().fg(Color::Green).add_modifier(Modifier::BOLD)).borders(Borders::ALL).border_style(Style::default().fg(Color::Green))) + .alignment(Alignment::Center), + area, + ); + return; + } + + let rx_data: Vec<(f64, f64)> = rx.iter().enumerate().map(|(i, &v)| (i as f64, v)).collect(); + let tx_data: Vec<(f64, f64)> = tx.iter().enumerate().map(|(i, &v)| (i as f64, v)).collect(); + + render_line_chart( + f, area, + &[(&rx_data, Color::Cyan, "RX"), (&tx_data, Color::Yellow, "TX")], + "MB/s", + &format!(" Network RX/TX — Last {}s ", rx_data.len()), + Color::Green, + |v| { + if v >= 1024.0 { format!("{:.1}GB/s", v / 1024.0) } + else { format!("{:.1}MB/s", v) } + }, + ); +} + +// ── SMART / hwmon panel ─────────────────────────────────────────────────────── + +fn draw_smart_panel(f: &mut Frame, app: &App, area: Rect) { + // Top: hwmon temps | Bottom: SMART drives + let hwmon_h = (app.hwmon.len() + 3).max(5).min(14) as u16; + let split = Layout::default() + .direction(Direction::Vertical) + .constraints([Constraint::Length(hwmon_h), Constraint::Min(0)]) + .split(area); + + draw_hwmon_table(f, app, split[0]); + draw_smart_table(f, app, split[1]); +} + +fn draw_hwmon_table(f: &mut Frame, app: &App, area: Rect) { + let header = styled_header(&["Source", "Sensor", "Temp °C", "Critical"]); + let rows: Vec = app.hwmon.iter().map(hwmon_row).collect(); + let widths = [Constraint::Length(14), Constraint::Min(20), Constraint::Length(9), Constraint::Length(9)]; + let rows_or_placeholder = if rows.is_empty() { vec![Row::new(vec![Cell::from("No hwmon sensors found")])] } else { rows }; + + f.render_widget( + Table::new(rows_or_placeholder, widths).header(header) + .block(Block::default().title(" System Temperatures (hwmon) ").title_style(Style::default().fg(Color::Yellow).add_modifier(Modifier::BOLD)).borders(Borders::ALL).border_style(Style::default().fg(Color::Yellow))), + area, + ); +} + +fn hwmon_row(s: &HwmonSensor) -> Row<'static> { + let temp_style = temp_color(s.temp_c, s.crit_c); + let crit_str = s.crit_c.map(|c| format!("{}°C", c)).unwrap_or_else(|| "—".into()); + Row::new(vec![ + Cell::from(s.source.clone()).style(Style::default().fg(Color::Gray)), + Cell::from(s.label.clone()), + Cell::from(format!("{}°C", s.temp_c)).style(temp_style), + Cell::from(crit_str).style(Style::default().fg(Color::DarkGray)), + ]) +} + +fn draw_smart_table(f: &mut Frame, app: &App, area: Rect) { + let title = if app.smart_permission_error { + " Drive SMART [run as root for drive data] " + } else { + " Drive SMART " + }; + + let header = styled_header(&["Device", "Type", "Model", "Health", "Temp", "Power-On h", "Realloc", "Pending"]); + let widths = [Constraint::Length(10), Constraint::Length(6), Constraint::Min(18), + Constraint::Length(8), Constraint::Length(7), Constraint::Length(11), + Constraint::Length(8), Constraint::Length(8)]; + + let rows: Vec = app.smart_drives.iter().map(|r| match r { + Ok(d) => smart_drive_row(d), + Err(e) => Row::new(vec![Cell::from("?"), Cell::from(""), Cell::from(""), Cell::from(""), + Cell::from(""), Cell::from(""), Cell::from(""), + Cell::from(e.clone()).style(Style::default().fg(Color::Red))]), + }).collect(); + + let rows_or_placeholder = if rows.is_empty() { + vec![Row::new(vec![ + Cell::from(if app.smart_permission_error { + "Run as root (sudo zfs-stats) to read SMART data" + } else { + "No drives detected — install smartmontools" + }), + ])] + } else { + rows + }; + + f.render_widget( + Table::new(rows_or_placeholder, widths).header(header) + .block(Block::default().title(title).title_style(Style::default().fg(Color::Yellow).add_modifier(Modifier::BOLD)).borders(Borders::ALL).border_style(Style::default().fg(Color::Yellow))), + area, + ); +} + +fn smart_drive_row(d: &SmartDrive) -> Row<'static> { + let type_label = match d.drive_type { + DriveType::Ata => "SATA", + DriveType::Nvme => "NVMe", + DriveType::Scsi => "SAS", + DriveType::Unknown => "?", + }; + let health_style = match d.health.as_str() { + "PASSED" => Style::default().fg(Color::Green), + "FAILED" => Style::default().fg(Color::Red).add_modifier(Modifier::BOLD), + _ => Style::default().fg(Color::Gray), + }; + let temp_cell = match d.temperature { + Some(t) => Cell::from(format!("{}°C", t)).style(temp_color(t, None)), + None => Cell::from("—").style(Style::default().fg(Color::DarkGray)), + }; + let realloc_style = |n: Option| match n { + Some(0) | None => Style::default().fg(Color::DarkGray), + _ => Style::default().fg(Color::Red).add_modifier(Modifier::BOLD), + }; + Row::new(vec![ + Cell::from(d.device.clone()), + Cell::from(type_label), + Cell::from(d.model.clone()), + Cell::from(d.health.clone()).style(health_style), + temp_cell, + Cell::from(d.power_on_hours.map(|h| format!("{}", h)).unwrap_or_else(|| "—".into())), + Cell::from(d.reallocated.map(|n| format!("{}", n)).unwrap_or_else(|| "—".into())) + .style(realloc_style(d.reallocated)), + Cell::from(d.pending.map(|n| format!("{}", n)).unwrap_or_else(|| "—".into())) + .style(realloc_style(d.pending)), + ]) +} + +// ── Password popup ──────────────────────────────────────────────────────────── + +fn draw_password_popup(f: &mut Frame, popup: &PasswordPopup, area: Rect) { + let popup_area = centered_rect(54, 9, area); + + // Clear the region behind the popup so it stands out + f.render_widget(Clear, popup_area); + + let stars: String = "•".repeat(popup.input.len()); + // Blinking block cursor effect + let cursor = Span::styled("█", Style::default().fg(Color::Yellow)); + + let mut lines: Vec = vec![ + Line::from(""), + Line::from(vec![ + Span::styled(" Password: ", Style::default().fg(Color::Gray)), + Span::styled(stars, Style::default().fg(Color::White).add_modifier(Modifier::BOLD)), + cursor, + ]), + Line::from(""), + ]; + + if let Some(ref err) = popup.error { + lines.push(Line::from(Span::styled( + format!(" ✗ {}", err), + Style::default().fg(Color::Red).add_modifier(Modifier::BOLD), + ))); + } else { + lines.push(Line::from(Span::styled( + " SMART data requires elevated privileges", + Style::default().fg(Color::DarkGray), + ))); + } + + lines.push(Line::from("")); + lines.push(Line::from(vec![ + Span::raw(" "), + Span::styled("[Enter]", Style::default().fg(Color::Green).add_modifier(Modifier::BOLD)), + Span::raw(" Confirm "), + Span::styled("[Esc]", Style::default().fg(Color::Yellow).add_modifier(Modifier::BOLD)), + Span::raw(" Cancel"), + ])); + + f.render_widget( + Paragraph::new(Text::from(lines)) + .block( + Block::default() + .title(" 🔒 sudo password for SMART ") + .title_style(Style::default().fg(Color::Yellow).add_modifier(Modifier::BOLD)) + .borders(Borders::ALL) + .border_style(Style::default().fg(Color::Yellow)), + ) + .wrap(Wrap { trim: false }), + popup_area, + ); +} + +fn centered_rect(width: u16, height: u16, area: Rect) -> Rect { + Rect { + x: area.x + area.width.saturating_sub(width) / 2, + y: area.y + area.height.saturating_sub(height) / 2, + width: width.min(area.width), + height: height.min(area.height), + } +} + +// ── Status bar ──────────────────────────────────────────────────────────────── + +fn draw_status_bar(f: &mut Frame, app: &App, area: Rect) { + let msg = if app.selected == SMART_IDX && app.smart_permission_error { + " ZFS Stats [!] SMART needs root — press p to enter sudo password q Quit" + } else if app.error.is_some() || app.pool_error.is_some() { + " ZFS Stats [!] Some stats unavailable ↑/↓ or j/k Navigate q Quit" + } else { + " ZFS Stats ↑/↓ or j/k Navigate q Quit" + }; + f.render_widget(Paragraph::new(msg).style(Style::default().bg(Color::DarkGray).fg(Color::White)), area); +} + +// ── Shared chart renderer ───────────────────────────────────────────────────── + +/// Render one or more line series on a single Chart widget. +fn render_line_chart( + f: &mut Frame, + area: Rect, + series: &[(&[(f64, f64)], Color, &str)], + y_unit: &str, + title: &str, + border_color: Color, + fmt_y: F, +) where + F: Fn(f64) -> String, +{ + // Compute Y bounds across all series + let y_max = series.iter().flat_map(|(d, _, _)| d.iter().map(|(_, v)| *v)).fold(f64::MIN, f64::max); + let y_min = series.iter().flat_map(|(d, _, _)| d.iter().map(|(_, v)| *v)).fold(f64::MAX, f64::min); + let y_range = (y_max - y_min).max(0.001); + let y_top = y_max + y_range * 0.1; + let y_bot = (y_min - y_range * 0.1).max(0.0); + let y_mid = (y_top + y_bot) / 2.0; + + let x_max = series.iter().map(|(d, _, _)| d.len()).max().unwrap_or(1) as f64 - 1.0; + let x_mid = (x_max / 2.0) as usize; + + let datasets: Vec = series + .iter() + .map(|(data, color, name)| { + Dataset::default() + .name(*name) + .marker(symbols::Marker::Braille) + .graph_type(GraphType::Line) + .style(Style::default().fg(*color)) + .data(data) + }) + .collect(); + + let chart = Chart::new(datasets) + .block( + Block::default() + .title(title) + .title_style(Style::default().fg(border_color).add_modifier(Modifier::BOLD)) + .borders(Borders::ALL) + .border_style(Style::default().fg(border_color)), + ) + .x_axis( + Axis::default() + .title(Span::styled("Time (s)", Style::default().fg(Color::Gray))) + .style(Style::default().fg(Color::DarkGray)) + .bounds([0.0, x_max.max(1.0)]) + .labels(vec![ + Span::raw("0"), + Span::raw(format!("{}s", x_mid)), + Span::raw(format!("{}s", x_max as usize)), + ]), + ) + .y_axis( + Axis::default() + .title(Span::styled(y_unit, Style::default().fg(Color::Gray))) + .style(Style::default().fg(Color::DarkGray)) + .bounds([y_bot, y_top]) + .labels(vec![ + Span::raw(fmt_y(y_bot)), + Span::raw(fmt_y(y_mid)), + Span::raw(fmt_y(y_top)), + ]), + ); + + f.render_widget(chart, area); +} + +// ── Formatting helpers ──────────────────────────────────────────────────────── + +pub fn format_value(kind: MetricKind, val: f64) -> String { + match kind { + MetricKind::ArcHitRatio | MetricKind::L2HitRatio | MetricKind::ArcL2CacheUsage => format!("{:.2}%", val), + MetricKind::ArcL2Rate => if val >= 1024.0 { format!("{:.2} GB/s", val / 1024.0) } else { format!("{:.2} MB/s", val) }, + MetricKind::ArcSize | MetricKind::ArcL2Size => { + if val >= 1024.0 { format!("{:.2} GB", val / 1024.0) } else { format!("{:.1} MB", val) } + } + } +} + +fn fmt_bytes(b: u64) -> String { + const TB: u64 = 1_099_511_627_776; + const GB: u64 = 1_073_741_824; + const MB: u64 = 1_048_576; + const KB: u64 = 1_024; + if b >= TB { format!("{:.2} TB", b as f64 / TB as f64) } + else if b >= GB { format!("{:.2} GB", b as f64 / GB as f64) } + else if b >= MB { format!("{:.1} MB", b as f64 / MB as f64) } + else if b >= KB { format!("{:.1} KB", b as f64 / KB as f64) } + else { format!("{} B", b) } +} + +fn temp_color(temp_c: i32, crit_c: Option) -> Style { + let crit = crit_c.unwrap_or(90); + if temp_c >= crit { Style::default().fg(Color::Red).add_modifier(Modifier::BOLD) } + else if temp_c >= crit - 20 { Style::default().fg(Color::Yellow) } + else { Style::default().fg(Color::Green) } +} + +fn styled_header(labels: &[&str]) -> Row<'static> { + Row::new(labels.iter().map(|l| { + Cell::from(l.to_string()).style(Style::default().fg(Color::Cyan).add_modifier(Modifier::BOLD)) + }).collect::>()) + .height(1) + .style(Style::default().add_modifier(Modifier::UNDERLINED)) +} diff --git a/src/zfs.rs b/src/zfs.rs new file mode 100644 index 0000000..7ba7551 --- /dev/null +++ b/src/zfs.rs @@ -0,0 +1,44 @@ +use std::collections::HashMap; +use std::fs; + +#[derive(Debug, Clone, Default)] +pub struct ArcStats { + pub hits: u64, + pub misses: u64, + pub l2_hits: u64, + pub l2_misses: u64, + pub size: u64, + pub l2_size: u64, + pub l2_read_bytes: u64, +} + +pub fn read_arcstats() -> anyhow::Result { + let content = fs::read_to_string("/proc/spl/kstat/zfs/arcstats")?; + let mut map = HashMap::new(); + + // File format: header line, type-line, then "name type value" rows + for line in content.lines().skip(2) { + let mut parts = line.split_whitespace(); + let name = match parts.next() { + Some(n) => n, + None => continue, + }; + // skip the type column + parts.next(); + if let Some(val_str) = parts.next() { + if let Ok(val) = val_str.parse::() { + map.insert(name.to_string(), val); + } + } + } + + Ok(ArcStats { + hits: map.get("hits").copied().unwrap_or(0), + misses: map.get("misses").copied().unwrap_or(0), + l2_hits: map.get("l2_hits").copied().unwrap_or(0), + l2_misses: map.get("l2_misses").copied().unwrap_or(0), + size: map.get("size").copied().unwrap_or(0), + l2_size: map.get("l2_size").copied().unwrap_or(0), + l2_read_bytes: map.get("l2_read_bytes").copied().unwrap_or(0), + }) +}