diff --git a/Cargo.lock b/Cargo.lock index 913b000..1b2c9a0 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -11,6 +11,12 @@ dependencies = [ "memchr", ] +[[package]] +name = "allocator-api2" +version = "0.2.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "683d7910e743518b0e34f1186f92494becacb047c7b6bf616c96772180fef923" + [[package]] name = "android_system_properties" version = "0.1.5" @@ -76,6 +82,15 @@ version = "1.0.104" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "330a5ed07fa54e4702c9d6c4174f74427fc0ef6e214bbd677ae50a5099946470" +[[package]] +name = "approx" +version = "0.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cab112f0a86d568ea0e627cc1d6be74a1e9cd55214684db5561995f6dad897c6" +dependencies = [ + "num-traits", +] + [[package]] name = "arc-swap" version = "1.9.2" @@ -96,6 +111,15 @@ dependencies = [ "syn 3.0.0", ] +[[package]] +name = "atomic" +version = "0.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a89cbf775b137e9b968e67227ef7f775587cde3fd31b0d8599dbd0f598a48340" +dependencies = [ + "bytemuck", +] + [[package]] name = "atomic-waker" version = "1.1.2" @@ -175,6 +199,27 @@ 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.13.1" @@ -196,12 +241,33 @@ version = "3.20.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "72f5acc6cb2ba439de613abc23857ec3d78374d8ed5ac84e9d11336e87da8649" +[[package]] +name = "by_address" +version = "1.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "64fa3c856b712db6612c019f14756e64e4bcea13337a6b33b696333a9eaa2d06" + +[[package]] +name = "bytemuck" +version = "1.25.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "95832e849adfb21180ccb6826a99da14e5d266ae5c2e668e1602cf234f153797" + [[package]] name = "bytes" version = "1.12.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "fc652a48c352aef3ea3aed32080501cf3ef6ed5da78602a020c991775b0aff04" +[[package]] +name = "castaway" +version = "0.2.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dec551ab6e7578819132c713a93c022a05d60159dc86e7a7050223577484c55a" +dependencies = [ + "rustversion", +] + [[package]] name = "cc" version = "1.3.0" @@ -309,6 +375,29 @@ dependencies = [ "tokio-util", ] +[[package]] +name = "compact_str" +version = "0.9.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9dfdd1c2274d9aa354115b09dc9a901d6c5576818cdf70d14cae2bdb47df00ab" +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 = "core-foundation-sys" version = "0.8.7" @@ -333,12 +422,45 @@ dependencies = [ "libc", ] +[[package]] +name = "critical-section" +version = "1.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "790eea4361631c5e7d22598ecd5723ff611904e3344ce8720784c93e3d83d40b" + [[package]] name = "crossbeam-utils" version = "0.8.22" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "61803da095bee82a81bb1a452ecc25d3b2f1416d1897eb86430c6159ef717c17" +[[package]] +name = "crossterm" +version = "0.29.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d8b9f2e4c67f833b660cdb0a3523065869fb35570177239812ed4c905aeff87b" +dependencies = [ + "bitflags 2.13.1", + "crossterm_winapi", + "derive_more", + "document-features", + "mio", + "parking_lot", + "rustix 1.1.4", + "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" @@ -349,6 +471,50 @@ dependencies = [ "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.24.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ed17f5901b6630b993ca003def43f2f8ef4014fc13b047b57aad617ff32bc2ec" +dependencies = [ + "darling_core", + "darling_macro", +] + +[[package]] +name = "darling_core" +version = "0.24.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6837e2cf7485aaae18f86181d2f0e9a7ed297a025e220aeabf63fdebd3a2ddff" +dependencies = [ + "ident_case", + "proc-macro2", + "quote", + "strsim", + "syn 3.0.0", +] + +[[package]] +name = "darling_macro" +version = "0.24.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2ac7135c3ef02b2f7833bbeb1be5ba7f966dcde8a87c6b87f65a778d71a02785" +dependencies = [ + "darling_core", + "quote", + "syn 3.0.0", +] + [[package]] name = "dashmap" version = "6.2.1" @@ -363,12 +529,40 @@ dependencies = [ "parking_lot_core", ] +[[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" +[[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.119", +] + [[package]] name = "digest" version = "0.10.7" @@ -390,6 +584,15 @@ dependencies = [ "syn 2.0.119", ] +[[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.16.0" @@ -412,24 +615,72 @@ dependencies = [ "windows-sys 0.61.2", ] +[[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 = "fastrand" version = "2.4.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9f1f227452a390804cdb637b74a86990f2a7d7ba4b7d5693aac9b4dd6defd8d6" +[[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 = "find-msvc-tools" version = "0.1.9" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "5baebc0774151f905a1a2cc41989300b1e6fbb29aff0ceffa1064fdd3088d582" +[[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.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "77ce24cb58228fbb8aa041425bb1050850ac19177686ea6e0f41a70416f56fdb" + [[package]] name = "form_urlencoded" version = "1.2.2" @@ -550,6 +801,18 @@ dependencies = [ "wasm-bindgen", ] +[[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.3" @@ -559,7 +822,7 @@ dependencies = [ "cfg-if", "js-sys", "libc", - "r-efi", + "r-efi 6.0.0", "rand_core 0.10.1", "wasm-bindgen", ] @@ -570,11 +833,27 @@ version = "0.14.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e5274423e17b7c9fc20b6e7e208532f9b19825d82dfd615708b70edd83df41f1" +[[package]] +name = "hashbrown" +version = "0.16.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "841d1cc9bed7f9236f321df977030373f4a4163ae1a7dbfe1a51a2c1a51d9100" +dependencies = [ + "allocator-api2", + "equivalent", + "foldhash", +] + [[package]] name = "hashbrown" version = "0.17.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ed5909b6e89a2db4456e54cd5f673791d7eca6732202bbf2a9cc504fe2f9b84a" +dependencies = [ + "allocator-api2", + "equivalent", + "foldhash", +] [[package]] name = "heck" @@ -799,6 +1078,12 @@ dependencies = [ "zerovec", ] +[[package]] +name = "ident_case" +version = "1.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b9e0384b61958566e926dc50660321d12159025e767c18e043daf26b70104c39" + [[package]] name = "idna" version = "1.1.0" @@ -830,6 +1115,28 @@ dependencies = [ "hashbrown 0.17.1", ] +[[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.13" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2bf84e73fa6f27f299dec58e13223cf70db80da872eb921d4f6138342a0eabc8" +dependencies = [ + "darling", + "indoc", + "proc-macro2", + "quote", + "syn 3.0.0", +] + [[package]] name = "ipnet" version = "2.12.0" @@ -857,6 +1164,15 @@ dependencies = [ "either", ] +[[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" @@ -889,6 +1205,23 @@ dependencies = [ "simple_asn1", ] +[[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.19", +] + +[[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" @@ -901,7 +1234,7 @@ version = "0.24.8" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "93edd9cd673087fa7518fd63ad6c87be2cd9b4e35034b1873f3e3258c018275b" dependencies = [ - "bitflags", + "bitflags 2.13.1", "libbpf-sys", "libc", "vsprintf", @@ -914,7 +1247,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a109478760b2900aa2a6f2087e9d0de1d9c535b1758602af2845d5d2ccfaed7c" dependencies = [ "cc", - "nix", + "nix 0.31.3", "pkg-config", ] @@ -924,18 +1257,45 @@ version = "0.2.186" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "68ab91017fe16c622486840e4c83c9a37afeff978bd239b5293d61ece587de66" +[[package]] +name = "libm" +version = "0.2.16" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6d2cec3eae94f9f509c767b45932f1ada8350c4bdb85af2fcab4a3c14807981" + +[[package]] +name = "line-clipping" +version = "0.3.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e752191d037c44ad111a8caa762921926658402f01cc1253f7bef2020ece4f5e" +dependencies = [ + "bitflags 2.13.1", +] + [[package]] name = "linux-raw-sys" version = "0.4.15" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d26c52dbd32dccf2d10cac7725f8eae5296885fb5703b261f7d0a0739ec807ab" +[[package]] +name = "linux-raw-sys" +version = "0.12.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32a66949e030da00e8c7d4434b251670a91556f4144941d37452769c25d58a53" + [[package]] name = "litemap" version = "0.8.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "92daf443525c4cce67b150400bc2316076100ce0b3686209eb8cf3c31612e6f0" +[[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" @@ -951,12 +1311,31 @@ version = "0.4.33" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0ceec5bc11778974d1bcb055b18002eba7f4b3518b6a0081b3af5f21666da9ad" +[[package]] +name = "lru" +version = "0.18.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5d2f2f9b4ba7e6b24d95e7e899329d35be83bcded72c8540cdd5368932d1d90a" +dependencies = [ + "hashbrown 0.17.1", +] + [[package]] name = "lru-slab" version = "0.1.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "112b39cec0b298b6c1999fee3e31427f74f676e4cb9879ed1a121b43661a4154" +[[package]] +name = "mac_address" +version = "1.1.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c0aeb26bf5e836cc1c341c8106051b573f1766dfa05aa87f0b98be5e51b02303" +dependencies = [ + "nix 0.29.0", + "winapi", +] + [[package]] name = "matchers" version = "0.2.0" @@ -990,12 +1369,33 @@ version = "2.8.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "cf8baf1c55e62ffcace7a9f06f4bd9cd3f0c4beb022d3b367256b91b87513d98" +[[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 = "mime" version = "0.3.17" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "6877bb514081ee2a7ff5ef9de3281f14a4dd4bceac4c09388074a6b5df8a139a" +[[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.2" @@ -1003,22 +1403,46 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "30d65c71f1ce40ab09135ce117d742b9f8a19ff91a41a8b57ed50bc2de59c427" dependencies = [ "libc", + "log", "wasi", "windows-sys 0.61.2", ] +[[package]] +name = "nix" +version = "0.29.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "71e2746dc3a24dd78b3cfcb7be93368c6de9963d30f43a6a73998a9cf4b17b46" +dependencies = [ + "bitflags 2.13.1", + "cfg-if", + "cfg_aliases", + "libc", + "memoffset", +] + [[package]] name = "nix" version = "0.31.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "cf20d2fde8ff38632c426f1165ed7436270b44f199fc55284c38276f9db47c3d" dependencies = [ - "bitflags", + "bitflags 2.13.1", "cfg-if", "cfg_aliases", "libc", ] +[[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 = "nu-ansi-term" version = "0.50.3" @@ -1044,6 +1468,17 @@ version = "0.2.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "521739c6d2bac4aa25192232afe6841231376b2b26d4d9fae5ecf8ca5772e441" +[[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.119", +] + [[package]] name = "num-integer" version = "0.1.46" @@ -1062,6 +1497,15 @@ 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" @@ -1074,6 +1518,48 @@ version = "1.70.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "384b8ab6d37215f3c5301a95a4accb5d64aa607f1fcb26a11b5303878451b4fe" +[[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 = "palette" +version = "0.7.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ddeed8580d347d2abf3dcf06a5f0b3dc020258338526b277847cd4248a70fc64" +dependencies = [ + "approx", + "libm", + "palette_derive", + "palette_math", +] + +[[package]] +name = "palette_derive" +version = "0.7.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "88537020289b719d81be994ccf1bbf4990f477e2f69ee52fe3e45f43a02e56be" +dependencies = [ + "by_address", + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "palette_math" +version = "0.7.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6e6eb142958d64335fb0e345c5b9ead2ecd6fc438c307e9d7d3c4fd428dbaf12" +dependencies = [ + "libm", +] + [[package]] name = "parking_lot" version = "0.12.5" @@ -1113,6 +1599,100 @@ version = "2.3.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9b4f627cb1b25917193a259e49bdad08f671f8d9708acfd5fe0a8c1455d87220" +[[package]] +name = "pest" +version = "2.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5a07a60cc7a4d00c91f95c685609d1d2f79050e6804b70ebedd7650f0b839bcf" +dependencies = [ + "memchr", + "ucd-trie", +] + +[[package]] +name = "pest_derive" +version = "2.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b3a83744a5c8455b8b3e0dc5031362780a347c878bdd11584d1a8984228cc88d" +dependencies = [ + "pest", + "pest_generator", +] + +[[package]] +name = "pest_generator" +version = "2.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e0cd3451aa3de60d4b9a1e736885e4dea6b31617598026f12256ad566d63304a" +dependencies = [ + "pest", + "pest_meta", + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "pest_meta" +version = "2.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e04d3a0849e241d7dfce834c83b1c5edc8622009e8dd51a12ba1927c32f05496" +dependencies = [ + "pest", +] + +[[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 0.8.7", +] + +[[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.119", +] + +[[package]] +name = "phf_shared" +version = "0.11.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "67eabc2ef2a60eb7faa00097bd1ffdb5bd28e62bf39990626a582201b7a754e5" +dependencies = [ + "siphasher", +] + [[package]] name = "pin-project-lite" version = "0.2.17" @@ -1125,6 +1705,12 @@ version = "0.3.33" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "19f132c84eca552bf34cab8ec81f1c1dcc229b811638f9d283dceabe58c5569e" +[[package]] +name = "portable-atomic" +version = "1.15.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "05c8b63e8d9609db387f0324918f81d68fe27748f084ef092fb35954d0539a85" + [[package]] name = "potential_utf" version = "0.1.5" @@ -1164,10 +1750,10 @@ version = "0.17.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "cc5b72d8145275d844d4b5f6d4e1eef00c8cd889edb6035c21675d1bb1f45c9f" dependencies = [ - "bitflags", + "bitflags 2.13.1", "hex", "procfs-core", - "rustix", + "rustix 0.38.44", ] [[package]] @@ -1176,7 +1762,7 @@ version = "0.17.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "239df02d8349b06fc07398a3a1697b06418223b1c7725085e801e7c0fc6a12ec" dependencies = [ - "bitflags", + "bitflags 2.13.1", "hex", ] @@ -1230,7 +1816,7 @@ dependencies = [ "quinn-udp", "rustc-hash", "rustls", - "socket2 0.5.10", + "socket2 0.6.5", "thiserror 2.0.19", "tokio", "tracing", @@ -1268,9 +1854,9 @@ dependencies = [ "cfg_aliases", "libc", "once_cell", - "socket2 0.5.10", + "socket2 0.6.5", "tracing", - "windows-sys 0.52.0", + "windows-sys 0.61.2", ] [[package]] @@ -1282,6 +1868,12 @@ 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" @@ -1296,6 +1888,7 @@ dependencies = [ "axum", "chrono", "clap", + "crossterm", "dashmap", "futures", "hex", @@ -1305,6 +1898,7 @@ dependencies = [ "maxminddb", "prometheus", "rand 0.8.7", + "ratatui", "redis", "reqwest", "serde", @@ -1377,6 +1971,107 @@ dependencies = [ "rand_core 0.10.1", ] +[[package]] +name = "ratatui" +version = "0.30.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3274ba0a2c5e1bcad2a2005d20f4dc59dad26b2eb0940fb094500dba4099d57d" +dependencies = [ + "instability", + "ratatui-core", + "ratatui-crossterm", + "ratatui-macros", + "ratatui-termina", + "ratatui-termwiz", + "ratatui-widgets", + "serde", +] + +[[package]] +name = "ratatui-core" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cbb175c433c8e28a809d1f5773a2ae96e68c0ce40db865cbab1020bf33ae479c" +dependencies = [ + "bitflags 2.13.1", + "compact_str", + "critical-section", + "hashbrown 0.17.1", + "itertools 0.14.0", + "kasuari", + "lru", + "palette", + "serde", + "strum", + "thiserror 2.0.19", + "unicode-segmentation", + "unicode-truncate", + "unicode-width", +] + +[[package]] +name = "ratatui-crossterm" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "567584a3b0e6a8203c23de40b4861497266725eb5363dbfd18a1edd603cca9f0" +dependencies = [ + "cfg-if", + "crossterm", + "instability", + "ratatui-core", +] + +[[package]] +name = "ratatui-macros" +version = "0.7.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ed7dc68daa7498a43e4d68e0eb078427e10c38fbcfbb1e42d955f1fa2140d814" +dependencies = [ + "ratatui-core", + "ratatui-widgets", +] + +[[package]] +name = "ratatui-termina" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c0bf912d9e66f057a759d92e386a280ea886b352ab757d6ac4d653c7ed2c43c2" +dependencies = [ + "instability", + "ratatui-core", + "termina", +] + +[[package]] +name = "ratatui-termwiz" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "faf03e0380b7744054d6cb74224fe3adf062a029754933f575ca1e3b4c2ce977" +dependencies = [ + "ratatui-core", + "termwiz", +] + +[[package]] +name = "ratatui-widgets" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "66e3d19bcc9130ca376277d93b60767ff121ace3be06f5f95f81dd68956407d1" +dependencies = [ + "bitflags 2.13.1", + "hashbrown 0.17.1", + "indoc", + "instability", + "itertools 0.14.0", + "line-clipping", + "ratatui-core", + "serde", + "strum", + "time", + "unicode-segmentation", + "unicode-width", +] + [[package]] name = "redis" version = "0.27.6" @@ -1390,7 +2085,7 @@ dependencies = [ "combine", "futures", "futures-util", - "itertools", + "itertools 0.13.0", "itoa", "num-bigint", "percent-encoding", @@ -1409,7 +2104,19 @@ version = "0.5.18" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ed2bf2547551a7053d6fdfafda3f938979645c44812fbfcda098faae3f1a362d" dependencies = [ - "bitflags", + "bitflags 2.13.1", +] + +[[package]] +name = "regex" +version = "1.13.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f020237b6c8eed93db2e2cb53c00c60a8e1bc73da7d073199a1180401450218d" +dependencies = [ + "aho-corasick", + "memchr", + "regex-automata", + "regex-syntax", ] [[package]] @@ -1487,19 +2194,41 @@ version = "2.1.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "6b1e7f9a428571be2dc5bc0505c13fb6bf936822b894ec87abf8a08a4e51742d" +[[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 = "0.38.44" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "fdb5bc1ae2baa591800df16c9ca78619bf65c0488b41b96ccec5d11220d8c154" dependencies = [ - "bitflags", + "bitflags 2.13.1", "errno", "libc", - "linux-raw-sys", + "linux-raw-sys 0.4.15", "windows-sys 0.59.0", ] +[[package]] +name = "rustix" +version = "1.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6fe4565b9518b83ef4f91bb47ce29620ca828bd32cb7e408f0062e9930ba190" +dependencies = [ + "bitflags 2.13.1", + "errno", + "libc", + "linux-raw-sys 0.12.1", + "windows-sys 0.61.2", +] + [[package]] name = "rustls" version = "0.23.42" @@ -1553,6 +2282,12 @@ 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.229" @@ -1660,6 +2395,27 @@ version = "2.0.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f8fadd59c855ef2080decdef8ff161eb6661b86933c9d82e5ba29dc602a55aba" +[[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" @@ -1682,6 +2438,12 @@ dependencies = [ "time", ] +[[package]] +name = "siphasher" +version = "1.0.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8ee5873ec9cce0195efcb7a4e9507a04cd49aec9c83d0389df45b1ef7ba2e649" + [[package]] name = "slab" version = "0.4.12" @@ -1720,18 +2482,56 @@ version = "1.2.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "6ce2be8dc25455e1f91df71bfa12ad37d7af1092ae736f3a6cd0e37bc7810596" +[[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.28.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9628de9b8791db39ceda2b119bbe13134770b56c138ec1d3af810d045c04f9bd" +dependencies = [ + "strum_macros", +] + +[[package]] +name = "strum_macros" +version = "0.28.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ab85eea0270ee17587ed4156089e10b9e6880ee688791d45a905f5b1ca36f664" +dependencies = [ + "heck", + "proc-macro2", + "quote", + "syn 2.0.119", +] + [[package]] name = "subtle" version = "2.6.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "13c2bddecc57b384dee18652358fb23172facb8a2c51ccc10d74c157bdea3292" +[[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.119" @@ -1774,6 +2574,82 @@ dependencies = [ "syn 2.0.119", ] +[[package]] +name = "termina" +version = "0.3.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9048a889effe34a5cddee0af7f53285198b16dca3be510858d38dfdb3e62a04e" +dependencies = [ + "bitflags 2.13.1", + "parking_lot", + "rustix 1.1.4", + "signal-hook", + "windows-sys 0.61.2", +] + +[[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.13.1", + "fancy-regex", + "filedescriptor", + "finl_unicode", + "fixedbitset", + "hex", + "lazy_static", + "libc", + "log", + "memmem", + "nix 0.29.0", + "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" @@ -1830,7 +2706,9 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "18dfaaeddcb932337b5e7866ee7d0ce9b76d2fd092997146f187ec09b4558a50" dependencies = [ "deranged", + "libc", "num-conv", + "num_threads", "powerfmt", "serde_core", "time-core", @@ -2002,7 +2880,7 @@ version = "0.6.11" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "4cfcf7e2740e6fc6d4d688b4ef00650406bb94adf4731e43c096c3a19fe40840" dependencies = [ - "bitflags", + "bitflags 2.13.1", "bytes", "futures-util", "http", @@ -2113,12 +2991,41 @@ version = "1.20.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b6f5e870be6c3b371b77fe0ee0bafb859fa4964b4404c27de1d380043c4dda20" +[[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.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c6f5d3c3b1bf09027a88a6bc961fc00497d651009560b5463668dc81b0fa87a8" + +[[package]] +name = "unicode-truncate" +version = "2.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "16b380a1238663e5f8a691f9039c73e1cdae598a30e9855f541d29b08b53e9a5" +dependencies = [ + "itertools 0.14.0", + "unicode-segmentation", + "unicode-width", +] + +[[package]] +name = "unicode-width" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b4ac048d71ede7ee76d585517add45da530660ef4390e49b098733c6e897f254" + [[package]] name = "untrusted" version = "0.9.0" @@ -2149,6 +3056,18 @@ version = "0.2.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "06abde3611657adf66d383f00b093d7faecc7fa57071cce2578660c9f1010821" +[[package]] +name = "uuid" +version = "1.25.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f053576934f05a761a402421fbbe3d425d9366f75f978806a037b3ca481abecc" +dependencies = [ + "atomic", + "getrandom 0.4.3", + "js-sys", + "wasm-bindgen", +] + [[package]] name = "valuable" version = "0.1.1" @@ -2171,6 +3090,15 @@ dependencies = [ "libc", ] +[[package]] +name = "vtparse" +version = "0.6.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6d9b2acfb050df409c972a37d3b8e08cdea3bddb0c09db9d53137e504cfabed0" +dependencies = [ + "utf8parse", +] + [[package]] name = "want" version = "0.3.1" @@ -2186,6 +3114,15 @@ 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.4+wasi-0.2.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b67efb37e106e55ce722a510d6b5f9c17f083e5fc79afc2badeb12cc313d9487" +dependencies = [ + "wit-bindgen", +] + [[package]] name = "wasm-bindgen" version = "0.2.126" @@ -2270,6 +3207,100 @@ dependencies = [ "rustls-pki-types", ] +[[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-core" version = "0.62.2" @@ -2429,6 +3460,12 @@ dependencies = [ "memchr", ] +[[package]] +name = "wit-bindgen" +version = "0.57.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1ebf944e87a7c253233ad6766e082e3cd714b5d03812acc24c318f549614536e" + [[package]] name = "writeable" version = "0.6.3" diff --git a/Cargo.toml b/Cargo.toml index 36897e1..6c442e7 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -62,6 +62,10 @@ rand = { version = "0.8", default-features = false, features = ["std", "std_rng" clap = { version = "4", features = ["derive"] } chrono = { version = "0.4", features = ["serde"] } +# TUI (rampart-tui): лёгкие deps, без feature-gate +ratatui = "0.30" +crossterm = "0.29" + axum = "0.8" tower-http = { version = "0.6", features = ["cors"] } jsonwebtoken = "9" diff --git a/src/bin/rampart-tui.rs b/src/bin/rampart-tui.rs new file mode 100644 index 0000000..4c10538 --- /dev/null +++ b/src/bin/rampart-tui.rs @@ -0,0 +1,64 @@ +//! `rampart-tui` — интерактивный терминальный дашборд live-метрик Rampart. + +use std::sync::Arc; +use std::time::Duration; + +use clap::Parser; +use ratatui::Terminal; +use ratatui::backend::CrosstermBackend; +use tokio::sync::Notify; + +use rampart::tui::app; +use rampart::tui::fetch::{Fetcher, HttpFetcher}; +use rampart::tui::state::AppState; + +#[derive(Parser)] +#[command( + name = "rampart-tui", + about = "Rampart live metrics TUI (Prometheus /metrics source)" +)] +struct Cli { + /// URL Prometheus metrics endpoint + #[arg(long, default_value = "http://127.0.0.1:9090/metrics")] + url: String, + /// Период опроса, секунды + #[arg(long, default_value_t = 1)] + refresh_secs: u64, +} + +fn main() -> anyhow::Result<()> { + let cli = Cli::parse(); + let Some(fetcher) = HttpFetcher::new(cli.url.clone()) else { + anyhow::bail!("failed to build http client"); + }; + let runtime = tokio::runtime::Builder::new_current_thread().enable_all().build()?; + + let terminal = ratatui::try_init()?; + let result = runtime.block_on(run( + terminal, + Arc::new(fetcher) as Arc, + cli.url, + Duration::from_secs(cli.refresh_secs.max(1)), + )); + ratatui::restore(); + result +} + +async fn run( + mut terminal: Terminal>, + fetcher: Arc, + url: String, + refresh: Duration, +) -> anyhow::Result<()> { + let state = Arc::new(AppState::new(url)); + let notify = Arc::new(Notify::new()); + tokio::spawn(app::spawn_fetch_loop( + fetcher, + Arc::clone(&state), + refresh, + Arc::clone(¬ify), + )); + app::run_ui(&mut terminal, state, notify) + .await + .map_err(anyhow::Error::from) +} diff --git a/src/lib.rs b/src/lib.rs index c5e9cad..1526efe 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -8,4 +8,5 @@ pub mod metrics; pub mod protocol; pub mod store; pub mod traffic; +pub mod tui; pub mod xdp; diff --git a/src/tui/app.rs b/src/tui/app.rs new file mode 100644 index 0000000..968dd2d --- /dev/null +++ b/src/tui/app.rs @@ -0,0 +1,86 @@ +//! Циклы приложения: fetch-таска (tokio) и UI-цикл (tick + события клавиатуры). + +use std::sync::Arc; +use std::time::Duration; + +use tokio::sync::Notify; + +use crate::tui::fetch::{FetchError, Fetcher}; +use crate::tui::prometheus::parse; +use crate::tui::state::AppState; +use crate::tui::ui; + +/// Управляемые пользователем команды (клавиши). +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum Command { + Quit, + Refresh, +} + +/// Маппинг клавиш: `q`/`Esc` — выход, `r` — обновить немедленно. +#[must_use] +pub fn handle_key(code: crossterm::event::KeyCode) -> Option { + match code { + crossterm::event::KeyCode::Char('q') | crossterm::event::KeyCode::Esc => Some(Command::Quit), + crossterm::event::KeyCode::Char('r') => Some(Command::Refresh), + _ => None, + } +} + +/// Приводит результат fetch к виду для [`AppState::apply_fetch`]. +pub fn normalize(result: Result) -> Result { + match result { + Ok(body) => Ok(parse(&body)), + Err(FetchError::Network(message)) => Err(message), + Err(FetchError::Status(status)) => Err(format!("http status {status}")), + } +} + +/// Фоновая задача: fetch → [`AppState`]. Ошибки сети пишутся в снапшот баннером. +pub async fn spawn_fetch_loop(fetcher: Arc, state: Arc, refresh: Duration, notify: Arc) { + loop { + let started = std::time::Instant::now(); + let result = normalize(fetcher.fetch().await); + let elapsed_secs = started.elapsed().as_secs_f64(); + state.apply_fetch(result, elapsed_secs); + + tokio::select! { + () = tokio::time::sleep(refresh) => {}, + () = notify.notified() => {}, + } + } +} + +/// UI-цикл: рисует кадр каждые ~100 мс, обрабатывает клавиши. +/// +/// # Errors +/// Возвращает ошибку терминала (draw/poll) — вызывающий восстанавливает TTY. +pub async fn run_ui( + terminal: &mut ratatui::Terminal>, + state: Arc, + notify: Arc, +) -> std::io::Result<()> { + loop { + terminal.draw(|frame| ui::render(frame, &state.frame_data()))?; + if let Some(command) = poll_command(Duration::from_millis(100))? { + match command { + Command::Quit => return Ok(()), + Command::Refresh => notify.notify_one(), + } + } + } +} + +/// Неблокирующее ожидание клавиши в течение `timeout`. +fn poll_command(timeout: Duration) -> std::io::Result> { + if !crossterm::event::poll(timeout)? { + return Ok(None); + } + if let crossterm::event::Event::Key(key) = crossterm::event::read()? { + // Реагируем только на нажатие (не автоповтор/отпускание). + if key.kind == crossterm::event::KeyEventKind::Press { + return Ok(handle_key(key.code)); + } + } + Ok(None) +} diff --git a/src/tui/fetch.rs b/src/tui/fetch.rs new file mode 100644 index 0000000..4bac643 --- /dev/null +++ b/src/tui/fetch.rs @@ -0,0 +1,65 @@ +//! Получение тела `/metrics`: трейт [`Fetcher`] для мока в тестах и HTTP-реализация. + +use std::time::Duration; + +use futures::future::BoxFuture; +use thiserror::Error; + +/// Ошибка загрузки метрик. +#[derive(Debug, Error, Clone)] +pub enum FetchError { + /// Сетевая ошибка / DNS / таймаут. + #[error("network error: {0}")] + Network(String), + /// Endpoint ответил не-200 статусом. + #[error("http status {0}")] + Status(u16), +} + +/// Абстракция источника exposition-текста Prometheus. +pub trait Fetcher: Send + Sync { + /// Загружает текущий срез метрик. + /// + /// # Errors + /// Возвращает [`FetchError`] при сетевом сбое или плохом HTTP-статусе. + fn fetch(&self) -> BoxFuture<'_, Result>; +} + +/// Реализация [`Fetcher`] поверх reqwest GET. +pub struct HttpFetcher { + url: String, + client: reqwest::Client, +} + +impl HttpFetcher { + /// Создаёт fetcher с таймаутом запроса 5 секунд. + pub fn new(url: impl Into) -> Option { + let client = reqwest::Client::builder() + .timeout(Duration::from_secs(5)) + .build() + .ok()?; + Some(Self { + url: url.into(), + client, + }) + } +} + +impl Fetcher for HttpFetcher { + fn fetch(&self) -> BoxFuture<'_, Result> { + let url = self.url.clone(); + let client = self.client.clone(); + Box::pin(async move { + let response = client + .get(&url) + .send() + .await + .map_err(|e| FetchError::Network(e.to_string()))?; + let status = response.status(); + if !status.is_success() { + return Err(FetchError::Status(status.as_u16())); + } + response.text().await.map_err(|e| FetchError::Network(e.to_string())) + }) + } +} diff --git a/src/tui/mod.rs b/src/tui/mod.rs new file mode 100644 index 0000000..23417a9 --- /dev/null +++ b/src/tui/mod.rs @@ -0,0 +1,10 @@ +//! TUI-модуль Rampart: live-метрики edge-ноды/manager в терминале (ratatui). +//! +//! Используется бинарём `rampart-tui`; источник данных — Prometheus +//! exposition endpoint (`/metrics`). + +pub mod app; +pub mod fetch; +pub mod prometheus; +pub mod state; +pub mod ui; diff --git a/src/tui/prometheus.rs b/src/tui/prometheus.rs new file mode 100644 index 0000000..6fe62c0 --- /dev/null +++ b/src/tui/prometheus.rs @@ -0,0 +1,238 @@ +//! Минималистичный парсер текстового формата Prometheus (version=0.0.4). +//! +//! Поддерживает: комментарии (`# HELP`, `# TYPE`, `#`), строки вида +//! `metric{label="value",...} value [timestamp]` и `metric value [timestamp]`. +//! Значения `NaN`/`+Inf`/`-Inf` парсятся штатно через `f64::from_str`. + +use std::collections::BTreeMap; + +/// Один сэмпл из exposition-формата Prometheus. +#[derive(Debug, Clone, PartialEq)] +pub struct MetricSample { + pub name: String, + pub labels: BTreeMap, + pub value: f64, +} + +/// Набор сэмплов одного ответа `/metrics`. +#[derive(Debug, Default, Clone, PartialEq)] +pub struct MetricSet { + samples: Vec, +} + +impl MetricSet { + /// Сумма значений всех серий с данным именем (для counter/gauge vec). + /// Отсутствующая метрика даёт `0.0`. + #[must_use] + pub fn sum(&self, name: &str) -> f64 { + self.samples.iter().filter(|s| s.name == name).map(|s| s.value).sum() + } + + /// Значение серии с именем и одной меткой; `None`, если не найдено. + #[must_use] + pub fn labeled(&self, name: &str, label: &str, value: &str) -> Option { + self.samples + .iter() + .find(|s| s.name == name && s.labels.get(label).map(String::as_str) == Some(value)) + .map(|s| s.value) + } + + /// Все сэмплы (для тестов и отладки). + #[must_use] + pub fn samples(&self) -> &[MetricSample] { + &self.samples + } +} + +/// Парсит тело ответа `/metrics`. +/// +/// Строки с ошибкой формата пропускаются (exposition-поток может быть частично +/// повреждён — TUI не должен падать на этом). +#[must_use] +pub fn parse(input: &str) -> MetricSet { + let mut samples = Vec::new(); + for line in input.lines() { + if let Some(sample) = parse_line(line) { + samples.push(sample); + } + } + MetricSet { samples } +} + +fn parse_line(line: &str) -> Option { + let line = line.trim(); + if line.is_empty() || line.starts_with('#') { + return None; + } + // Формат: metric[{labels}] value [timestamp]; значения меток могут + // содержать пробелы, поэтому голову отрезаем с учётом кавычек. + let (head, rest) = split_head_and_rest(line); + let value_str = rest.split_whitespace().next()?; + // Timestamp после value игнорируем. + + let (name, labels) = split_metric(head)?; + let value = parse_value(value_str)?; + Some(MetricSample { name, labels, value }) +} + +/// Отделяет `metric{...}` от хвоста `value [timestamp]`. +fn split_head_and_rest(line: &str) -> (&str, &str) { + let Some(open) = line.find('{') else { + return match line.find(char::is_whitespace) { + Some(space) => (&line[..space], &line[space..]), + None => (line, ""), + }; + }; + match find_closing_brace(line, open + 1) { + Some(close) => (&line[..=close], &line[close + 1..]), + None => match line.find(char::is_whitespace) { + Some(space) => (&line[..space.min(open)], &line[space.min(open)..]), + None => (line, ""), + }, + } +} + +/// Индекс `}` вне кавычек, начиная с позиции `from`. +fn find_closing_brace(line: &str, from: usize) -> Option { + let mut in_quotes = false; + let mut escaped = false; + for (idx, ch) in line[from..].char_indices() { + match ch { + '\\' if in_quotes => escaped = !escaped, + '"' if !escaped => in_quotes = !in_quotes, + '}' if !in_quotes => return Some(from + idx), + _ => {}, + } + if ch != '\\' { + escaped = false; + } + } + None +} + +fn split_metric(head: &str) -> Option<(String, BTreeMap)> { + match head.find('{') { + None => Some((head.to_owned(), BTreeMap::new())), + Some(open) => { + if !head.ends_with('}') || open == 0 { + return None; + } + let name = head[..open].trim().to_owned(); + let inner = &head[open + 1..head.len() - 1]; + Some((name, parse_labels(inner))) + }, + } +} + +fn parse_labels(inner: &str) -> BTreeMap { + let mut labels = BTreeMap::new(); + for pair in split_label_pairs(inner) { + if let Some(eq) = pair.find('=') { + let key = pair[..eq].trim(); + let raw_value = pair[eq + 1..].trim(); + if let Some(unquoted) = raw_value.strip_prefix('"').and_then(|v| v.strip_suffix('"')) { + labels.insert(key.to_owned(), unescape_label(unquoted)); + } + } + } + labels +} + +/// Разбивает `a="1", b="2"` по запятым вне кавычек. +fn split_label_pairs(inner: &str) -> Vec<&str> { + let mut parts = Vec::new(); + let mut in_quotes = false; + let mut escaped = false; + let mut start = 0; + for (idx, ch) in inner.char_indices() { + match ch { + '\\' if in_quotes => escaped = !escaped, + '"' if !escaped => in_quotes = !in_quotes, + ',' if !in_quotes => { + parts.push(&inner[start..idx]); + start = idx + 1; + }, + _ => {}, + } + if ch != '\\' { + escaped = false; + } + } + parts.push(&inner[start..]); + parts +} + +fn unescape_label(value: &str) -> String { + let mut out = String::with_capacity(value.len()); + let mut chars = value.chars(); + while let Some(ch) = chars.next() { + if ch == '\\' { + match chars.next() { + Some('n') => out.push('\n'), + Some(next) => out.push(next), + None => {}, + } + } else { + out.push(ch); + } + } + out +} + +fn parse_value(value: &str) -> Option { + match value { + "+Inf" | "Inf" => Some(f64::INFINITY), + "-Inf" => Some(f64::NEG_INFINITY), + "NaN" => Some(f64::NAN), + v => v.parse::().ok(), + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn parses_simple_and_labeled_lines() { + let set = parse("up 1\nxdp_total{iface=\"eth0\"} 42\n"); + assert_eq!(set.sum("up"), 1.0); + assert_eq!(set.labeled("xdp_total", "iface", "eth0"), Some(42.0)); + } + + #[test] + fn skips_comments_help_type_and_empty_lines() { + let input = "# HELP rampart_xdp_total Total\n# TYPE rampart_xdp_total gauge\n\nrampart_xdp_total 7\n"; + let set = parse(input); + assert_eq!(set.samples().len(), 1); + assert_eq!(set.sum("rampart_xdp_total"), 7.0); + } + + #[test] + fn ignores_timestamp_and_special_values() { + let set = parse("m1 10 1700000000000\nm2 +Inf\nm3 NaN\nbroken_line_no_value\nbad{} \nm4 -2.5e3\n"); + assert_eq!(set.sum("m1"), 10.0); + assert_eq!(set.sum("m2"), f64::INFINITY); + assert!(set.labeled("m3", "x", "y").is_none()); + assert_eq!(set.sum("m4"), -2500.0); + } + + #[test] + fn sums_series_with_same_name() { + let input = "bans{reason=\"cps\"} 3\nbans{reason=\"syn\"} 4\nbans 5\n"; + assert_eq!(parse(input).sum("bans"), 12.0); + } + + #[test] + fn handles_spaces_in_label_values() { + let set = parse("m{k=\"a b\", j=\"v w\"} 1\n"); + assert_eq!(set.labeled("m", "k", "a b"), Some(1.0)); + assert_eq!(set.labeled("m", "j", "v w"), Some(1.0)); + } + + #[test] + fn handles_escaped_quotes_and_backslashes_in_labels() { + let set = parse("m{q=\"a\\\"b\", s=\"c\\\\d\"} 1\n"); + assert_eq!(set.labeled("m", "q", "a\"b"), Some(1.0)); + assert_eq!(set.labeled("m", "s", "c\\d"), Some(1.0)); + } +} diff --git a/src/tui/state.rs b/src/tui/state.rs new file mode 100644 index 0000000..4410d12 --- /dev/null +++ b/src/tui/state.rs @@ -0,0 +1,166 @@ +//! Общее состояние TUI: последний срез метрик, история pps/drops, расчёт rate. + +use std::collections::VecDeque; +use std::sync::Mutex; +use std::time::Instant; + +use crate::tui::prometheus::MetricSet; + +/// Максимальная длина скользящего окна графика. +pub const HISTORY_CAPACITY: usize = 120; + +/// Один сэмпл истории (значения — rates, единиц/сек). +#[derive(Debug, Clone, Copy, PartialEq)] +pub struct HistoryPoint { + pub packets_per_sec: f64, + pub drops_per_sec: f64, +} + +/// Скользящее окно истории фиксированной ёмкости. +#[derive(Debug, Clone)] +pub struct History { + points: VecDeque, +} + +impl Default for History { + fn default() -> Self { + Self { + points: VecDeque::with_capacity(HISTORY_CAPACITY), + } + } +} + +impl History { + /// Добавляет точку, вытесняя старейшую при переполнении окна. + pub fn push(&mut self, point: HistoryPoint) { + if self.points.len() == HISTORY_CAPACITY { + self.points.pop_front(); + } + self.points.push_back(point); + } + + /// Точки в хронологическом порядке. + #[must_use] + pub fn points(&self) -> &VecDeque { + &self.points + } +} + +/// Состояние приложения, общее между fetch-потоком и UI-циклом. +#[derive(Debug)] +pub struct AppState { + url: String, + snapshot: Mutex, + history: Mutex, + started_at: Instant, +} + +impl AppState { + /// Создаёт состояние с URL источника метрик (для статус-панели). + #[must_use] + pub fn new(url: impl Into) -> Self { + Self { + url: url.into(), + snapshot: Mutex::new(Snapshot::default()), + history: Mutex::new(History::default()), + started_at: Instant::now(), + } + } + + /// URL источника метрик. + #[must_use] + pub fn url(&self) -> &str { + &self.url + } +} + +/// Последний успешный/неуспешный результат fetch. +#[derive(Debug, Default, Clone)] +pub struct Snapshot { + pub metrics: Option, + pub fetched_at: Option, + pub error: Option, +} + +impl AppState { + /// Записывает результат очередного fetch и обновляет историю rate. + pub fn apply_fetch(&self, result: Result, elapsed_secs: f64) { + let mut snapshot = match self.snapshot.lock() { + Ok(guard) => guard, + Err(poisoned) => poisoned.into_inner(), + }; + match result { + Ok(metrics) => { + let mut history = match self.history.lock() { + Ok(guard) => guard, + Err(poisoned) => poisoned.into_inner(), + }; + let previous = snapshot.metrics.take(); + let point = HistoryPoint { + packets_per_sec: counter_rate( + previous.as_ref().map(|p| p.sum("rampart_xdp_total")), + Some(metrics.sum("rampart_xdp_total")), + elapsed_secs, + ), + drops_per_sec: counter_rate( + previous.as_ref().map(|p| p.sum("rampart_xdp_dropped")), + Some(metrics.sum("rampart_xdp_dropped")), + elapsed_secs, + ), + }; + history.push(point); + snapshot.metrics = Some(metrics); + snapshot.fetched_at = Some(Instant::now()); + snapshot.error = None; + }, + Err(message) => { + snapshot.error = Some(message); + }, + } + } + + /// Копия среза метрик + истории для отрисовки кадра. + /// + /// UI не держит лок — рисуем по снимку. + #[must_use] + pub fn frame_data(&self) -> FrameData { + let snapshot = self.snapshot.lock().unwrap_or_else(std::sync::PoisonError::into_inner); + let history = self + .history + .lock() + .unwrap_or_else(std::sync::PoisonError::into_inner) + .clone(); + FrameData { + url: self.url.clone(), + snapshot: snapshot.clone(), + history, + uptime_secs: self.started_at.elapsed().as_secs(), + } + } +} + +/// Снимок для одного кадра UI. +#[derive(Debug, Default, Clone)] +pub struct FrameData { + /// URL источника метрик. + pub url: String, + pub snapshot: Snapshot, + pub history: History, + /// Время работы TUI (сек). + pub uptime_secs: u64, +} + +/// Rate счётчика из двух сэмплов: `(current - previous) / dt`. +/// +/// `None` у предыдущего значения (первый сэмпл) даёт 0.0. Сброс счётчика +/// (`current < previous`) трактуетcя как рестарт процесса → 0.0. +#[must_use] +pub fn counter_rate(previous: Option, current: Option, elapsed_secs: f64) -> f64 { + let (Some(previous), Some(current)) = (previous, current) else { + return 0.0; + }; + if !(elapsed_secs > 0.0 && current.is_finite()) || current < previous { + return 0.0; + } + (current - previous) / elapsed_secs +} diff --git a/src/tui/ui.rs b/src/tui/ui.rs new file mode 100644 index 0000000..e412485 --- /dev/null +++ b/src/tui/ui.rs @@ -0,0 +1,179 @@ +//! Отрисовка кадра ratatui: статус-панель, таблица счётчиков, график pps/drops, помощь. + +use ratatui::Frame; +use ratatui::layout::{Constraint, Layout, Rect}; +use ratatui::style::{Color, Modifier, Style}; +use ratatui::symbols; +use ratatui::text::{Line, Span}; +use ratatui::widgets::{Axis, Block, Borders, Chart, Dataset, GraphType, Paragraph, Row, Table}; + +use crate::tui::state::{FrameData, HistoryPoint}; + +/// Имена метрик edge/manager (см. src/metrics.rs и src/xdp/metrics.rs). +mod names { + pub const XDP_TOTAL: &str = "rampart_xdp_total"; + pub const XDP_TCP: &str = "rampart_xdp_tcp"; + pub const XDP_UDP: &str = "rampart_xdp_udp"; + pub const XDP_PASSED: &str = "rampart_xdp_passed"; + pub const XDP_DROPPED: &str = "rampart_xdp_dropped"; + pub const XDP_THROTTLE: &str = "rampart_xdp_syn_throttle"; + pub const XDP_RATE_LIMIT: &str = "rampart_xdp_rate_limit"; + pub const XDP_BLACKLIST: &str = "rampart_xdp_blacklist"; + pub const RATE_LIMIT_HITS: &str = "rampart_rate_limit_hits"; + pub const AUTO_BANS_TOTAL: &str = "rampart_auto_bans_total"; + pub const INTEL_DROPS: &str = "rampart_intel_rate_limit_drops_total"; + pub const INTEL_CPS: &str = "rampart_intel_cps"; + pub const INTEL_ALERTS: &str = "rampart_intel_alerts_total"; + pub const ATTACK_STATUS: &str = "rampart_attack_status"; +} + +/// Рисует весь кадр. +pub fn render(frame: &mut Frame, data: &FrameData) { + let [header, body, help] = + Layout::vertical([Constraint::Length(3), Constraint::Min(3), Constraint::Length(1)]).areas(frame.area()); + render_header(frame, header, data); + let [table_area, chart_area] = + Layout::vertical([Constraint::Percentage(55), Constraint::Percentage(45)]).areas(body); + render_table(frame, table_area, data); + render_chart(frame, chart_area, data); + render_help(frame, help); +} + +fn status_color(data: &FrameData) -> Color { + match data.snapshot.error { + Some(_) => Color::Red, + None if data.snapshot.fetched_at.is_some() => Color::Green, + None => Color::Yellow, + } +} + +fn render_header(frame: &mut Frame, area: Rect, data: &FrameData) { + let dot = Span::styled( + "● ", + Style::default().fg(status_color(data)).add_modifier(Modifier::BOLD), + ); + let title = Span::styled(" Rampart live metrics", Style::default().add_modifier(Modifier::BOLD)); + let state_text = match (&data.snapshot.error, data.snapshot.fetched_at) { + (Some(err), _) => format!("ERROR: {err}"), + (_, Some(at)) => format!("OK, last update {}s ago", at.elapsed().as_secs()), + (None, None) => "waiting for first fetch...".to_owned(), + }; + let line = Line::from(vec![ + dot, + title, + Span::raw(format!( + " {} | uptime {}s | {state_text}", + data.url, data.uptime_secs + )), + ]); + frame.render_widget(Paragraph::new(line).block(Block::new().borders(Borders::ALL)), area); +} + +/// Пары «подпись → имя метрики» для таблицы. +const COUNTER_ROWS: &[(&str, &str)] = &[ + ("XDP total", names::XDP_TOTAL), + ("XDP tcp", names::XDP_TCP), + ("XDP udp", names::XDP_UDP), + ("XDP passed", names::XDP_PASSED), + ("XDP dropped", names::XDP_DROPPED), + ("SYN throttle", names::XDP_THROTTLE), + ("Rate limited", names::XDP_RATE_LIMIT), + ("Blacklisted", names::XDP_BLACKLIST), + ("HTTP rate-limit hits", names::RATE_LIMIT_HITS), + ("Auto bans", names::AUTO_BANS_TOTAL), + ("Intel rl drops", names::INTEL_DROPS), + ("Intel cps", names::INTEL_CPS), + ("Intel alerts", names::INTEL_ALERTS), +]; + +fn attack_status_label(value: Option) -> String { + match value { + None => "-".to_owned(), + Some(v) if v <= 0.0 => "normal".to_owned(), + Some(v) if v < 2.0 => "suspicious".to_owned(), + Some(_) => "under attack".to_owned(), + } +} + +fn render_table(frame: &mut Frame, area: Rect, data: &FrameData) { + let rows: Vec = COUNTER_ROWS + .iter() + .map(|(label, metric)| { + let value = data.snapshot.metrics.as_ref().map(|m| m.sum(metric)); + Row::new(vec![(*label).to_owned(), fmt_value(value)]) + }) + .chain(std::iter::once(Row::new(vec![ + "Attack status".to_owned(), + attack_status_label(data.snapshot.metrics.as_ref().map(|m| m.sum(names::ATTACK_STATUS))), + ]))) + .collect(); + + let table = Table::new(rows, [Constraint::Percentage(60), Constraint::Percentage(40)]) + .block(Block::new().borders(Borders::ALL).title(" Counters ")) + .header(Row::new(vec!["metric", "value"]).style(Style::default().add_modifier(Modifier::BOLD))); + frame.render_widget(table, area); +} + +#[allow(clippy::float_arithmetic)] +fn fmt_value(value: Option) -> String { + match value { + None => "-".to_owned(), + Some(v) if !v.is_finite() => format!("{v}"), + Some(v) if v.fract() == 0.0 => format!("{}", v as u64), + Some(v) => format!("{v:.1}"), + } +} + +fn history_series(history: &[HistoryPoint], pick: impl Fn(&HistoryPoint) -> f64) -> Vec<(f64, f64)> { + history + .iter() + .enumerate() + .map(|(idx, point)| (f64::from(idx as u16), pick(point))) + .collect() +} + +fn render_chart(frame: &mut Frame, area: Rect, data: &FrameData) { + let points: Vec = data.history.points().iter().copied().collect(); + let pps_series = history_series(&points, |p| p.packets_per_sec); + let drops_series = history_series(&points, |p| p.drops_per_sec); + let pps = Dataset::default() + .name("pps") + .marker(symbols::Marker::Braille) + .graph_type(GraphType::Line) + .style(Style::default().fg(Color::Cyan)) + .data(&pps_series); + let drops = Dataset::default() + .name("drops/s") + .marker(symbols::Marker::Braille) + .graph_type(GraphType::Line) + .style(Style::default().fg(Color::Red)) + .data(&drops_series); + + let x_max = points.len().max(2) as f64 - 1.0; + let y_max = points + .iter() + .flat_map(|p| [p.packets_per_sec, p.drops_per_sec]) + .fold(1.0_f64, f64::max); + + let chart = Chart::new(vec![pps, drops]) + .block( + Block::new() + .borders(Borders::ALL) + .title(" pps / drops (last 120 samples) "), + ) + .x_axis(Axis::default().bounds([0.0, x_max])) + .y_axis(Axis::default().bounds([0.0, y_max])); + frame.render_widget(chart, area); +} + +fn render_help(frame: &mut Frame, area: Rect) { + frame.render_widget( + Paragraph::new(Line::from(vec![ + Span::styled("q", Style::default().add_modifier(Modifier::BOLD)), + Span::raw(" quit "), + Span::styled("r", Style::default().add_modifier(Modifier::BOLD)), + Span::raw(" refresh now"), + ])), + area, + ); +} diff --git a/tests/tui_metrics.rs b/tests/tui_metrics.rs new file mode 100644 index 0000000..239d5fa --- /dev/null +++ b/tests/tui_metrics.rs @@ -0,0 +1,159 @@ +//! Тесты TUI-модуля: парсер Prometheus, расчёт rate, поведение при ошибках fetch. + +use futures::future::BoxFuture; +use rampart::tui::fetch::{FetchError, Fetcher, HttpFetcher}; +use rampart::tui::prometheus::{MetricSample, parse}; +use rampart::tui::state::{AppState, counter_rate}; + +// ---------- парсер ---------- + +#[test] +fn parses_metric_with_labels_value_and_timestamp() { + let set = parse("rampart_xdp_total{iface=\"eth0\",core=\"0\"} 123 1700000000000\n"); + assert_eq!( + set.samples(), + vec![MetricSample { + name: "rampart_xdp_total".to_owned(), + labels: [("iface", "eth0"), ("core", "0")] + .iter() + .map(|(k, v)| ((*k).to_owned(), (*v).to_owned())) + .collect(), + value: 123.0, + }] + ); +} + +#[test] +fn parser_skips_comments_help_and_type() { + let body = "\ +# HELP rampart_xdp_dropped Packets dropped\n\ +# TYPE rampart_xdp_dropped gauge\n\ +# обычный комментарий\n\ +rampart_xdp_dropped 42\n"; + let set = parse(body); + assert_eq!(set.samples().len(), 1); + assert_eq!(set.sum("rampart_xdp_dropped"), 42.0); +} + +#[test] +fn parser_sums_counter_vec_series() { + let body = "rampart_auto_bans_total{reason=\"cps\"} 3\nrampart_auto_bans_total{reason=\"syn\"} 4\n"; + assert_eq!(parse(body).sum("rampart_auto_bans_total"), 7.0); +} + +#[test] +fn parser_missing_metric_is_zero() { + assert_eq!(parse("# nothing here\n").sum("rampart_xdp_tcp"), 0.0); +} + +#[test] +fn parser_tolerates_malformed_lines() { + let set = parse("garbage\nm{unterminated=\"v} 1\nok_metric 5\n"); + assert_eq!(set.sum("ok_metric"), 5.0); + assert_eq!(set.samples().len(), 1); +} + +// ---------- rate ---------- + +#[test] +fn rate_from_two_samples_divides_by_elapsed() { + // 1200 - 1100 = 100 пакетов за 2 сек + assert_eq!(counter_rate(Some(1100.0), Some(1200.0), 2.0), 50.0); +} + +#[test] +fn rate_first_sample_and_counter_reset_are_zero() { + assert_eq!(counter_rate(None, Some(10.0), 1.0), 0.0); + assert_eq!(counter_rate(Some(20.0), Some(10.0), 1.0), 0.0); + assert_eq!(counter_rate(Some(10.0), Some(12.0), 0.0), 0.0); +} + +#[test] +fn state_accumulates_history_rates() { + let state = AppState::new("http://test/metrics"); + state.apply_fetch(Ok(parse("rampart_xdp_total 100\n")), 1.0); + state.apply_fetch(Ok(parse("rampart_xdp_total 150\n")), 1.0); + let frame = state.frame_data(); + assert_eq!(frame.history.points().len(), 2); + assert_eq!(frame.history.points()[1].packets_per_sec, 50.0); + assert!(frame.snapshot.error.is_none()); +} + +// ---------- ошибки fetch (мок Fetcher + локальный HTTP-сервер) ---------- + +struct FailingFetcher { + error: FetchError, +} + +impl Fetcher for FailingFetcher { + fn fetch(&self) -> BoxFuture<'_, Result> { + let error = self.error.clone(); + Box::pin(async { Err(error) }) + } +} + +#[test] +fn network_error_sets_error_banner_not_panic() { + let state = AppState::new("http://127.0.0.1:9/metrics"); + let fetcher = FailingFetcher { + error: FetchError::Network("connection refused".to_owned()), + }; + + // Тот же путь, что и в spawn_fetch_loop: normalize → apply_fetch. + let normalized = rampart::tui::app::normalize(Err(fetcher.error)); + assert!(normalized.is_err()); + let message = match normalized { + Err(message) => message, + Ok(_) => return, + }; + + state.apply_fetch(Err(message), 1.0); + assert_eq!(state.frame_data().snapshot.error.as_deref(), Some("connection refused")); +} + +#[tokio::test] +async fn http_fetcher_reads_prometheus_body_from_local_server() { + let Ok(listener) = tokio::net::TcpListener::bind("127.0.0.1:0").await else { + return; + }; + let Ok(addr) = listener.local_addr() else { + return; + }; + let server = tokio::spawn(async move { + use tokio::io::AsyncWriteExt; + if let Ok((mut stream, _)) = listener.accept().await { + let body = "rampart_xdp_total 99\n"; + let head = format!( + "HTTP/1.1 200 OK\r\nContent-Length: {}\r\nConnection: close\r\n\r\n", + body.len() + ); + let _ = stream.write_all(head.as_bytes()).await; + let _ = stream.write_all(body.as_bytes()).await; + } + }); + + let Some(fetcher) = HttpFetcher::new(format!("http://{addr}/metrics")) else { + server.abort(); + return; + }; + let Ok(body) = fetcher.fetch().await else { + server.abort(); + return; + }; + assert_eq!(parse(&body).sum("rampart_xdp_total"), 99.0); + server.abort(); +} + +#[test] +fn failed_fetch_keeps_last_good_metrics_but_reports_error() { + let state = AppState::new("http://test/metrics"); + state.apply_fetch(Ok(parse("rampart_xdp_total 7\n")), 1.0); + state.apply_fetch(Err("connection refused".to_owned()), 1.0); + + let frame = state.frame_data(); + assert_eq!(frame.snapshot.error.as_deref(), Some("connection refused")); + assert_eq!( + frame.snapshot.metrics.as_ref().map(|m| m.sum("rampart_xdp_total")), + Some(7.0) + ); +}