diff --git a/Cargo.lock b/Cargo.lock index 4e3444740..b9921557d 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -170,6 +170,12 @@ dependencies = [ "object", ] +[[package]] +name = "arraydeque" +version = "0.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7d902e3d592a523def97af8f317b08ce16b7ab854c1985a0c671e6f15cebc236" + [[package]] name = "arrayref" version = "0.3.9" @@ -355,7 +361,7 @@ dependencies = [ "arrow-schema", "chrono", "half", - "indexmap 2.13.0", + "indexmap 2.13.1", "itoa", "lexical-core", "memchr", @@ -514,6 +520,15 @@ dependencies = [ "num-traits", ] +[[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" @@ -580,9 +595,9 @@ dependencies = [ [[package]] name = "aws-lc-sys" -version = "0.39.0" +version = "0.39.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1fa7e52a4c5c547c741610a2c6f123f3881e409b714cd27e6798ef020c514f0a" +checksum = "83a25cf98105baa966497416dbd42565ce3a8cf8dbfd59803ec9ad46f3126399" dependencies = [ "cc", "cmake", @@ -979,14 +994,29 @@ name = "ballista-cli" version = "53.0.0" dependencies = [ "ballista", + "chrono", "clap 4.6.0", + "config", + "crossterm", "datafusion", "datafusion-cli", "dirs", + "dotparser", "env_logger", + "futures", "mimalloc", + "percent-encoding", + "prometheus-parse", + "ratatui", + "reqwest 0.13.2", "rustyline", + "serde", + "serde_json", "tokio", + "tracing", + "tracing-appender", + "tracing-subscriber", + "tui-big-text", ] [[package]] @@ -1153,6 +1183,21 @@ dependencies = [ "serde", ] +[[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" @@ -1176,16 +1221,16 @@ dependencies = [ [[package]] name = "blake3" -version = "1.8.3" +version = "1.8.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2468ef7d57b3fb7e16b576e8377cdbde2320c60e1491e961d11da40fc4f02a2d" +checksum = "4d2d5991425dfd0785aed03aedcf0b321d61975c9b5b3689c774a2610ae0b51e" dependencies = [ "arrayref", "arrayvec", "cc", "cfg-if", "constant_time_eq", - "cpufeatures 0.2.17", + "cpufeatures 0.3.0", ] [[package]] @@ -1296,7 +1341,7 @@ version = "3.9.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "519bd3116aeeb42d5372c29d982d16d0170d3d4a5ed85fc7dd91642ffff3c67c" dependencies = [ - "darling", + "darling 0.23.0", "ident_case", "prettyplease", "proc-macro2", @@ -1332,6 +1377,12 @@ 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 = "byteorder" version = "1.5.0" @@ -1369,11 +1420,20 @@ version = "0.3.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "37b2a672a2cb129a2e41c10b1224bb368f9f37a2b16b612598138befd7b37eb5" +[[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.2.57" +version = "1.2.58" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7a0dd1ca384932ff3641c8718a02769f1698e7563dc6974ffd03346116310423" +checksum = "e1e928d4b69e3077709075a938a05ffbedfa53a84c8f766efbf8220bb1ff60e1" dependencies = [ "find-msvc-tools", "jobserver", @@ -1381,6 +1441,12 @@ dependencies = [ "shlex", ] +[[package]] +name = "cesu8" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6d43a04d8753f35258c91f8ec639f792891f748a1edbd759cf1dcea3382ad83c" + [[package]] name = "cfg-if" version = "1.0.4" @@ -1423,7 +1489,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a6139a8597ed92cf816dfb33f5dd6cf0bb93a6adc938f11039f371bc5bcd26c3" dependencies = [ "chrono", - "phf", + "phf 0.12.1", ] [[package]] @@ -1515,9 +1581,9 @@ dependencies = [ [[package]] name = "cmake" -version = "0.1.57" +version = "0.1.58" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "75443c44cd6b379beb8c5b45d85d0773baf31cce901fe7bb252f4eff3008ef7d" +checksum = "c0f78a02292a74a88ac736019ab962ece0bc380e3f977bf72e376c5d78ff0678" dependencies = [ "cc", ] @@ -1528,6 +1594,16 @@ version = "1.0.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1d07550c9036bf2ae0c684c4297d503f838287c83c53686d05370d0e139ae570" +[[package]] +name = "combine" +version = "4.6.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ba5a308b75df32fe02788e748662718f03fde005016435c444eea572398219fd" +dependencies = [ + "bytes", + "memchr", +] + [[package]] name = "comfy-table" version = "7.2.2" @@ -1538,6 +1614,20 @@ dependencies = [ "unicode-width 0.2.2", ] +[[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 = "compression-codecs" version = "0.4.37" @@ -1559,6 +1649,18 @@ version = "0.4.31" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "75984efb6ed102a0d42db99afb6c1948f0380d1d91808d5529916e6c08b49d8d" +[[package]] +name = "config" +version = "0.15.22" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8e68cfe19cd7d23ffde002c24ffa5cda73931913ef394d5eaaa32037dc940c0c" +dependencies = [ + "pathdiff", + "serde_core", + "winnow", + "yaml-rust2", +] + [[package]] name = "console" version = "0.16.3" @@ -1602,6 +1704,25 @@ version = "0.4.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "3d52eff69cd5e647efe296129160853a42795992097e8af39800e1060caeea9b" +[[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" +version = "0.9.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "91e195e091a93c46f7102ec7818a2aa394e1e1771c3ab4825963fa03e45afb8f" +dependencies = [ + "core-foundation-sys", + "libc", +] + [[package]] name = "core-foundation" version = "0.10.1" @@ -1714,6 +1835,34 @@ version = "0.8.21" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d0a5c400df2834b80a4c3327b3aad3a4c4cd4de0629063962b03235697506a28" +[[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", + "futures-core", + "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 = "crunchy" version = "0.2.4" @@ -1739,6 +1888,16 @@ dependencies = [ "hybrid-array", ] +[[package]] +name = "csscolorparser" +version = "0.6.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "eb2a7d3066da2de787b7f032c736763eb7ae5d355f81a68bab2675a96008b0bf" +dependencies = [ + "lab", + "phf 0.11.3", +] + [[package]] name = "csv" version = "1.4.0" @@ -1776,14 +1935,38 @@ version = "0.0.7" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "52560adf09603e58c9a7ee1fe1dcb95a16927b17c127f0ac02d6e768a0e25bc1" +[[package]] +name = "darling" +version = "0.20.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fc7f46116c46ff9ab3eb1597a45688b6715c6e628b5c133e288e709a29bcb4ee" +dependencies = [ + "darling_core 0.20.11", + "darling_macro 0.20.11", +] + [[package]] name = "darling" version = "0.23.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "25ae13da2f202d56bd7f91c25fba009e7717a1e4a1cc98a76d844b65ae912e9d" dependencies = [ - "darling_core", - "darling_macro", + "darling_core 0.23.0", + "darling_macro 0.23.0", +] + +[[package]] +name = "darling_core" +version = "0.20.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0d00b9596d185e565c2207a0b01f8bd1a135483d02d9b7b0a54b11da8d53412e" +dependencies = [ + "fnv", + "ident_case", + "proc-macro2", + "quote", + "strsim", + "syn 2.0.117", ] [[package]] @@ -1799,13 +1982,24 @@ dependencies = [ "syn 2.0.117", ] +[[package]] +name = "darling_macro" +version = "0.20.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fc34b93ccb385b40dc71c6fceac4b2ad23662c7eeb248cf10d529b7e055b6ead" +dependencies = [ + "darling_core 0.20.11", + "quote", + "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", + "darling_core 0.23.0", "quote", "syn 2.0.117", ] @@ -1970,7 +2164,7 @@ dependencies = [ "half", "hashbrown 0.16.1", "hex", - "indexmap 2.13.0", + "indexmap 2.13.1", "itertools 0.14.0", "libc", "log", @@ -2195,7 +2389,7 @@ dependencies = [ "datafusion-functions-aggregate-common", "datafusion-functions-window-common", "datafusion-physical-expr-common", - "indexmap 2.13.0", + "indexmap 2.13.1", "itertools 0.14.0", "paste", "recursive", @@ -2211,7 +2405,7 @@ checksum = "ab05fdd00e05d5a6ee362882546d29d6d3df43a6c55355164a7fbee12d163bc9" dependencies = [ "arrow", "datafusion-common", - "indexmap 2.13.0", + "indexmap 2.13.1", "itertools 0.14.0", "paste", ] @@ -2375,7 +2569,7 @@ dependencies = [ "datafusion-expr", "datafusion-expr-common", "datafusion-physical-expr", - "indexmap 2.13.0", + "indexmap 2.13.1", "itertools 0.14.0", "log", "recursive", @@ -2398,7 +2592,7 @@ dependencies = [ "datafusion-physical-expr-common", "half", "hashbrown 0.16.1", - "indexmap 2.13.0", + "indexmap 2.13.1", "itertools 0.14.0", "parking_lot", "paste", @@ -2434,7 +2628,7 @@ dependencies = [ "datafusion-common", "datafusion-expr-common", "hashbrown 0.16.1", - "indexmap 2.13.0", + "indexmap 2.13.1", "itertools 0.14.0", "parking_lot", ] @@ -2481,7 +2675,7 @@ dependencies = [ "futures", "half", "hashbrown 0.16.1", - "indexmap 2.13.0", + "indexmap 2.13.1", "itertools 0.14.0", "log", "num-traits", @@ -2599,7 +2793,7 @@ dependencies = [ "datafusion-common", "datafusion-expr", "datafusion-functions-nested", - "indexmap 2.13.0", + "indexmap 2.13.1", "log", "recursive", "regex", @@ -2626,6 +2820,12 @@ dependencies = [ "url", ] +[[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" @@ -2636,6 +2836,59 @@ dependencies = [ "serde_core", ] +[[package]] +name = "derive_builder" +version = "0.20.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "507dfb09ea8b7fa618fcf76e953f4f5e192547945816d5358edffe39f6f94947" +dependencies = [ + "derive_builder_macro", +] + +[[package]] +name = "derive_builder_core" +version = "0.20.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2d5bcf7b024d6835cfb3d473887cd966994907effbe9227e8c8219824d06c4e8" +dependencies = [ + "darling 0.20.11", + "proc-macro2", + "quote", + "syn 2.0.117", +] + +[[package]] +name = "derive_builder_macro" +version = "0.20.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ab63b0e2bf4d5928aff72e83a7dace85d7bba5fe12dcc3c5a572d78caffd3f3c" +dependencies = [ + "derive_builder_core", + "syn 2.0.117", +] + +[[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" @@ -2701,6 +2954,15 @@ dependencies = [ "serde_json", ] +[[package]] +name = "document-features" +version = "0.2.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d4b8a88685455ed29a21542a33abd9cb6510b6b129abadabdcef0f4c55bc8f61" +dependencies = [ + "litrs", +] + [[package]] name = "dot-generator" version = "0.2.0" @@ -2716,6 +2978,17 @@ version = "0.1.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "498cfcded997a93eb31edd639361fa33fd229a8784e953b37d71035fe3890b7b" +[[package]] +name = "dotparser" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8848b9ff98f7f7ca6188235d81d273c3579fa72306e3992d71aca7bfa874785e" +dependencies = [ + "pest", + "pest_derive", + "petgraph", +] + [[package]] name = "dtor" version = "0.3.0" @@ -2755,6 +3028,15 @@ version = "1.0.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "34aa73646ffb006b8f5147f3dc182bd4bcb190227ce861fc4a4844bf8e3cb2c0" +[[package]] +name = "encoding_rs" +version = "0.8.35" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "75030f3c4f45dafd7586dd6780965a8c7e8e285a5ecb86713e63a79c5b2766f3" +dependencies = [ + "cfg-if", +] + [[package]] name = "endian-type" version = "0.1.2" @@ -2816,6 +3098,25 @@ 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.3.0" @@ -2844,6 +3145,17 @@ dependencies = [ "web-time", ] +[[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 = "filetime" version = "0.2.27" @@ -2861,6 +3173,18 @@ 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 = "fixedbitset" version = "0.5.7" @@ -2906,6 +3230,12 @@ version = "0.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "77ce24cb58228fbb8aa041425bb1050850ac19177686ea6e0f41a70416f56fdb" +[[package]] +name = "font8x8" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "875488b8711a968268c7cf5d139578713097ca4635a76044e8fe8eedf831d07e" + [[package]] name = "form_urlencoded" version = "1.2.2" @@ -3100,7 +3430,7 @@ dependencies = [ "futures-core", "futures-sink", "http 1.4.0", - "indexmap 2.13.0", + "indexmap 2.13.1", "slab", "tokio", "tokio-util", @@ -3151,6 +3481,15 @@ dependencies = [ "foldhash 0.2.0", ] +[[package]] +name = "hashlink" +version = "0.10.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7382cf6263419f2d8df38c55d7da83da5c18aef87fc7a7fc1fb1e344edfe14c1" +dependencies = [ + "hashbrown 0.15.5", +] + [[package]] name = "heck" version = "0.3.3" @@ -3265,18 +3604,18 @@ checksum = "135b12329e5e3ce057a9f972339ea52bc954fe1e9358ef27f95e89716fbc5424" [[package]] name = "hybrid-array" -version = "0.4.8" +version = "0.4.10" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8655f91cd07f2b9d0c24137bd650fe69617773435ee5ec83022377777ce65ef1" +checksum = "3944cf8cf766b40e2a1a333ee5e9b563f854d5fa49d6a8ca2764e97c6eddb214" dependencies = [ "typenum", ] [[package]] name = "hyper" -version = "1.8.1" +version = "1.9.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2ab2d4f250c3d7b1c9fcdff1cece94ea4e2dfbec68614f7b87cb205f24ca9d11" +checksum = "6299f016b246a94207e63da54dbe807655bf9e00044f73ded42c3ac5305fbcca" dependencies = [ "atomic-waker", "bytes", @@ -3289,7 +3628,6 @@ dependencies = [ "httpdate", "itoa", "pin-project-lite", - "pin-utils", "smallvec", "tokio", "want", @@ -3358,9 +3696,11 @@ dependencies = [ "percent-encoding", "pin-project-lite", "socket2", + "system-configuration", "tokio", "tower-service", "tracing", + "windows-registry", ] [[package]] @@ -3404,12 +3744,13 @@ dependencies = [ [[package]] name = "icu_collections" -version = "2.1.1" +version = "2.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4c6b649701667bbe825c3b7e6388cb521c23d88644678e83c0c4d0a621a34b43" +checksum = "2984d1cd16c883d7935b9e07e44071dca8d917fd52ecc02c04d5fa0b5a3f191c" dependencies = [ "displaydoc", "potential_utf", + "utf8_iter", "yoke", "zerofrom", "zerovec", @@ -3417,9 +3758,9 @@ dependencies = [ [[package]] name = "icu_locale_core" -version = "2.1.1" +version = "2.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "edba7861004dd3714265b4db54a3c390e880ab658fec5f7db895fae2046b5bb6" +checksum = "92219b62b3e2b4d88ac5119f8904c10f8f61bf7e95b640d25ba3075e6cac2c29" dependencies = [ "displaydoc", "litemap", @@ -3430,9 +3771,9 @@ dependencies = [ [[package]] name = "icu_normalizer" -version = "2.1.1" +version = "2.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5f6c8828b67bf8908d82127b2054ea1b4427ff0230ee9141c54251934ab1b599" +checksum = "c56e5ee99d6e3d33bd91c5d85458b6005a22140021cc324cea84dd0e72cff3b4" dependencies = [ "icu_collections", "icu_normalizer_data", @@ -3444,15 +3785,15 @@ dependencies = [ [[package]] name = "icu_normalizer_data" -version = "2.1.1" +version = "2.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7aedcccd01fc5fe81e6b489c15b247b8b0690feb23304303a9e560f37efc560a" +checksum = "da3be0ae77ea334f4da67c12f149704f19f81d1adf7c51cf482943e84a2bad38" [[package]] name = "icu_properties" -version = "2.1.2" +version = "2.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "020bfc02fe870ec3a66d93e677ccca0562506e5872c650f893269e08615d74ec" +checksum = "bee3b67d0ea5c2cca5003417989af8996f8604e34fb9ddf96208a033901e70de" dependencies = [ "icu_collections", "icu_locale_core", @@ -3464,15 +3805,15 @@ dependencies = [ [[package]] name = "icu_properties_data" -version = "2.1.2" +version = "2.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "616c294cf8d725c6afcd8f55abc17c56464ef6211f9ed59cccffe534129c77af" +checksum = "8e2bbb201e0c04f7b4b3e14382af113e17ba4f63e2c9d2ee626b720cbce54a14" [[package]] name = "icu_provider" -version = "2.1.1" +version = "2.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "85962cf0ce02e1e0a629cc34e7ca3e373ce20dda4c4d7294bbd0bf1fdb59e614" +checksum = "139c4cf31c8b5f33d7e199446eff9c1e02decfc2f0eec2c8d71f65befa45b421" dependencies = [ "displaydoc", "icu_locale_core", @@ -3529,9 +3870,9 @@ dependencies = [ [[package]] name = "indexmap" -version = "2.13.0" +version = "2.13.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7714e70437a7dc3ac8eb7e6f8df75fd8eb422675fc7678aff7364301092b1017" +checksum = "45a8a2b9cb3e0b0c1803dbb0758ffac5de2f425b23c28f518faabd9d805342ff" dependencies = [ "equivalent", "hashbrown 0.16.1", @@ -3539,6 +3880,15 @@ dependencies = [ "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 = "insta" version = "1.47.2" @@ -3552,10 +3902,23 @@ dependencies = [ ] [[package]] -name = "integer-encoding" -version = "3.0.4" +name = "instability" +version = "0.3.12" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8bb03732005da905c88227371639bf1ad885cc712789c011c31c5fb3ab3ccf02" +checksum = "5eb2d60ef19920a3a9193c3e371f726ec1dafc045dac788d0fb3704272458971" +dependencies = [ + "darling 0.23.0", + "indoc", + "proc-macro2", + "quote", + "syn 2.0.117", +] + +[[package]] +name = "integer-encoding" +version = "3.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8bb03732005da905c88227371639bf1ad885cc712789c011c31c5fb3ab3ccf02" [[package]] name = "into-attr" @@ -3587,9 +3950,9 @@ checksum = "d98f6fed1fde3f8c21bc40a1abb88dd75e67924f9cffc3ef95607bad8017f8e2" [[package]] name = "iri-string" -version = "0.7.11" +version = "0.7.12" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d8e7418f59cc01c88316161279a7f665217ae316b388e58a0d10e29f54f1e5eb" +checksum = "25e659a4bb38e810ebc252e53b5814ff908a8c58c2a9ce2fae1bbec24cbf4e20" dependencies = [ "memchr", "serde", @@ -3601,6 +3964,15 @@ version = "1.70.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a6cb138bb79a146c1bd460005623e142ef0181e3d0219cb493e02f7d08a35695" +[[package]] +name = "itertools" +version = "0.12.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ba291022dbbd398a455acf126c1e341954079855bc60dfdda641363bd6922569" +dependencies = [ + "either", +] + [[package]] name = "itertools" version = "0.13.0" @@ -3649,6 +4021,50 @@ dependencies = [ "syn 2.0.117", ] +[[package]] +name = "jni" +version = "0.21.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1a87aa2bb7d2af34197c04845522473242e1aa17c12f4935d5856491a7fb8c97" +dependencies = [ + "cesu8", + "cfg-if", + "combine", + "jni-sys 0.3.1", + "log", + "thiserror 1.0.69", + "walkdir", + "windows-sys 0.45.0", +] + +[[package]] +name = "jni-sys" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "41a652e1f9b6e0275df1f15b32661cf0d4b78d4d87ddec5e0c3c20f097433258" +dependencies = [ + "jni-sys 0.4.1", +] + +[[package]] +name = "jni-sys" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c6377a88cb3910bee9b0fa88d4f42e1d2da8e79915598f65fb0c7ee14c878af2" +dependencies = [ + "jni-sys-macros", +] + +[[package]] +name = "jni-sys-macros" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "38c0b942f458fe50cdac086d2f946512305e5631e720728f2a61aabcd47a6264" +dependencies = [ + "quote", + "syn 2.0.117", +] + [[package]] name = "jobserver" version = "0.1.34" @@ -3661,14 +4077,33 @@ dependencies = [ [[package]] name = "js-sys" -version = "0.3.91" +version = "0.3.94" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b49715b7073f385ba4bc528e5747d02e66cb39c6146efb66b781f131f0fb399c" +checksum = "2e04e2ef80ce82e13552136fabeef8a5ed1f985a96805761cbb9a2c34e7664d9" dependencies = [ + "cfg-if", + "futures-util", "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" @@ -3798,6 +4233,15 @@ dependencies = [ "redox_syscall 0.7.3", ] +[[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.4.15" @@ -3812,9 +4256,15 @@ checksum = "32a66949e030da00e8c7d4434b251670a91556f4144941d37452769c25d58a53" [[package]] name = "litemap" -version = "0.8.1" +version = "0.8.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6373607a59f0be73a39b6fe456b8192fcc3585f602af20751600e974dd455e77" +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" @@ -3831,6 +4281,15 @@ version = "0.4.29" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "5e5032e24019045c762d3c0f28f5b6b8bbf38563a65908389bf7978758920897" +[[package]] +name = "lru" +version = "0.16.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a1dc47f592c06f33f8e3aea9591776ec7c9f9e4124778ff8a3c3b87159f7e593" +dependencies = [ + "hashbrown 0.16.1", +] + [[package]] name = "lru-slab" version = "0.1.2" @@ -3846,6 +4305,16 @@ dependencies = [ "twox-hash", ] +[[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" @@ -3887,6 +4356,21 @@ 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 = "mimalloc" version = "0.1.48" @@ -3902,6 +4386,12 @@ 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 = "miniz_oxide" version = "0.8.9" @@ -3919,6 +4409,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "50b7e5b27aa02a74bac8c3f23f448f8d87ff11f92d3aac1a6ed369ee08cc56c1" dependencies = [ "libc", + "log", "wasi", "windows-sys 0.61.2", ] @@ -3938,6 +4429,19 @@ dependencies = [ "smallvec", ] +[[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 = "nix" version = "0.30.1" @@ -3950,6 +4454,16 @@ dependencies = [ "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" @@ -3995,9 +4509,20 @@ dependencies = [ [[package]] name = "num-conv" -version = "0.2.0" +version = "0.2.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "cf97ec579c3c42f953ef76dbf8d55ac91fb219dde70e49aa4a6b7d74e9919050" +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-integer" @@ -4040,6 +4565,15 @@ dependencies = [ "libm", ] +[[package]] +name = "num_threads" +version = "0.1.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5c7398b9c8b70908f6371f47ed36737907c87c52af34c268fed0bf0ceb92ead9" +dependencies = [ + "libc", +] + [[package]] name = "object" version = "0.37.3" @@ -4073,7 +4607,7 @@ dependencies = [ "percent-encoding", "quick-xml", "rand 0.10.0", - "reqwest", + "reqwest 0.12.28", "ring", "rustls-pki-types", "serde", @@ -4127,6 +4661,15 @@ dependencies = [ "num-traits", ] +[[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 = "outref" version = "0.5.2" @@ -4234,6 +4777,12 @@ version = "1.0.15" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "57c0d7b74b563b49d38dae00a0c37d4d6de9b432382b2892f0574ddcae73fd0a" +[[package]] +name = "pathdiff" +version = "0.2.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "df94ce210e5bc13cb6651479fa48d14f601d9858cfe0467f43ae157023b938d3" + [[package]] name = "pbjson" version = "0.8.0" @@ -4326,19 +4875,71 @@ version = "0.8.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "8701b58ea97060d5e5b155d383a69952a60943f0e6dfe30b04c287beb0b27455" dependencies = [ - "fixedbitset", + "fixedbitset 0.5.7", "hashbrown 0.15.5", - "indexmap 2.13.0", + "indexmap 2.13.1", "serde", ] +[[package]] +name = "phf" +version = "0.11.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1fd6780a80ae0c52cc120a26a1a42c1ae51b247a253e4e06113d23d2c2edd078" +dependencies = [ + "phf_macros", + "phf_shared 0.11.3", +] + [[package]] name = "phf" version = "0.12.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "913273894cec178f401a31ec4b656318d95473527be05c0752cc41cdc32be8b7" dependencies = [ - "phf_shared", + "phf_shared 0.12.1", +] + +[[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 0.11.3", +] + +[[package]] +name = "phf_generator" +version = "0.11.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3c80231409c20246a13fddb31776fb942c38553c51e871f8cbd687a4cfb5843d" +dependencies = [ + "phf_shared 0.11.3", + "rand 0.8.5", +] + +[[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 0.11.3", + "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]] @@ -4439,9 +5040,9 @@ dependencies = [ [[package]] name = "potential_utf" -version = "0.1.4" +version = "0.1.5" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b73949432f5e2a09657003c25bca5e19a0e9c84f8058ca374f49e0ebe605af77" +checksum = "0103b1cef7ec0cf76490e969665504990193874ea05c85ff9bab8b911d0a0564" dependencies = [ "zerovec", ] @@ -4552,6 +5153,18 @@ dependencies = [ "thiserror 2.0.18", ] +[[package]] +name = "prometheus-parse" +version = "0.2.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "811031bea65e5a401fb2e1f37d802cca6601e204ac463809a3189352d13b78a5" +dependencies = [ + "chrono", + "itertools 0.12.1", + "once_cell", + "regex", +] + [[package]] name = "prost" version = "0.14.3" @@ -4697,6 +5310,7 @@ version = "0.11.14" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "434b42fec591c96ef50e21e886936e66d3cc3f737104fdb9b737c40ffb94c098" dependencies = [ + "aws-lc-rs", "bytes", "getrandom 0.3.4", "lru-slab", @@ -4757,6 +5371,15 @@ dependencies = [ "nibble_vec", ] +[[package]] +name = "rand" +version = "0.8.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "34af8d1a0e25924bc5b7c43c079c942339d8f0a8b57c39049bef581b46327404" +dependencies = [ + "rand_core 0.6.4", +] + [[package]] name = "rand" version = "0.9.2" @@ -4788,6 +5411,12 @@ dependencies = [ "rand_core 0.9.5", ] +[[package]] +name = "rand_core" +version = "0.6.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ec0be4795e2f6a28069bec0b5ff3e2ac9bafc99e6a9a7dc3547996c5c816922c" + [[package]] name = "rand_core" version = "0.9.5" @@ -4803,6 +5432,91 @@ version = "0.10.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0c8d0fd677905edcbeedbf2edb6494d676f0e98d54d5cf9bda0b061cb8fb8aba" +[[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 0.14.0", + "kasuari", + "lru", + "strum", + "thiserror 2.0.18", + "unicode-segmentation", + "unicode-truncate", + "unicode-width 0.2.2", +] + +[[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 0.14.0", + "line-clipping", + "ratatui-core", + "strum", + "time", + "unicode-segmentation", + "unicode-width 0.2.2", +] + [[package]] name = "rayon" version = "1.11.0" @@ -4970,18 +5684,58 @@ dependencies = [ "rustls-pki-types", "serde", "serde_json", - "serde_urlencoded", + "serde_urlencoded", + "sync_wrapper", + "tokio", + "tokio-rustls", + "tokio-util", + "tower", + "tower-http", + "tower-service", + "url", + "wasm-bindgen", + "wasm-bindgen-futures", + "wasm-streams", + "web-sys", +] + +[[package]] +name = "reqwest" +version = "0.13.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ab3f43e3283ab1488b624b44b0e988d0acea0b3214e694730a055cb6b2efa801" +dependencies = [ + "base64 0.22.1", + "bytes", + "encoding_rs", + "futures-core", + "h2", + "http 1.4.0", + "http-body 1.0.1", + "http-body-util", + "hyper", + "hyper-rustls", + "hyper-util", + "js-sys", + "log", + "mime", + "percent-encoding", + "pin-project-lite", + "quinn", + "rustls", + "rustls-pki-types", + "rustls-platform-verifier", + "serde", + "serde_json", "sync_wrapper", "tokio", "tokio-rustls", - "tokio-util", "tower", "tower-http", "tower-service", "url", "wasm-bindgen", "wasm-bindgen-futures", - "wasm-streams", "web-sys", ] @@ -5030,9 +5784,9 @@ dependencies = [ [[package]] name = "rustc-hash" -version = "2.1.1" +version = "2.1.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "357703d41365b4b27c590e3ed91eabb1b663f07c4c084095e60cbed4362dff0d" +checksum = "94300abf3f1ae2e2b8ffb7b58043de3d399c73fa6f4b73826402a5c457614dbe" [[package]] name = "rustc_version" @@ -5107,6 +5861,33 @@ dependencies = [ "zeroize", ] +[[package]] +name = "rustls-platform-verifier" +version = "0.6.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1d99feebc72bae7ab76ba994bb5e121b8d83d910ca40b36e0921f53becc41784" +dependencies = [ + "core-foundation 0.10.1", + "core-foundation-sys", + "jni", + "log", + "once_cell", + "rustls", + "rustls-native-certs", + "rustls-platform-verifier-android", + "rustls-webpki", + "security-framework", + "security-framework-sys", + "webpki-root-certs", + "windows-sys 0.61.2", +] + +[[package]] +name = "rustls-platform-verifier-android" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f87165f0995f63a9fbeea62b64d10b4d9d8e78ec6d7d51fb2125fda7bb36788f" + [[package]] name = "rustls-webpki" version = "0.103.10" @@ -5139,7 +5920,7 @@ dependencies = [ "libc", "log", "memchr", - "nix", + "nix 0.30.1", "radix_trie", "unicode-segmentation", "unicode-width 0.2.2", @@ -5232,7 +6013,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b7f4bc775c73d9a02cde8bf7b2ec4c9d12743edf609006c7facc23998404cd1d" dependencies = [ "bitflags 2.11.0", - "core-foundation", + "core-foundation 0.10.1", "core-foundation-sys", "libc", "security-framework-sys", @@ -5384,7 +6165,7 @@ dependencies = [ "chrono", "hex", "indexmap 1.9.3", - "indexmap 2.13.0", + "indexmap 2.13.1", "schemars 0.9.0", "schemars 1.2.1", "serde_core", @@ -5399,7 +6180,7 @@ version = "3.18.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d3db8978e608f1fe7357e211969fd9abdcae80bac1ba7a3369bb7eb6b404eb65" dependencies = [ - "darling", + "darling 0.23.0", "proc-macro2", "quote", "syn 2.0.117", @@ -5411,7 +6192,7 @@ version = "0.9.34+deprecated" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "6a8b1a1a2ebf674015cc02edccce75287f1a0130d394307b36743c2f5d504b47" dependencies = [ - "indexmap 2.13.0", + "indexmap 2.13.1", "itoa", "ryu", "serde", @@ -5455,6 +6236,27 @@ version = "1.3.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0fda2ff0d084019ba4d7c6f371c95d8fd75ce3524c3cb8fb653a3023f6323e64" +[[package]] +name = "signal-hook" +version = "0.3.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d881a16cf4426aa584979d30bd82cb33429027e42122b169753d6ef1085ed6e2" +dependencies = [ + "libc", + "signal-hook-registry", +] + +[[package]] +name = "signal-hook-mio" +version = "0.2.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b75a19a7a740b25bc7944bdee6172368f988763b744e3d4dfe753f6b4ece40cc" +dependencies = [ + "libc", + "mio", + "signal-hook", +] + [[package]] name = "signal-hook-registry" version = "1.4.8" @@ -5467,9 +6269,9 @@ dependencies = [ [[package]] name = "simd-adler32" -version = "0.3.8" +version = "0.3.9" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e320a6c5ad31d271ad523dcf3ad13e2767ad8b1cb8f047f75a8aeaf8da139da2" +checksum = "703d5c7ef118737c72f1af64ad2f6f8c5e1921f818cdcb97b8fe6fc69bf66214" [[package]] name = "simdutf8" @@ -5558,6 +6360,12 @@ dependencies = [ "windows-sys 0.59.0", ] +[[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" @@ -5616,6 +6424,9 @@ 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" @@ -5702,6 +6513,27 @@ dependencies = [ "syn 2.0.117", ] +[[package]] +name = "system-configuration" +version = "0.7.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a13f3d0daba03132c0aa9767f98351b3488edc2c100cda2d2ec2b04f3d8d3c8b" +dependencies = [ + "bitflags 2.11.0", + "core-foundation 0.9.4", + "system-configuration-sys", +] + +[[package]] +name = "system-configuration-sys" +version = "0.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8e1d1b10ced5ca923a1fcb8d03e96b8d3268065d724548c0211415ff6ac6bac4" +dependencies = [ + "core-foundation-sys", + "libc", +] + [[package]] name = "tempfile" version = "3.27.0" @@ -5715,6 +6547,69 @@ dependencies = [ "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 0.11.3", + "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 0.22.1", + "bitflags 2.11.0", + "fancy-regex", + "filedescriptor", + "finl_unicode", + "fixedbitset 0.4.2", + "hex", + "lazy_static", + "libc", + "log", + "memmem", + "nix 0.29.0", + "num-derive", + "num-traits", + "ordered-float 4.6.0", + "pest", + "pest_derive", + "phf 0.11.3", + "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 = "testcontainers" version = "0.27.2" @@ -5821,7 +6716,7 @@ checksum = "7e54bc85fc7faa8bc175c4bab5b92ba8d9a3ce893d0e9f42cc455c8ab16a9e09" dependencies = [ "byteorder", "integer-encoding", - "ordered-float", + "ordered-float 2.10.1", ] [[package]] @@ -5832,7 +6727,9 @@ checksum = "743bd48c283afc0388f9b8827b976905fb217ad9e647fae3a379a9283c4def2c" dependencies = [ "deranged", "itoa", + "libc", "num-conv", + "num_threads", "powerfmt", "serde_core", "time-core", @@ -5866,9 +6763,9 @@ dependencies = [ [[package]] name = "tinystr" -version = "0.8.2" +version = "0.8.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "42d3e9c45c09de15d06dd8acf5f4e0e399e85927b7f00711024eb7ae10fa4869" +checksum = "c8323304221c2a851516f22236c5722a72eaa19749016521d6dff0824447d96d" dependencies = [ "displaydoc", "zerovec", @@ -5964,20 +6861,20 @@ dependencies = [ [[package]] name = "toml_datetime" -version = "1.1.0+spec-1.1.0" +version = "1.1.1+spec-1.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "97251a7c317e03ad83774a8752a7e81fb6067740609f75ea2b585b569a59198f" +checksum = "3165f65f62e28e0115a00b2ebdd37eb6f3b641855f9d636d3cd4103767159ad7" dependencies = [ "serde_core", ] [[package]] name = "toml_edit" -version = "0.25.8+spec-1.1.0" +version = "0.25.10+spec-1.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "16bff38f1d86c47f9ff0647e6838d7bb362522bdf44006c7068c2b1e606f1f3c" +checksum = "a82418ca169e235e6c399a84e395ab6debeb3bc90edc959bf0f48647c6a32d1b" dependencies = [ - "indexmap 2.13.0", + "indexmap 2.13.1", "toml_datetime", "toml_parser", "winnow", @@ -5985,9 +6882,9 @@ dependencies = [ [[package]] name = "toml_parser" -version = "1.1.0+spec-1.1.0" +version = "1.1.2+spec-1.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2334f11ee363607eb04df9b8fc8a13ca1715a72ba8662a26ac285c98aabb4011" +checksum = "a2abe9b86193656635d2411dc43050282ca48aa31c2451210f4202550afb7526" dependencies = [ "winnow", ] @@ -6069,7 +6966,7 @@ checksum = "ebe5ef63511595f1344e2d5cfa636d973292adc0eec1f0ad45fae9f0851ab1d4" dependencies = [ "futures-core", "futures-util", - "indexmap 2.13.0", + "indexmap 2.13.1", "pin-project-lite", "slab", "sync_wrapper", @@ -6190,6 +7087,19 @@ version = "0.2.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e421abadd41a4225275504ea4d6566923418b7f05506fbc9c0fe86ba7396114b" +[[package]] +name = "tui-big-text" +version = "0.8.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ba845eeb0fd68828b4ade874364f89204105506245ba4ed592fa658bef51fd98" +dependencies = [ + "derive_builder", + "font8x8", + "itertools 0.14.0", + "ratatui-core", + "ratatui-widgets", +] + [[package]] name = "twox-hash" version = "2.1.2" @@ -6269,9 +7179,20 @@ checksum = "e6e4313cd5fcd3dad5cafa179702e2b244f760991f45397d14d4ebf38247da75" [[package]] name = "unicode-segmentation" -version = "1.13.0" +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 = "a559e63b5d8004e12f9bce88af5c6d939c58de839b7532cfe9653846cedd2a9e" +checksum = "16b380a1238663e5f8a691f9039c73e1cdae598a30e9855f541d29b08b53e9a5" +dependencies = [ + "itertools 0.14.0", + "unicode-segmentation", + "unicode-width 0.2.2", +] [[package]] name = "unicode-width" @@ -6373,6 +7294,7 @@ version = "1.23.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "5ac8b6f42ead25368cf5b098aeb3dc8a1a2c05a3eee8a9a1a68c640edbfc79d9" dependencies = [ + "atomic", "getrandom 0.4.2", "js-sys", "serde_core", @@ -6397,6 +7319,15 @@ version = "0.8.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "5c3082ca00d5a5ef149bb8b555a72ae84c9c59f7250f013ac822ac2e49b19c64" +[[package]] +name = "vtparse" +version = "0.6.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6d9b2acfb050df409c972a37d3b8e08cdea3bddb0c09db9d53137e504cfabed0" +dependencies = [ + "utf8parse", +] + [[package]] name = "walkdir" version = "2.5.0" @@ -6442,9 +7373,9 @@ dependencies = [ [[package]] name = "wasm-bindgen" -version = "0.2.114" +version = "0.2.117" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6532f9a5c1ece3798cb1c2cfdba640b9b3ba884f5db45973a6f442510a87d38e" +checksum = "0551fc1bb415591e3372d0bc4780db7e587d84e2a7e79da121051c5c4b89d0b0" dependencies = [ "cfg-if", "once_cell", @@ -6455,23 +7386,19 @@ dependencies = [ [[package]] name = "wasm-bindgen-futures" -version = "0.4.64" +version = "0.4.67" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e9c5522b3a28661442748e09d40924dfb9ca614b21c00d3fd135720e48b67db8" +checksum = "03623de6905b7206edd0a75f69f747f134b7f0a2323392d664448bf2d3c5d87e" dependencies = [ - "cfg-if", - "futures-util", "js-sys", - "once_cell", "wasm-bindgen", - "web-sys", ] [[package]] name = "wasm-bindgen-macro" -version = "0.2.114" +version = "0.2.117" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "18a2d50fcf105fb33bb15f00e7a77b772945a2ee45dcf454961fd843e74c18e6" +checksum = "7fbdf9a35adf44786aecd5ff89b4563a90325f9da0923236f6104e603c7e86be" dependencies = [ "quote", "wasm-bindgen-macro-support", @@ -6479,9 +7406,9 @@ dependencies = [ [[package]] name = "wasm-bindgen-macro-support" -version = "0.2.114" +version = "0.2.117" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "03ce4caeaac547cdf713d280eda22a730824dd11e6b8c3ca9e42247b25c631e3" +checksum = "dca9693ef2bab6d4e6707234500350d8dad079eb508dca05530c85dc3a529ff2" dependencies = [ "bumpalo", "proc-macro2", @@ -6492,9 +7419,9 @@ dependencies = [ [[package]] name = "wasm-bindgen-shared" -version = "0.2.114" +version = "0.2.117" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "75a326b8c223ee17883a4251907455a2431acc2791c98c26279376490c378c16" +checksum = "39129a682a6d2d841b6c429d0c51e5cb0ed1a03829d8b3d1e69a011e62cb3d3b" dependencies = [ "unicode-ident", ] @@ -6516,7 +7443,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "bb0e353e6a2fbdc176932bbaab493762eb1255a7900fe0fea1a2f96c296cc909" dependencies = [ "anyhow", - "indexmap 2.13.0", + "indexmap 2.13.1", "wasm-encoder", "wasmparser", ] @@ -6542,15 +7469,15 @@ checksum = "47b807c72e1bac69382b3a6fb3dbe8ea4c0ed87ff5629b8685ae6b9a611028fe" dependencies = [ "bitflags 2.11.0", "hashbrown 0.15.5", - "indexmap 2.13.0", + "indexmap 2.13.1", "semver", ] [[package]] name = "web-sys" -version = "0.3.91" +version = "0.3.94" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "854ba17bb104abfb26ba36da9729addc7ce7f06f5c0f90f3c391f8461cca21f9" +checksum = "cd70027e39b12f0849461e08ffc50b9cd7688d942c1c8e3c7b22273236b4dd0a" dependencies = [ "js-sys", "wasm-bindgen", @@ -6566,6 +7493,87 @@ dependencies = [ "wasm-bindgen", ] +[[package]] +name = "webpki-root-certs" +version = "1.0.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "804f18a4ac2676ffb4e8b5b5fa9ae38af06df08162314f96a68d2a363e21a8ca" +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 4.6.0", + "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" @@ -6638,6 +7646,17 @@ version = "0.2.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f0805222e57f7521d6a62e36fa9163bc891acd422f971defe97d64e70d0a4fe5" +[[package]] +name = "windows-registry" +version = "0.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "02752bf7fbdcce7f2a27a742f798510f3e5ad88dbe84871e5168e2120c3d5720" +dependencies = [ + "windows-link", + "windows-result", + "windows-strings", +] + [[package]] name = "windows-result" version = "0.4.1" @@ -6656,6 +7675,15 @@ dependencies = [ "windows-link", ] +[[package]] +name = "windows-sys" +version = "0.45.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "75283be5efb2831d37ea142365f009c02ec203cd29a3ebecbc093d52315b66d0" +dependencies = [ + "windows-targets 0.42.2", +] + [[package]] name = "windows-sys" version = "0.52.0" @@ -6692,6 +7720,21 @@ dependencies = [ "windows-link", ] +[[package]] +name = "windows-targets" +version = "0.42.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8e5180c00cd44c9b1c88adb3693291f1cd93605ded80c250a75d472756b4d071" +dependencies = [ + "windows_aarch64_gnullvm 0.42.2", + "windows_aarch64_msvc 0.42.2", + "windows_i686_gnu 0.42.2", + "windows_i686_msvc 0.42.2", + "windows_x86_64_gnu 0.42.2", + "windows_x86_64_gnullvm 0.42.2", + "windows_x86_64_msvc 0.42.2", +] + [[package]] name = "windows-targets" version = "0.52.6" @@ -6725,6 +7768,12 @@ dependencies = [ "windows_x86_64_msvc 0.53.1", ] +[[package]] +name = "windows_aarch64_gnullvm" +version = "0.42.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "597a5118570b68bc08d8d59125332c54f1ba9d9adeedeef5b99b02ba2b0698f8" + [[package]] name = "windows_aarch64_gnullvm" version = "0.52.6" @@ -6737,6 +7786,12 @@ version = "0.53.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a9d8416fa8b42f5c947f8482c43e7d89e73a173cead56d044f6a56104a6d1b53" +[[package]] +name = "windows_aarch64_msvc" +version = "0.42.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e08e8864a60f06ef0d0ff4ba04124db8b0fb3be5776a5cd47641e942e58c4d43" + [[package]] name = "windows_aarch64_msvc" version = "0.52.6" @@ -6749,6 +7804,12 @@ version = "0.53.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b9d782e804c2f632e395708e99a94275910eb9100b2114651e04744e9b125006" +[[package]] +name = "windows_i686_gnu" +version = "0.42.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c61d927d8da41da96a81f029489353e68739737d3beca43145c8afec9a31a84f" + [[package]] name = "windows_i686_gnu" version = "0.52.6" @@ -6773,6 +7834,12 @@ version = "0.53.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "fa7359d10048f68ab8b09fa71c3daccfb0e9b559aed648a8f95469c27057180c" +[[package]] +name = "windows_i686_msvc" +version = "0.42.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "44d840b6ec649f480a41c8d80f9c65108b92d89345dd94027bfe06ac444d1060" + [[package]] name = "windows_i686_msvc" version = "0.52.6" @@ -6785,6 +7852,12 @@ version = "0.53.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1e7ac75179f18232fe9c285163565a57ef8d3c89254a30685b57d83a38d326c2" +[[package]] +name = "windows_x86_64_gnu" +version = "0.42.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8de912b8b8feb55c064867cf047dda097f92d51efad5b491dfb98f6bbb70cb36" + [[package]] name = "windows_x86_64_gnu" version = "0.52.6" @@ -6797,6 +7870,12 @@ version = "0.53.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9c3842cdd74a865a8066ab39c8a7a473c0778a3f29370b5fd6b4b9aa7df4a499" +[[package]] +name = "windows_x86_64_gnullvm" +version = "0.42.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "26d41b46a36d453748aedef1486d5c7a85db22e56aff34643984ea85514e94a3" + [[package]] name = "windows_x86_64_gnullvm" version = "0.52.6" @@ -6809,6 +7888,12 @@ version = "0.53.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0ffa179e2d07eee8ad8f57493436566c7cc30ac536a3379fdf008f47f6bb7ae1" +[[package]] +name = "windows_x86_64_msvc" +version = "0.42.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9aec5da331524158c6d1a4ac0ab1541149c0b9505fde06423b02f5ef0106b9f0" + [[package]] name = "windows_x86_64_msvc" version = "0.52.6" @@ -6823,9 +7908,9 @@ checksum = "d6bbff5f0aada427a1e5a6da5f1f98158182f26556f345ac9e04d36d0ebed650" [[package]] name = "winnow" -version = "1.0.0" +version = "1.0.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a90e88e4667264a994d34e6d1ab2d26d398dcdca8b7f52bec8668957517fc7d8" +checksum = "09dac053f1cd375980747450bfc7250c264eaae0583872e845c0c7cd578872b5" dependencies = [ "memchr", ] @@ -6858,7 +7943,7 @@ checksum = "b7c566e0f4b284dd6561c786d9cb0142da491f46a9fbed79ea69cdad5db17f21" dependencies = [ "anyhow", "heck 0.5.0", - "indexmap 2.13.0", + "indexmap 2.13.1", "prettyplease", "syn 2.0.117", "wasm-metadata", @@ -6889,7 +7974,7 @@ checksum = "9d66ea20e9553b30172b5e831994e35fbde2d165325bec84fc43dbf6f4eb9cb2" dependencies = [ "anyhow", "bitflags 2.11.0", - "indexmap 2.13.0", + "indexmap 2.13.1", "log", "serde", "serde_derive", @@ -6908,7 +7993,7 @@ checksum = "ecc8ac4bc1dc3381b7f59c34f00b67e18f910c2c0f50015669dde7def656a736" dependencies = [ "anyhow", "id-arena", - "indexmap 2.13.0", + "indexmap 2.13.1", "log", "semver", "serde", @@ -6920,9 +8005,9 @@ dependencies = [ [[package]] name = "writeable" -version = "0.6.2" +version = "0.6.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9edde0db4769d2dc68579893f2306b26c6ecfbe0ef499b013d731b7b9247e0b9" +checksum = "1ffae5123b2d3fc086436f8834ae3ab053a283cfac8fe0a0b8eaae044768a4c4" [[package]] name = "xattr" @@ -6940,11 +8025,22 @@ version = "0.13.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "66fee0b777b0f5ac1c69bb06d361268faafa61cd4682ae064a171c16c433e9e4" +[[package]] +name = "yaml-rust2" +version = "0.10.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2462ea039c445496d8793d052e13787f2b90e750b833afee748e601c17621ed9" +dependencies = [ + "arraydeque", + "encoding_rs", + "hashlink", +] + [[package]] name = "yoke" -version = "0.8.1" +version = "0.8.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "72d6e5c6afb84d73944e5cedb052c4680d5657337201555f9f2a16b7406d4954" +checksum = "abe8c5fda708d9ca3df187cae8bfb9ceda00dd96231bed36e445a1a48e66f9ca" dependencies = [ "stable_deref_trait", "yoke-derive", @@ -6953,9 +8049,9 @@ dependencies = [ [[package]] name = "yoke-derive" -version = "0.8.1" +version = "0.8.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b659052874eb698efe5b9e8cf382204678a0086ebf46982b79d6ca3182927e5d" +checksum = "de844c262c8848816172cef550288e7dc6c7b7814b4ee56b3e1553f275f1858e" dependencies = [ "proc-macro2", "quote", @@ -6965,18 +8061,18 @@ dependencies = [ [[package]] name = "zerocopy" -version = "0.8.47" +version = "0.8.48" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "efbb2a062be311f2ba113ce66f697a4dc589f85e78a4aea276200804cea0ed87" +checksum = "eed437bf9d6692032087e337407a86f04cd8d6a16a37199ed57949d415bd68e9" dependencies = [ "zerocopy-derive", ] [[package]] name = "zerocopy-derive" -version = "0.8.47" +version = "0.8.48" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0e8bc7269b54418e7aeeef514aa68f8690b8c0489a06b0136e5f57c4c5ccab89" +checksum = "70e3cd084b1788766f53af483dd21f93881ff30d7320490ec3ef7526d203bad4" dependencies = [ "proc-macro2", "quote", @@ -6985,18 +8081,18 @@ dependencies = [ [[package]] name = "zerofrom" -version = "0.1.6" +version = "0.1.7" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "50cc42e0333e05660c3587f3bf9d0478688e15d870fab3346451ce7f8c9fbea5" +checksum = "69faa1f2a1ea75661980b013019ed6687ed0e83d069bc1114e2cc74c6c04c4df" dependencies = [ "zerofrom-derive", ] [[package]] name = "zerofrom-derive" -version = "0.1.6" +version = "0.1.7" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d71e5d6e06ab090c67b5e44993ec16b72dcbaabc526db883a360057678b48502" +checksum = "11532158c46691caf0f2593ea8358fed6bbf68a0315e80aae9bd41fbade684a1" dependencies = [ "proc-macro2", "quote", @@ -7012,9 +8108,9 @@ checksum = "b97154e67e32c85465826e8bcc1c59429aaaf107c1e4a9e53c8d8ccd5eff88d0" [[package]] name = "zerotrie" -version = "0.2.3" +version = "0.2.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2a59c17a5562d507e4b54960e8569ebee33bee890c70aa3fe7b97e85a9fd7851" +checksum = "0f9152d31db0792fa83f70fb2f83148effb5c1f5b8c7686c3459e361d9bc20bf" dependencies = [ "displaydoc", "yoke", @@ -7023,9 +8119,9 @@ dependencies = [ [[package]] name = "zerovec" -version = "0.11.5" +version = "0.11.6" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6c28719294829477f525be0186d13efa9a3c602f7ec202ca9e353d310fb9a002" +checksum = "90f911cbc359ab6af17377d242225f4d75119aec87ea711a880987b18cd7b239" dependencies = [ "yoke", "zerofrom", @@ -7034,9 +8130,9 @@ dependencies = [ [[package]] name = "zerovec-derive" -version = "0.11.2" +version = "0.11.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "eadce39539ca5cb3985590102671f2567e659fca9666581ad3411d59207951f3" +checksum = "625dc425cab0dca6dc3c3319506e6593dcb08a9f387ea3b284dbd52a92c40555" dependencies = [ "proc-macro2", "quote", diff --git a/ballista-cli/Cargo.toml b/ballista-cli/Cargo.toml index c241a842d..859b28392 100644 --- a/ballista-cli/Cargo.toml +++ b/ballista-cli/Cargo.toml @@ -30,14 +30,29 @@ readme = "README.md" [dependencies] ballista = { path = "../ballista/client", version = "53.0.0", features = ["standalone"] } +chrono = { version = "0.4", default-features = false, features = ["std", "clock"], optional = true } clap = { workspace = true, features = ["derive", "cargo"] } +config = { version = "0.15.19", default-features = false, features = ["yaml"], optional = true } +crossterm = { version = "0.29.0", features = ["event-stream"], optional = true } datafusion = { workspace = true } datafusion-cli = { workspace = true } dirs = "6.0" +dotparser = { version = "0.3.0", optional = true } env_logger = { workspace = true } +futures = { version = "0.3.31", optional = true } mimalloc = { workspace = true } +percent-encoding = { version = "2.3.2", optional = true } +prometheus-parse = { version = "0.2", optional = true } +ratatui = { version = "0.30.0", optional = true } +reqwest = { version = "0.13.1", features = ["json"], optional = true } rustyline = "17.0.1" -tokio = { workspace = true, features = ["macros", "rt", "rt-multi-thread", "sync", "parking_lot"] } +serde = { version = "1", features = ["derive"], optional = true } +serde_json = { version = "1", optional = true } +tokio = { workspace = true, features = ["macros", "rt", "rt-multi-thread", "sync", "time", "parking_lot"] } +tracing = { version = "0.1", optional = true } +tracing-appender = { version = "0.2", optional = true } +tracing-subscriber = { version = "0.3", features = ["env-filter", "fmt"], optional = true } +tui-big-text = { version = "0.8.2", optional = true } [features] - +tui = ["dep:chrono", "dep:config", "dep:crossterm", "dep:dotparser", "dep:futures", "dep:percent-encoding", "dep:prometheus-parse", "dep:ratatui", "dep:reqwest", "dep:serde", "dep:serde_json", "dep:tracing", "dep:tracing-subscriber", "dep:tracing-appender", "dep:tui-big-text"] diff --git a/ballista-cli/src/command.rs b/ballista-cli/src/command.rs index 95ed32ce7..031d5bcdc 100644 --- a/ballista-cli/src/command.rs +++ b/ballista-cli/src/command.rs @@ -43,6 +43,8 @@ pub enum Command { SearchFunctions(String), QuietMode(Option), OutputFormat(Option), + #[cfg(feature = "tui")] + OpenTui, } pub enum OutputFormat { @@ -129,11 +131,18 @@ impl Command { "Unexpected change output format, this should be handled outside" .to_string(), )), + #[cfg(feature = "tui")] + Self::OpenTui => match crate::tui::tui_main().await { + Ok(()) => Ok(()), + Err(e) => Err(DataFusionError::Internal(format!( + "Error opening TUI: {e}", + ))), + }, } } fn get_name_and_description(&self) -> (&'static str, &'static str) { - match self { + match *self { Self::Quit => ("\\q", "quit ballista-cli"), Self::ListTables => ("\\d", "list tables"), Self::DescribeTable(_) => ("\\d name", "describe table"), @@ -144,11 +153,13 @@ impl Command { Self::OutputFormat(_) => { ("\\pset [NAME [VALUE]]", "set table output option\n(format)") } + #[cfg(feature = "tui")] + Self::OpenTui => ("\\tui", "open tui"), } } } -const ALL_COMMANDS: [Command; 8] = [ +const ALL_COMMANDS: &[Command] = &[ Command::ListTables, Command::DescribeTable(String::new()), Command::Quit, @@ -157,6 +168,8 @@ const ALL_COMMANDS: [Command; 8] = [ Command::SearchFunctions(String::new()), Command::QuietMode(None), Command::OutputFormat(None), + #[cfg(feature = "tui")] + Command::OpenTui, ]; fn all_commands_info() -> RecordBatch { @@ -165,7 +178,7 @@ fn all_commands_info() -> RecordBatch { Field::new("Description", DataType::Utf8, false), ])); let (names, description): (Vec<&str>, Vec<&str>) = ALL_COMMANDS - .into_iter() + .iter() .map(|c| c.get_name_and_description()) .unzip(); RecordBatch::try_new( @@ -205,6 +218,8 @@ impl FromStr for Command { Self::OutputFormat(Some(subcommand.to_string())) } ("pset", None) => Self::OutputFormat(None), + #[cfg(feature = "tui")] + ("tui", None) => Self::OpenTui, _ => return Err(()), }) } diff --git a/ballista-cli/src/lib.rs b/ballista-cli/src/lib.rs index c0a1dc41e..01cd2331f 100644 --- a/ballista-cli/src/lib.rs +++ b/ballista-cli/src/lib.rs @@ -20,5 +20,7 @@ pub const BALLISTA_CLI_VERSION: &str = env!("CARGO_PKG_VERSION"); pub mod command; pub mod exec; +#[cfg(feature = "tui")] +mod tui; pub use datafusion_cli::{functions, helper, print_format, print_options}; diff --git a/ballista-cli/src/main.rs b/ballista-cli/src/main.rs index 2a6c691d8..b210dc15f 100644 --- a/ballista-cli/src/main.rs +++ b/ballista-cli/src/main.rs @@ -15,6 +15,9 @@ // specific language governing permissions and limitations // under the License. +#[cfg(feature = "tui")] +mod tui; + use std::path::Path; use std::{env, sync::Arc}; @@ -24,7 +27,6 @@ use ballista_cli::{ }; use clap::Parser; use datafusion::{ - common::Result, execution::SessionStateBuilder, prelude::{SessionConfig, SessionContext}, }; @@ -99,20 +101,31 @@ struct Args { #[clap(long, help = "Enables console syntax highlighting")] color: bool, + + #[cfg(feature = "tui")] + #[clap(long, help = "Enables terminal user interface")] + tui: bool, } #[tokio::main] -pub async fn main() -> Result<()> { - env_logger::init(); +pub async fn main() -> Result<(), Box> { let args = Args::parse(); + #[cfg(feature = "tui")] + if args.tui { + return tui::tui_main() + .await + .map_err(|e| Box::new(e) as Box); + } + env_logger::init(); + if !args.quiet { println!("Ballista CLI v{BALLISTA_CLI_VERSION}"); } if let Some(ref path) = args.data_path { let p = Path::new(path); - env::set_current_dir(p).unwrap(); + env::set_current_dir(p)?; }; let mut ballista_config = diff --git a/ballista-cli/src/tui/app.rs b/ballista-cli/src/tui/app.rs new file mode 100644 index 000000000..b8d833e29 --- /dev/null +++ b/ballista-cli/src/tui/app.rs @@ -0,0 +1,694 @@ +// Licensed to the Apache Software Foundation (ASF) under one +// or more contributor license agreements. See the NOTICE file +// distributed with this work for additional information +// regarding copyright ownership. The ASF licenses this file +// to you under the Apache License, Version 2.0 (the +// "License"); you may not use this file except in compliance +// with the License. You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, +// software distributed under the License is distributed on an +// "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY +// KIND, either express or implied. See the License for the +// specific language governing permissions and limitations +// under the License. + +use crate::tui::TuiResult; +use crate::tui::{ + TuiError, + domain::{ + SortOrder, + executors::{ExecutorsData, SortColumn as ExecutorsSortColumn}, + jobs::{ + CancelJobResult, JobDetails, JobsData, SortColumn as JobsSortColumn, + StagesGraph, + }, + metrics::MetricsData, + metrics::SortColumn as MetricsSortColumn, + }, + event::Event, + infrastructure::Settings, +}; +use crossterm::event::{KeyCode, KeyEvent}; +use std::sync::Arc; +use tokio::sync::mpsc::Sender; + +use crate::tui::http_client::HttpClient; +use crate::tui::ui::{ + load_executors_data, load_job_details, load_job_dot, load_jobs_data, + load_metrics_data, +}; + +#[derive(Debug, PartialEq)] +enum Views { + Executors, + Jobs, + Metrics, +} + +#[derive(Debug, PartialEq)] +enum InputMode { + View, + Edit, +} + +#[derive(Debug, PartialEq)] +pub(crate) enum PlanTab { + Stage, + Physical, + Logical, +} + +pub(crate) struct App { + should_quit: bool, + + event_tx: Option>, + + current_view: Views, + input_mode: InputMode, + + pub jobs_data: JobsData, + pub executors_data: ExecutorsData, + pub metrics_data: MetricsData, + + // Popups + pub show_help: bool, + pub show_scheduler_info: bool, + + pub cancel_job_result: Option, + + pub search_term: String, + + pub job_details: Option, + + pub job_dot_popup: Option, + pub job_dot_scroll: u16, + + pub job_plan_popup: Option<(JobDetails, PlanTab)>, + pub job_plan_popup_scroll: u16, + + pub http_client: Arc, +} + +impl App { + pub fn new(config: Settings) -> TuiResult { + Ok(Self { + should_quit: false, + event_tx: None, + current_view: Views::Jobs, + input_mode: InputMode::View, + search_term: String::new(), + show_help: false, + show_scheduler_info: false, + cancel_job_result: None, + job_details: None, + job_dot_popup: None, + job_dot_scroll: 0, + job_plan_popup: None, + job_plan_popup_scroll: 0, + executors_data: ExecutorsData::new(), + jobs_data: JobsData::new(), + metrics_data: MetricsData::new(), + http_client: Arc::new(HttpClient::new(config)?), + }) + } + + pub fn is_scheduler_up(&self) -> bool { + self.executors_data.scheduler_state.is_some() + } + + pub fn is_executors_view(&self) -> bool { + self.current_view == Views::Executors + } + + pub fn is_jobs_view(&self) -> bool { + self.current_view == Views::Jobs + } + + pub fn is_metrics_view(&self) -> bool { + self.current_view == Views::Metrics + } + + pub fn is_edit_mode(&self) -> bool { + self.input_mode == InputMode::Edit + } + + pub fn should_quit(&self) -> bool { + self.should_quit + } + + pub fn set_event_tx(&mut self, tx: Sender) { + self.event_tx = Some(tx); + } + + pub async fn send_event(&self, event: Event) -> TuiResult<()> { + if let Some(tx) = &self.event_tx { + tx.send(event) + .await + .inspect_err(|e| tracing::warn!("Failed to send event: {e:?}")) + .map_err(TuiError::from) + } else { + tracing::warn!("Event_tx is not set"); + Ok(()) + } + } + + pub async fn on_tick(&mut self) { + if self.is_executors_view() { + self.load_executors_data().await; + } else if self.is_jobs_view() { + self.load_jobs_data().await; + let selected_job_id = self + .jobs_data + .selected_job(&self.search_term) + .map(|j| j.job_id.clone()); + let current_details_id = self.job_details.as_ref().map(|d| d.job_id.clone()); + if selected_job_id != current_details_id { + self.job_details = None; + if let Some(job_id) = selected_job_id { + self.load_selected_job_details(&job_id).await; + } + } + } else if self.is_metrics_view() { + self.load_metrics_data().await; + } + } + + pub async fn on_key(&mut self, key: KeyEvent) -> TuiResult<()> { + // Edit mode takes priority over everything + if self.is_edit_mode() { + match key.code { + KeyCode::Esc => { + self.search_term.clear(); + self.input_mode = InputMode::View; + } + KeyCode::Backspace => { + self.search_term.pop(); + } + KeyCode::Char(c) => { + self.search_term.push(c); + } + _ => {} + } + return Ok(()); + } + + if self.cancel_job_result.is_some() { + self.cancel_job_result = None; + return Ok(()); + } + + if self.job_dot_popup.is_some() { + match key.code { + KeyCode::Up => { + self.job_dot_scroll = self.job_dot_scroll.saturating_sub(1); + } + KeyCode::Down => { + self.job_dot_scroll = self.job_dot_scroll.saturating_add(1); + } + _ => { + self.job_dot_popup = None; + self.job_dot_scroll = 0; + } + } + return Ok(()); + } + + if self.job_plan_popup.is_some() { + match key.code { + KeyCode::Up => { + self.job_plan_popup_scroll = + self.job_plan_popup_scroll.saturating_sub(1); + } + KeyCode::Down => { + self.job_plan_popup_scroll += 1; + } + KeyCode::Char('s') => { + if let Some((_, tab)) = &mut self.job_plan_popup { + *tab = PlanTab::Stage; + self.job_plan_popup_scroll = 0; + } + } + KeyCode::Char('p') => { + if let Some((_, tab)) = &mut self.job_plan_popup { + *tab = PlanTab::Physical; + self.job_plan_popup_scroll = 0; + } + } + KeyCode::Char('l') => { + if let Some((_, tab)) = &mut self.job_plan_popup { + *tab = PlanTab::Logical; + self.job_plan_popup_scroll = 0; + } + } + KeyCode::Esc => { + self.job_plan_popup = None; + self.job_plan_popup_scroll = 0; + } + _ => {} + } + return Ok(()); + } + + if self.show_help || self.show_scheduler_info { + self.show_help = false; + self.show_scheduler_info = false; + return Ok(()); + } + + match key.code { + KeyCode::Char('q') | KeyCode::Esc => { + self.should_quit = true; + } + KeyCode::Char('?') | KeyCode::Char('h') => { + self.show_help = true; + } + KeyCode::Char('i') => { + self.show_scheduler_info = true; + } + KeyCode::Char('g') if self.is_jobs_view() => { + self.load_job_dot_data().await; + } + KeyCode::Char('D') if self.is_jobs_view() => { + self.open_job_plan_popup(); + } + KeyCode::Char('e') if self.is_scheduler_up() => { + self.current_view = Views::Executors; + self.load_executors_data().await; + } + KeyCode::Char('j') if self.is_scheduler_up() => { + self.current_view = Views::Jobs; + self.load_jobs_data().await; + } + KeyCode::Char('m') if self.is_scheduler_up() => { + self.current_view = Views::Metrics; + self.load_metrics_data().await; + } + KeyCode::Char('/') if self.is_jobs_view() || self.is_metrics_view() => { + self.input_mode = InputMode::Edit; + } + KeyCode::Char('1') => { + if self.is_jobs_view() { + self.sort_jobs_by(JobsSortColumn::Id); + } else if self.is_executors_view() { + self.sort_executors_by(ExecutorsSortColumn::Host); + } else if self.is_metrics_view() { + self.sort_metrics_by(MetricsSortColumn::Name); + } + } + KeyCode::Char('2') => { + if self.is_jobs_view() { + self.sort_jobs_by(JobsSortColumn::Name); + } else if self.is_executors_view() { + self.sort_executors_by(ExecutorsSortColumn::Id); + } + } + KeyCode::Char('3') => { + if self.is_jobs_view() { + self.sort_jobs_by(JobsSortColumn::Status); + } else if self.is_executors_view() { + self.sort_executors_by(ExecutorsSortColumn::LastSeen); + } + } + KeyCode::Char('4') => { + if self.is_jobs_view() { + self.sort_jobs_by(JobsSortColumn::StagesCompleted); + } + } + KeyCode::Char('5') => { + if self.is_jobs_view() { + self.sort_jobs_by(JobsSortColumn::PercentComplete); + } + } + KeyCode::Char('6') => { + if self.is_jobs_view() { + self.sort_jobs_by(JobsSortColumn::StartTime); + } + } + KeyCode::Char('c') + if self.is_jobs_view() && self.input_mode == InputMode::View => + { + self.cancel_selected_job().await; + } + KeyCode::Down => { + if self.is_jobs_view() { + self.jobs_data.scroll_down(); + self.update_selected_job_details().await; + } else if self.is_executors_view() { + self.executors_data.scroll_down(); + } else if self.is_metrics_view() { + self.metrics_data.scroll_down(); + } + } + KeyCode::Up => { + if self.is_jobs_view() { + self.jobs_data.scroll_up(); + self.update_selected_job_details().await; + } else if self.is_executors_view() { + self.executors_data.scroll_up(); + } else if self.is_metrics_view() { + self.metrics_data.scroll_up(); + } + } + _ => {} + } + Ok(()) + } + + async fn update_selected_job_details(&mut self) { + self.job_details = None; + if let Some(job_id) = self + .jobs_data + .selected_job(&self.search_term) + .map(|j| j.job_id.clone()) + { + self.load_selected_job_details(&job_id).await; + } + } + + async fn load_selected_job_details(&self, job_id: &str) { + if let Err(e) = load_job_details(self, job_id).await { + tracing::error!("Failed to load job details: {e:?}"); + } + } + + async fn load_job_dot_data(&self) { + if let Some(selected_job) = self.jobs_data.selected_job(&self.search_term) { + if selected_job.status == "Completed" + && let Err(e) = load_job_dot(self, &selected_job.job_id).await + { + tracing::error!("Failed to load job dot: {e:?}"); + } + } else { + tracing::trace!("No job selected"); + } + } + + async fn load_executors_data(&mut self) { + if let Err(e) = load_executors_data(self).await { + tracing::error!("Failed to load executors data on tick: {e:?}"); + } + } + + async fn load_jobs_data(&mut self) { + if let Err(e) = load_jobs_data(self).await { + tracing::error!("Failed to load jobs data on tick: {e:?}"); + } + } + + async fn load_metrics_data(&mut self) { + if let Err(e) = load_metrics_data(self).await { + tracing::error!("Failed to load metrics data on tick: {e:?}"); + } + } + + fn sort_jobs_by(&mut self, sort_column: JobsSortColumn) { + if self.jobs_data.sort_column == sort_column { + match self.jobs_data.sort_order { + SortOrder::Ascending => { + self.jobs_data.sort_order = SortOrder::Descending; + } + SortOrder::Descending => { + self.jobs_data.sort_column = JobsSortColumn::None; + } + } + } else { + self.jobs_data.sort_column = sort_column; + self.jobs_data.sort_order = SortOrder::Ascending; + } + } + + fn sort_executors_by(&mut self, sort_column: ExecutorsSortColumn) { + if self.executors_data.sort_column == sort_column { + match self.executors_data.sort_order { + SortOrder::Ascending => { + self.executors_data.sort_order = SortOrder::Descending; + } + SortOrder::Descending => { + self.executors_data.sort_column = ExecutorsSortColumn::None; + } + } + } else { + self.executors_data.sort_column = sort_column; + self.executors_data.sort_order = SortOrder::Ascending; + } + self.executors_data.sort(); + } + + fn sort_metrics_by(&mut self, sort_column: MetricsSortColumn) { + if self.metrics_data.sort_column == sort_column { + match self.metrics_data.sort_order { + SortOrder::Ascending => { + self.metrics_data.sort_order = SortOrder::Descending; + } + SortOrder::Descending => { + self.metrics_data.sort_column = MetricsSortColumn::None; + } + } + } else { + self.metrics_data.sort_column = sort_column; + self.metrics_data.sort_order = SortOrder::Ascending; + } + self.metrics_data.sort(); + } + + async fn cancel_selected_job(&mut self) { + if let Some(job) = self.jobs_data.selected_job(&self.search_term) + && (job.status == "Running" || job.status == "Queued") + { + let job_id = job.job_id.clone(); + self.cancel_job_result = + Some(match self.http_client.cancel_job(&job_id).await { + Ok(resp) if resp.canceled => CancelJobResult::Success { job_id }, + Ok(_) => CancelJobResult::NotCanceled { job_id }, + Err(e) => CancelJobResult::Failure { + job_id, + error: e.to_string(), + }, + }); + } + } + + pub fn has_selected_job(&self) -> bool { + self.jobs_data.selected_job(&self.search_term).is_some() + } + + pub fn has_selected_completed_job(&self) -> bool { + self.jobs_data + .selected_job(&self.search_term) + .is_some_and(|j| j.status == "Completed") + } + + pub fn has_more_than_one_job(&self) -> bool { + self.jobs_data.jobs.len() > 1 + } + + fn open_job_plan_popup(&mut self) { + let is_completed = self + .jobs_data + .selected_job(&self.search_term) + .is_some_and(|j| j.status == "Completed"); + if is_completed && let Some(details) = &self.job_details { + self.job_plan_popup = Some((details.clone(), PlanTab::Stage)); + self.job_plan_popup_scroll = 0; + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::tui::domain::{SchedulerState, jobs::Job}; + + fn make_app() -> App { + let settings = + Settings::new().expect("Settings::new should succeed with defaults"); + App::new(settings).expect("App::new should succeed with valid settings") + } + + fn make_job(id: &str, status: &str) -> Job { + Job { + job_id: id.to_string(), + job_name: format!("Job {id}"), + status: status.to_string(), + start_time: 0, + num_stages: 1, + completed_stages: 0, + percent_complete: 0, + } + } + + fn make_scheduler_state() -> SchedulerState { + SchedulerState { + started: 0, + version: "0.1.0".to_string(), + datafusion_version: "40.0.0".to_string(), + substrait_support: false, + keda_support: false, + prometheus_support: false, + graphviz_support: false, + spark_support: false, + scheduling_policy: "round-robin".to_string(), + } + } + + // --- Initial state tests --- + + #[test] + fn new_app_starts_in_jobs_view() { + let app = make_app(); + assert!(app.is_jobs_view()); + assert!(!app.is_executors_view()); + assert!(!app.is_metrics_view()); + } + + #[test] + fn new_app_scheduler_is_not_up() { + let app = make_app(); + assert!(!app.is_scheduler_up()); + } + + #[test] + fn new_app_should_not_quit() { + let app = make_app(); + assert!(!app.should_quit()); + } + + #[test] + fn new_app_is_not_edit_mode() { + let app = make_app(); + assert!(!app.is_edit_mode()); + } + + // --- Scheduler state --- + + #[test] + fn is_scheduler_up_when_scheduler_state_is_some() { + let mut app = make_app(); + app.executors_data.scheduler_state = Some(make_scheduler_state()); + assert!(app.is_scheduler_up()); + } + + // --- Job helper methods --- + + #[test] + fn has_no_selected_job_when_empty() { + let app = make_app(); + assert!(!app.has_selected_job()); + } + + #[test] + fn has_more_than_one_job_false_when_empty() { + let app = make_app(); + assert!(!app.has_more_than_one_job()); + } + + #[test] + fn has_more_than_one_job_true_with_two_jobs() { + let mut app = make_app(); + app.jobs_data.jobs = vec![make_job("j1", "Running"), make_job("j2", "Running")]; + assert!(app.has_more_than_one_job()); + } + + #[test] + fn has_selected_completed_job_true_when_completed_job_selected() { + let mut app = make_app(); + app.jobs_data.jobs = vec![make_job("j1", "Completed")]; + app.jobs_data.table_state.select(Some(0)); + assert!(app.has_selected_completed_job()); + } + + #[test] + fn has_selected_completed_job_false_for_running_job() { + let mut app = make_app(); + app.jobs_data.jobs = vec![make_job("j1", "Running")]; + app.jobs_data.table_state.select(Some(0)); + assert!(!app.has_selected_completed_job()); + } + + // --- sort_jobs_by toggle tests --- + + #[test] + fn sort_jobs_by_new_column_sets_ascending() { + let mut app = make_app(); + app.sort_jobs_by(JobsSortColumn::Id); + assert_eq!(app.jobs_data.sort_column, JobsSortColumn::Id); + assert_eq!(app.jobs_data.sort_order, SortOrder::Ascending); + } + + #[test] + fn sort_jobs_by_same_column_ascending_toggles_to_descending() { + let mut app = make_app(); + app.sort_jobs_by(JobsSortColumn::Name); + app.sort_jobs_by(JobsSortColumn::Name); + assert_eq!(app.jobs_data.sort_column, JobsSortColumn::Name); + assert_eq!(app.jobs_data.sort_order, SortOrder::Descending); + } + + #[test] + fn sort_jobs_by_same_column_descending_resets_to_none() { + let mut app = make_app(); + app.sort_jobs_by(JobsSortColumn::Status); + app.sort_jobs_by(JobsSortColumn::Status); + app.sort_jobs_by(JobsSortColumn::Status); + assert_eq!(app.jobs_data.sort_column, JobsSortColumn::None); + } + + // --- sort_executors_by toggle tests --- + + #[test] + fn sort_executors_by_new_column_sets_ascending() { + let mut app = make_app(); + app.sort_executors_by(ExecutorsSortColumn::Host); + assert_eq!(app.executors_data.sort_column, ExecutorsSortColumn::Host); + assert_eq!(app.executors_data.sort_order, SortOrder::Ascending); + } + + #[test] + fn sort_executors_by_same_column_ascending_toggles_to_descending() { + let mut app = make_app(); + app.sort_executors_by(ExecutorsSortColumn::Id); + app.sort_executors_by(ExecutorsSortColumn::Id); + assert_eq!(app.executors_data.sort_column, ExecutorsSortColumn::Id); + assert_eq!(app.executors_data.sort_order, SortOrder::Descending); + } + + #[test] + fn sort_executors_by_same_column_descending_resets_to_none() { + let mut app = make_app(); + app.sort_executors_by(ExecutorsSortColumn::LastSeen); + app.sort_executors_by(ExecutorsSortColumn::LastSeen); + app.sort_executors_by(ExecutorsSortColumn::LastSeen); + assert_eq!(app.executors_data.sort_column, ExecutorsSortColumn::None); + } + + // --- sort_metrics_by toggle tests --- + + #[test] + fn sort_metrics_by_new_column_sets_ascending() { + let mut app = make_app(); + app.sort_metrics_by(MetricsSortColumn::Name); + assert_eq!(app.metrics_data.sort_column, MetricsSortColumn::Name); + assert_eq!(app.metrics_data.sort_order, SortOrder::Ascending); + } + + #[test] + fn sort_metrics_by_same_column_ascending_toggles_to_descending() { + let mut app = make_app(); + app.sort_metrics_by(MetricsSortColumn::Name); + app.sort_metrics_by(MetricsSortColumn::Name); + assert_eq!(app.metrics_data.sort_column, MetricsSortColumn::Name); + assert_eq!(app.metrics_data.sort_order, SortOrder::Descending); + } + + #[test] + fn sort_metrics_by_same_column_descending_resets_to_none() { + let mut app = make_app(); + app.sort_metrics_by(MetricsSortColumn::Name); + app.sort_metrics_by(MetricsSortColumn::Name); + app.sort_metrics_by(MetricsSortColumn::Name); + assert_eq!(app.metrics_data.sort_column, MetricsSortColumn::None); + } +} diff --git a/ballista-cli/src/tui/domain/executors.rs b/ballista-cli/src/tui/domain/executors.rs new file mode 100644 index 000000000..095594432 --- /dev/null +++ b/ballista-cli/src/tui/domain/executors.rs @@ -0,0 +1,409 @@ +// Licensed to the Apache Software Foundation (ASF) under one +// or more contributor license agreements. See the NOTICE file +// distributed with this work for additional information +// regarding copyright ownership. The ASF licenses this file +// to you under the Apache License, Version 2.0 (the +// "License"); you may not use this file except in compliance +// with the License. You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, +// software distributed under the License is distributed on an +// "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY +// KIND, either express or implied. See the License for the +// specific language governing permissions and limitations +// under the License. + +use crate::tui::domain::{SortOrder, jobs::Job}; +use ratatui::widgets::{ScrollbarState, TableState}; +use serde::Deserialize; + +#[derive(Deserialize, Clone, Debug)] +pub struct Executor { + pub host: String, + pub port: u16, + pub id: String, + pub last_seen: i64, +} + +#[derive(Clone, Debug, PartialEq)] +pub enum SortColumn { + None, + Host, + Id, + LastSeen, +} + +#[derive(Clone, Debug)] +pub struct ExecutorsData { + pub executors: Vec, + pub table_state: TableState, + pub scrollbar_state: ScrollbarState, + pub sort_column: SortColumn, + pub sort_order: SortOrder, + pub scheduler_state: Option, + pub jobs: Vec, +} + +impl ExecutorsData { + pub fn new() -> Self { + Self { + executors: Vec::new(), + table_state: TableState::default(), + scrollbar_state: ScrollbarState::new(0).position(0), + sort_column: SortColumn::None, + sort_order: SortOrder::Ascending, + scheduler_state: None, + jobs: Vec::new(), + } + } + + pub fn sort(&mut self) { + match self.sort_column { + SortColumn::Host => self.executors.sort_by(|a, b| { + let a_host = format!("{}:{}", a.host, a.port); + let b_host = format!("{}:{}", b.host, b.port); + let cmp = a_host.cmp(&b_host); + if self.sort_order == crate::tui::domain::SortOrder::Descending { + cmp.reverse() + } else { + cmp + } + }), + SortColumn::Id => self.executors.sort_by(|a, b| { + let cmp = a.id.cmp(&b.id); + if self.sort_order == crate::tui::domain::SortOrder::Descending { + cmp.reverse() + } else { + cmp + } + }), + SortColumn::LastSeen => self.executors.sort_by(|a, b| { + let cmp = a.last_seen.cmp(&b.last_seen); + if self.sort_order == crate::tui::domain::SortOrder::Descending { + cmp.reverse() + } else { + cmp + } + }), + SortColumn::None => {} + } + } + + fn get_selected_executor_index(&self) -> Option { + self.table_state.selected() + } + + pub fn scroll_down(&mut self) { + if self.executors.is_empty() { + self.table_state.select(None); + return; + } + + if let Some(selected) = self.get_selected_executor_index() { + if selected < self.executors.len() - 1 { + self.table_state.select(Some(selected + 1)); + } else { + self.table_state.select(None); + } + } else { + self.table_state.select(Some(0)); + } + self.scrollbar_state = self + .scrollbar_state + .position(self.get_selected_executor_index().unwrap_or(0)); + } + + pub fn scroll_up(&mut self) { + if self.executors.is_empty() { + self.table_state.select(None); + return; + } + + if let Some(selected) = self.get_selected_executor_index() { + if selected == 0 { + self.table_state.select(None); + } else { + self.table_state.select(Some(selected - 1)); + } + } else { + self.table_state.select(Some(self.executors.len() - 1)); + } + self.scrollbar_state = self + .scrollbar_state + .position(self.get_selected_executor_index().unwrap_or(0)); + } +} + +#[cfg(test)] +mod tests { + use super::*; + + fn make_executor(host: &str, port: u16, id: &str, last_seen: i64) -> Executor { + Executor { + host: host.to_string(), + port, + id: id.to_string(), + last_seen, + } + } + + fn make_executors_data( + executors: Vec, + sort_column: SortColumn, + sort_order: SortOrder, + ) -> ExecutorsData { + ExecutorsData { + executors, + sort_column, + sort_order, + ..ExecutorsData::new() + } + } + + // --- sort tests --- + + #[test] + fn sort_by_none_preserves_order() { + let mut data = make_executors_data( + vec![ + make_executor("z-host", 8080, "id-c", 300), + make_executor("a-host", 8080, "id-a", 100), + make_executor("m-host", 8080, "id-b", 200), + ], + SortColumn::None, + SortOrder::Ascending, + ); + data.sort(); + assert_eq!(data.executors[0].id, "id-c"); + assert_eq!(data.executors[1].id, "id-a"); + assert_eq!(data.executors[2].id, "id-b"); + } + + #[test] + fn sort_by_host_ascending() { + let mut data = make_executors_data( + vec![ + make_executor("z-host", 8080, "id-c", 300), + make_executor("a-host", 8080, "id-a", 100), + make_executor("m-host", 8080, "id-b", 200), + ], + SortColumn::Host, + SortOrder::Ascending, + ); + data.sort(); + assert_eq!(data.executors[0].host, "a-host"); + assert_eq!(data.executors[1].host, "m-host"); + assert_eq!(data.executors[2].host, "z-host"); + } + + #[test] + fn sort_by_host_descending() { + let mut data = make_executors_data( + vec![ + make_executor("a-host", 8080, "id-a", 100), + make_executor("z-host", 8080, "id-c", 300), + make_executor("m-host", 8080, "id-b", 200), + ], + SortColumn::Host, + SortOrder::Descending, + ); + data.sort(); + assert_eq!(data.executors[0].host, "z-host"); + assert_eq!(data.executors[1].host, "m-host"); + assert_eq!(data.executors[2].host, "a-host"); + } + + #[test] + fn sort_by_host_uses_port_in_comparison() { + // Two executors with same host but different ports — sorted as "host:port" + let mut data = make_executors_data( + vec![ + make_executor("host", 9000, "id-b", 200), + make_executor("host", 8080, "id-a", 100), + ], + SortColumn::Host, + SortOrder::Ascending, + ); + data.sort(); + assert_eq!(data.executors[0].port, 8080); + assert_eq!(data.executors[1].port, 9000); + } + + #[test] + fn sort_by_id_ascending() { + let mut data = make_executors_data( + vec![ + make_executor("host", 8080, "id-c", 300), + make_executor("host", 8080, "id-a", 100), + make_executor("host", 8080, "id-b", 200), + ], + SortColumn::Id, + SortOrder::Ascending, + ); + data.sort(); + assert_eq!(data.executors[0].id, "id-a"); + assert_eq!(data.executors[1].id, "id-b"); + assert_eq!(data.executors[2].id, "id-c"); + } + + #[test] + fn sort_by_id_descending() { + let mut data = make_executors_data( + vec![ + make_executor("host", 8080, "id-a", 100), + make_executor("host", 8080, "id-c", 300), + make_executor("host", 8080, "id-b", 200), + ], + SortColumn::Id, + SortOrder::Descending, + ); + data.sort(); + assert_eq!(data.executors[0].id, "id-c"); + assert_eq!(data.executors[1].id, "id-b"); + assert_eq!(data.executors[2].id, "id-a"); + } + + #[test] + fn sort_by_last_seen_ascending() { + let mut data = make_executors_data( + vec![ + make_executor("host", 8080, "id-c", 300), + make_executor("host", 8080, "id-a", 100), + make_executor("host", 8080, "id-b", 200), + ], + SortColumn::LastSeen, + SortOrder::Ascending, + ); + data.sort(); + assert_eq!(data.executors[0].last_seen, 100); + assert_eq!(data.executors[1].last_seen, 200); + assert_eq!(data.executors[2].last_seen, 300); + } + + #[test] + fn sort_by_last_seen_descending() { + let mut data = make_executors_data( + vec![ + make_executor("host", 8080, "id-a", 100), + make_executor("host", 8080, "id-c", 300), + make_executor("host", 8080, "id-b", 200), + ], + SortColumn::LastSeen, + SortOrder::Descending, + ); + data.sort(); + assert_eq!(data.executors[0].last_seen, 300); + assert_eq!(data.executors[1].last_seen, 200); + assert_eq!(data.executors[2].last_seen, 100); + } + + // --- scroll_down tests --- + + #[test] + fn scroll_down_empty_list_stays_none() { + let mut data = ExecutorsData::new(); + data.scroll_down(); + assert_eq!(data.table_state.selected(), None); + } + + #[test] + fn scroll_down_with_no_selection_selects_first() { + let mut data = make_executors_data( + vec![ + make_executor("host", 8080, "id-a", 1), + make_executor("host", 8081, "id-b", 2), + ], + SortColumn::None, + SortOrder::Ascending, + ); + data.scroll_down(); + assert_eq!(data.table_state.selected(), Some(0)); + } + + #[test] + fn scroll_down_advances_selection() { + let mut data = make_executors_data( + vec![ + make_executor("host", 8080, "id-a", 1), + make_executor("host", 8081, "id-b", 2), + make_executor("host", 8082, "id-c", 3), + ], + SortColumn::None, + SortOrder::Ascending, + ); + data.table_state.select(Some(0)); + data.scroll_down(); + assert_eq!(data.table_state.selected(), Some(1)); + } + + #[test] + fn scroll_down_at_last_item_deselects() { + let mut data = make_executors_data( + vec![ + make_executor("host", 8080, "id-a", 1), + make_executor("host", 8081, "id-b", 2), + ], + SortColumn::None, + SortOrder::Ascending, + ); + data.table_state.select(Some(1)); + data.scroll_down(); + assert_eq!(data.table_state.selected(), None); + } + + // --- scroll_up tests --- + + #[test] + fn scroll_up_empty_list_stays_none() { + let mut data = ExecutorsData::new(); + data.scroll_up(); + assert_eq!(data.table_state.selected(), None); + } + + #[test] + fn scroll_up_with_no_selection_selects_last() { + let mut data = make_executors_data( + vec![ + make_executor("host", 8080, "id-a", 1), + make_executor("host", 8081, "id-b", 2), + make_executor("host", 8082, "id-c", 3), + ], + SortColumn::None, + SortOrder::Ascending, + ); + data.scroll_up(); + assert_eq!(data.table_state.selected(), Some(2)); + } + + #[test] + fn scroll_up_moves_selection_back() { + let mut data = make_executors_data( + vec![ + make_executor("host", 8080, "id-a", 1), + make_executor("host", 8081, "id-b", 2), + ], + SortColumn::None, + SortOrder::Ascending, + ); + data.table_state.select(Some(1)); + data.scroll_up(); + assert_eq!(data.table_state.selected(), Some(0)); + } + + #[test] + fn scroll_up_at_first_item_deselects() { + let mut data = make_executors_data( + vec![ + make_executor("host", 8080, "id-a", 1), + make_executor("host", 8081, "id-b", 2), + ], + SortColumn::None, + SortOrder::Ascending, + ); + data.table_state.select(Some(0)); + data.scroll_up(); + assert_eq!(data.table_state.selected(), None); + } +} diff --git a/ballista-cli/src/tui/domain/jobs.rs b/ballista-cli/src/tui/domain/jobs.rs new file mode 100644 index 000000000..0aa9ca7d4 --- /dev/null +++ b/ballista-cli/src/tui/domain/jobs.rs @@ -0,0 +1,594 @@ +// Licensed to the Apache Software Foundation (ASF) under one +// or more contributor license agreements. See the NOTICE file +// distributed with this work for additional information +// regarding copyright ownership. The ASF licenses this file +// to you under the Apache License, Version 2.0 (the +// "License"); you may not use this file except in compliance +// with the License. You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, +// software distributed under the License is distributed on an +// "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY +// KIND, either express or implied. See the License for the +// specific language governing permissions and limitations +// under the License. + +use ratatui::widgets::{ScrollbarState, TableState}; +use serde::Deserialize; + +#[derive(Deserialize, Clone, Debug)] +pub struct Job { + pub job_id: String, + pub job_name: String, + pub status: String, // Running, Completed, Failed, Canceled + pub start_time: i64, + pub num_stages: usize, + pub completed_stages: usize, + pub percent_complete: u8, +} + +#[derive(Clone, Debug, PartialEq)] +pub enum SortColumn { + None, + Id, + Name, + Status, + StagesCompleted, + PercentComplete, + StartTime, +} + +#[derive(Clone, Debug)] +pub struct JobsData { + pub jobs: Vec, + pub table_state: TableState, + pub scrollbar_state: ScrollbarState, + pub sort_column: SortColumn, + pub sort_order: crate::tui::domain::SortOrder, +} + +impl Default for JobsData { + fn default() -> Self { + Self { + jobs: Vec::new(), + table_state: TableState::default(), + scrollbar_state: ScrollbarState::new(0).position(0), + sort_column: SortColumn::None, + sort_order: crate::tui::domain::SortOrder::Ascending, + } + } +} + +impl JobsData { + pub fn new() -> Self { + Self::default() + } +} + +#[derive(Deserialize, Debug)] +pub struct CancelJobResponse { + pub canceled: bool, +} + +pub enum CancelJobResult { + Success { job_id: String }, + NotCanceled { job_id: String }, + Failure { job_id: String, error: String }, +} + +#[derive(Clone, Debug)] +pub struct JobDetails { + pub job_id: String, + pub logical_plan: Option, + pub physical_plan: Option, + pub stage_plan: Option, +} + +#[derive(Clone, Debug)] +pub struct GraphNode { + pub id: String, + pub label: String, +} + +#[derive(Clone, Debug)] +pub struct GraphStage { + pub label: String, + pub nodes: Vec, +} + +#[derive(Clone, Debug)] +pub struct StagesGraph { + pub job_id: String, + pub stages: Vec, + pub edges: Vec<(String, String)>, +} + +impl JobsData { + pub fn sort_jobs(&self, jobs: &mut Vec<&Job>) { + match self.sort_column { + SortColumn::Id => jobs.sort_by(|a, b| { + let cmp = a.job_id.cmp(&b.job_id); + if self.sort_order == crate::tui::domain::SortOrder::Descending { + cmp.reverse() + } else { + cmp + } + }), + SortColumn::Name => jobs.sort_by(|a, b| { + let cmp = a.job_name.cmp(&b.job_name); + if self.sort_order == crate::tui::domain::SortOrder::Descending { + cmp.reverse() + } else { + cmp + } + }), + SortColumn::Status => jobs.sort_by(|a, b| { + let cmp = a.status.cmp(&b.status); + if self.sort_order == crate::tui::domain::SortOrder::Descending { + cmp.reverse() + } else { + cmp + } + }), + SortColumn::StagesCompleted => jobs.sort_by(|a, b| { + let a_stages = a.completed_stages / a.num_stages; + let b_stages = b.completed_stages / b.num_stages; + let cmp = a_stages.cmp(&b_stages); + if self.sort_order == crate::tui::domain::SortOrder::Descending { + cmp.reverse() + } else { + cmp + } + }), + SortColumn::PercentComplete => jobs.sort_by(|a, b| { + let cmp = a.percent_complete.cmp(&b.percent_complete); + if self.sort_order == crate::tui::domain::SortOrder::Descending { + cmp.reverse() + } else { + cmp + } + }), + SortColumn::StartTime => jobs.sort_by(|a, b| { + let cmp = a.start_time.cmp(&b.start_time); + if self.sort_order == crate::tui::domain::SortOrder::Descending { + cmp.reverse() + } else { + cmp + } + }), + SortColumn::None => {} + } + } + + pub fn selected_job<'a>(&'a self, search_term: &str) -> Option<&'a Job> { + let search_term = search_term.to_lowercase(); + let mut filtered: Vec<&Job> = if search_term.is_empty() { + self.jobs.iter().collect() + } else { + self.jobs + .iter() + .filter(|j| { + j.job_id.to_lowercase().contains(&search_term) + || j.job_name.to_lowercase().contains(&search_term) + }) + .collect() + }; + self.sort_jobs(&mut filtered); + self.get_selected_job_index() + .and_then(|idx| filtered.get(idx).copied()) + } + + fn get_selected_job_index(&self) -> Option { + self.table_state.selected() + } + + pub fn scroll_down(&mut self) { + if self.jobs.is_empty() { + self.table_state.select(None); + return; + } + + if let Some(selected) = self.get_selected_job_index() { + if selected < self.jobs.len() - 1 { + self.table_state.select(Some(selected + 1)); + } else { + self.table_state.select(None); + } + } else { + self.table_state.select(Some(0)); + } + self.scrollbar_state = self + .scrollbar_state + .position(self.get_selected_job_index().unwrap_or(0)); + } + + pub fn scroll_up(&mut self) { + if self.jobs.is_empty() { + self.table_state.select(None); + return; + } + + if let Some(selected) = self.get_selected_job_index() { + if selected == 0 { + self.table_state.select(None); + } else { + self.table_state.select(Some(selected - 1)); + } + } else { + self.table_state.select(Some(self.jobs.len() - 1)); + } + self.scrollbar_state = self + .scrollbar_state + .position(self.get_selected_job_index().unwrap_or(0)); + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::tui::domain::SortOrder; + + fn make_job( + id: &str, + name: &str, + status: &str, + start_time: i64, + num_stages: usize, + completed_stages: usize, + percent_complete: u8, + ) -> Job { + Job { + job_id: id.to_string(), + job_name: name.to_string(), + status: status.to_string(), + start_time, + num_stages, + completed_stages, + percent_complete, + } + } + + fn make_jobs_data( + jobs: Vec, + sort_column: SortColumn, + sort_order: SortOrder, + ) -> JobsData { + JobsData { + jobs, + sort_column, + sort_order, + ..JobsData::new() + } + } + + // --- sort_jobs tests --- + + #[test] + fn sort_by_none_preserves_order() { + let jobs = vec![ + make_job("c", "Charlie", "Running", 3, 1, 0, 0), + make_job("a", "Alpha", "Running", 1, 1, 0, 0), + make_job("b", "Beta", "Running", 2, 1, 0, 0), + ]; + let data = make_jobs_data(jobs, SortColumn::None, SortOrder::Ascending); + let mut refs: Vec<&Job> = data.jobs.iter().collect(); + data.sort_jobs(&mut refs); + assert_eq!(refs[0].job_id, "c"); + assert_eq!(refs[1].job_id, "a"); + assert_eq!(refs[2].job_id, "b"); + } + + #[test] + fn sort_by_id_ascending() { + let jobs = vec![ + make_job("c", "C", "Running", 3, 1, 0, 0), + make_job("a", "A", "Running", 1, 1, 0, 0), + make_job("b", "B", "Running", 2, 1, 0, 0), + ]; + let data = make_jobs_data(jobs, SortColumn::Id, SortOrder::Ascending); + let mut refs: Vec<&Job> = data.jobs.iter().collect(); + data.sort_jobs(&mut refs); + assert_eq!(refs[0].job_id, "a"); + assert_eq!(refs[1].job_id, "b"); + assert_eq!(refs[2].job_id, "c"); + } + + #[test] + fn sort_by_id_descending() { + let jobs = vec![ + make_job("a", "A", "Running", 1, 1, 0, 0), + make_job("c", "C", "Running", 3, 1, 0, 0), + make_job("b", "B", "Running", 2, 1, 0, 0), + ]; + let data = make_jobs_data(jobs, SortColumn::Id, SortOrder::Descending); + let mut refs: Vec<&Job> = data.jobs.iter().collect(); + data.sort_jobs(&mut refs); + assert_eq!(refs[0].job_id, "c"); + assert_eq!(refs[1].job_id, "b"); + assert_eq!(refs[2].job_id, "a"); + } + + #[test] + fn sort_by_name_ascending() { + let jobs = vec![ + make_job("1", "Zeta", "Running", 1, 1, 0, 0), + make_job("2", "Alpha", "Running", 2, 1, 0, 0), + make_job("3", "Mu", "Running", 3, 1, 0, 0), + ]; + let data = make_jobs_data(jobs, SortColumn::Name, SortOrder::Ascending); + let mut refs: Vec<&Job> = data.jobs.iter().collect(); + data.sort_jobs(&mut refs); + assert_eq!(refs[0].job_name, "Alpha"); + assert_eq!(refs[1].job_name, "Mu"); + assert_eq!(refs[2].job_name, "Zeta"); + } + + #[test] + fn sort_by_name_descending() { + let jobs = vec![ + make_job("1", "Alpha", "Running", 1, 1, 0, 0), + make_job("2", "Zeta", "Running", 2, 1, 0, 0), + make_job("3", "Mu", "Running", 3, 1, 0, 0), + ]; + let data = make_jobs_data(jobs, SortColumn::Name, SortOrder::Descending); + let mut refs: Vec<&Job> = data.jobs.iter().collect(); + data.sort_jobs(&mut refs); + assert_eq!(refs[0].job_name, "Zeta"); + assert_eq!(refs[1].job_name, "Mu"); + assert_eq!(refs[2].job_name, "Alpha"); + } + + #[test] + fn sort_by_status_ascending() { + let jobs = vec![ + make_job("1", "A", "Running", 1, 1, 0, 0), + make_job("2", "B", "Completed", 2, 1, 0, 0), + make_job("3", "C", "Failed", 3, 1, 0, 0), + ]; + let data = make_jobs_data(jobs, SortColumn::Status, SortOrder::Ascending); + let mut refs: Vec<&Job> = data.jobs.iter().collect(); + data.sort_jobs(&mut refs); + assert_eq!(refs[0].status, "Completed"); + assert_eq!(refs[1].status, "Failed"); + assert_eq!(refs[2].status, "Running"); + } + + #[test] + fn sort_by_status_descending() { + let jobs = vec![ + make_job("1", "A", "Completed", 1, 1, 0, 0), + make_job("2", "B", "Running", 2, 1, 0, 0), + make_job("3", "C", "Failed", 3, 1, 0, 0), + ]; + let data = make_jobs_data(jobs, SortColumn::Status, SortOrder::Descending); + let mut refs: Vec<&Job> = data.jobs.iter().collect(); + data.sort_jobs(&mut refs); + assert_eq!(refs[0].status, "Running"); + assert_eq!(refs[1].status, "Failed"); + assert_eq!(refs[2].status, "Completed"); + } + + #[test] + fn sort_by_percent_complete_ascending() { + let jobs = vec![ + make_job("1", "A", "Running", 1, 1, 0, 75), + make_job("2", "B", "Running", 2, 1, 0, 25), + make_job("3", "C", "Running", 3, 1, 0, 50), + ]; + let data = + make_jobs_data(jobs, SortColumn::PercentComplete, SortOrder::Ascending); + let mut refs: Vec<&Job> = data.jobs.iter().collect(); + data.sort_jobs(&mut refs); + assert_eq!(refs[0].percent_complete, 25); + assert_eq!(refs[1].percent_complete, 50); + assert_eq!(refs[2].percent_complete, 75); + } + + #[test] + fn sort_by_percent_complete_descending() { + let jobs = vec![ + make_job("1", "A", "Running", 1, 1, 0, 25), + make_job("2", "B", "Running", 2, 1, 0, 75), + make_job("3", "C", "Running", 3, 1, 0, 50), + ]; + let data = + make_jobs_data(jobs, SortColumn::PercentComplete, SortOrder::Descending); + let mut refs: Vec<&Job> = data.jobs.iter().collect(); + data.sort_jobs(&mut refs); + assert_eq!(refs[0].percent_complete, 75); + assert_eq!(refs[1].percent_complete, 50); + assert_eq!(refs[2].percent_complete, 25); + } + + #[test] + fn sort_by_start_time_ascending() { + let jobs = vec![ + make_job("1", "A", "Running", 300, 1, 0, 0), + make_job("2", "B", "Running", 100, 1, 0, 0), + make_job("3", "C", "Running", 200, 1, 0, 0), + ]; + let data = make_jobs_data(jobs, SortColumn::StartTime, SortOrder::Ascending); + let mut refs: Vec<&Job> = data.jobs.iter().collect(); + data.sort_jobs(&mut refs); + assert_eq!(refs[0].start_time, 100); + assert_eq!(refs[1].start_time, 200); + assert_eq!(refs[2].start_time, 300); + } + + #[test] + fn sort_by_start_time_descending() { + let jobs = vec![ + make_job("1", "A", "Running", 100, 1, 0, 0), + make_job("2", "B", "Running", 300, 1, 0, 0), + make_job("3", "C", "Running", 200, 1, 0, 0), + ]; + let data = make_jobs_data(jobs, SortColumn::StartTime, SortOrder::Descending); + let mut refs: Vec<&Job> = data.jobs.iter().collect(); + data.sort_jobs(&mut refs); + assert_eq!(refs[0].start_time, 300); + assert_eq!(refs[1].start_time, 200); + assert_eq!(refs[2].start_time, 100); + } + + // --- selected_job tests --- + + #[test] + fn selected_job_no_selection_returns_none() { + let jobs = vec![make_job("j1", "Job One", "Running", 1, 1, 0, 0)]; + let data = make_jobs_data(jobs, SortColumn::None, SortOrder::Ascending); + assert!(data.selected_job("").is_none()); + } + + #[test] + fn selected_job_returns_correct_job() { + let jobs = vec![ + make_job("j1", "Job One", "Running", 1, 1, 0, 0), + make_job("j2", "Job Two", "Running", 2, 1, 0, 0), + ]; + let mut data = make_jobs_data(jobs, SortColumn::None, SortOrder::Ascending); + data.table_state.select(Some(1)); + let job = data.selected_job("").unwrap(); + assert_eq!(job.job_id, "j2"); + } + + #[test] + fn selected_job_filters_by_search_term_on_id() { + let jobs = vec![ + make_job("abc-123", "Job One", "Running", 1, 1, 0, 0), + make_job("xyz-456", "Job Two", "Running", 2, 1, 0, 0), + ]; + let mut data = make_jobs_data(jobs, SortColumn::None, SortOrder::Ascending); + data.table_state.select(Some(0)); + // Only "xyz-456" matches the search; index 0 in filtered list is that job + let job = data.selected_job("xyz").unwrap(); + assert_eq!(job.job_id, "xyz-456"); + } + + #[test] + fn selected_job_filters_by_search_term_on_name() { + let jobs = vec![ + make_job("j1", "Query Alpha", "Running", 1, 1, 0, 0), + make_job("j2", "Query Beta", "Running", 2, 1, 0, 0), + make_job("j3", "Other Job", "Running", 3, 1, 0, 0), + ]; + let mut data = make_jobs_data(jobs, SortColumn::None, SortOrder::Ascending); + data.table_state.select(Some(1)); + // Filtered by "beta": only "Query Beta" matches, index 0 + let job = data.selected_job("beta"); + assert!(job.is_none()); // index 1 out of bounds in filtered list of 1 + + data.table_state.select(Some(0)); + let job = data.selected_job("beta").unwrap(); + assert_eq!(job.job_id, "j2"); + } + + #[test] + fn selected_job_search_is_case_insensitive() { + let jobs = vec![make_job("j1", "My QUERY", "Running", 1, 1, 0, 0)]; + let mut data = make_jobs_data(jobs, SortColumn::None, SortOrder::Ascending); + data.table_state.select(Some(0)); + assert!(data.selected_job("query").is_some()); + assert!(data.selected_job("QUERY").is_some()); + assert!(data.selected_job("Query").is_some()); + } + + #[test] + fn selected_job_no_match_returns_none() { + let jobs = vec![make_job("j1", "Job One", "Running", 1, 1, 0, 0)]; + let mut data = make_jobs_data(jobs, SortColumn::None, SortOrder::Ascending); + data.table_state.select(Some(0)); + assert!(data.selected_job("nonexistent").is_none()); + } + + // --- scroll_down tests --- + + #[test] + fn scroll_down_empty_list_stays_none() { + let mut data = JobsData::new(); + data.scroll_down(); + assert_eq!(data.table_state.selected(), None); + } + + #[test] + fn scroll_down_with_no_selection_selects_first() { + let jobs = vec![ + make_job("j1", "A", "Running", 1, 1, 0, 0), + make_job("j2", "B", "Running", 2, 1, 0, 0), + ]; + let mut data = make_jobs_data(jobs, SortColumn::None, SortOrder::Ascending); + data.scroll_down(); + assert_eq!(data.table_state.selected(), Some(0)); + } + + #[test] + fn scroll_down_advances_selection() { + let jobs = vec![ + make_job("j1", "A", "Running", 1, 1, 0, 0), + make_job("j2", "B", "Running", 2, 1, 0, 0), + make_job("j3", "C", "Running", 3, 1, 0, 0), + ]; + let mut data = make_jobs_data(jobs, SortColumn::None, SortOrder::Ascending); + data.table_state.select(Some(0)); + data.scroll_down(); + assert_eq!(data.table_state.selected(), Some(1)); + } + + #[test] + fn scroll_down_at_last_item_deselects() { + let jobs = vec![ + make_job("j1", "A", "Running", 1, 1, 0, 0), + make_job("j2", "B", "Running", 2, 1, 0, 0), + ]; + let mut data = make_jobs_data(jobs, SortColumn::None, SortOrder::Ascending); + data.table_state.select(Some(1)); + data.scroll_down(); + assert_eq!(data.table_state.selected(), None); + } + + // --- scroll_up tests --- + + #[test] + fn scroll_up_empty_list_stays_none() { + let mut data = JobsData::new(); + data.scroll_up(); + assert_eq!(data.table_state.selected(), None); + } + + #[test] + fn scroll_up_with_no_selection_selects_last() { + let jobs = vec![ + make_job("j1", "A", "Running", 1, 1, 0, 0), + make_job("j2", "B", "Running", 2, 1, 0, 0), + make_job("j3", "C", "Running", 3, 1, 0, 0), + ]; + let mut data = make_jobs_data(jobs, SortColumn::None, SortOrder::Ascending); + data.scroll_up(); + assert_eq!(data.table_state.selected(), Some(2)); + } + + #[test] + fn scroll_up_moves_selection_back() { + let jobs = vec![ + make_job("j1", "A", "Running", 1, 1, 0, 0), + make_job("j2", "B", "Running", 2, 1, 0, 0), + ]; + let mut data = make_jobs_data(jobs, SortColumn::None, SortOrder::Ascending); + data.table_state.select(Some(1)); + data.scroll_up(); + assert_eq!(data.table_state.selected(), Some(0)); + } + + #[test] + fn scroll_up_at_first_item_deselects() { + let jobs = vec![ + make_job("j1", "A", "Running", 1, 1, 0, 0), + make_job("j2", "B", "Running", 2, 1, 0, 0), + ]; + let mut data = make_jobs_data(jobs, SortColumn::None, SortOrder::Ascending); + data.table_state.select(Some(0)); + data.scroll_up(); + assert_eq!(data.table_state.selected(), None); + } +} diff --git a/ballista-cli/src/tui/domain/metrics.rs b/ballista-cli/src/tui/domain/metrics.rs new file mode 100644 index 000000000..4f9fdf971 --- /dev/null +++ b/ballista-cli/src/tui/domain/metrics.rs @@ -0,0 +1,335 @@ +// Licensed to the Apache Software Foundation (ASF) under one +// or more contributor license agreements. See the NOTICE file +// distributed with this work for additional information +// regarding copyright ownership. The ASF licenses this file +// to you under the Apache License, Version 2.0 (the +// "License"); you may not use this file except in compliance +// with the License. You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, +// software distributed under the License is distributed on an +// "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY +// KIND, either express or implied. See the License for the +// specific language governing permissions and limitations +// under the License. + +use crate::tui::domain::SortOrder; +use prometheus_parse::Sample; +use ratatui::widgets::{ScrollbarState, TableState}; +use std::str::FromStr; + +/// A Prometheus metric +/// +/// Returned by the /api/metrics REST endpoint +#[derive(Clone, Debug)] +pub struct Metric { + pub sample: Sample, + pub help: String, +} + +#[derive(Clone, Debug, PartialEq)] +pub enum SortColumn { + None, + Name, +} + +#[derive(Clone, Debug)] +pub struct MetricsData { + pub metrics: Vec, + pub scrollbar_state: ScrollbarState, + pub table_state: TableState, + pub sort_column: SortColumn, + pub sort_order: SortOrder, +} + +impl Default for MetricsData { + fn default() -> Self { + Self { + metrics: vec![], + scrollbar_state: ScrollbarState::new(0), + table_state: TableState::default(), + sort_column: SortColumn::None, + sort_order: SortOrder::Ascending, + } + } +} + +impl MetricsData { + pub fn new() -> Self { + Self::default() + } + + pub fn sort(&mut self) { + match self.sort_column { + SortColumn::Name => self.metrics.sort_by(|a, b| { + let cmp = a.sample.metric.cmp(&b.sample.metric); + if self.sort_order == crate::tui::domain::SortOrder::Descending { + cmp.reverse() + } else { + cmp + } + }), + SortColumn::None => {} + } + } + + fn get_selected_metric_index(&self) -> Option { + self.table_state.selected() + } + + pub fn scroll_down(&mut self) { + if self.metrics.is_empty() { + self.table_state.select(None); + return; + } + + if let Some(selected) = self.get_selected_metric_index() { + if selected < self.metrics.len() - 1 { + self.table_state.select(Some(selected + 1)); + } else { + self.table_state.select(None); + } + } else { + self.table_state.select(Some(0)); + } + + self.scrollbar_state = self + .scrollbar_state + .position(self.get_selected_metric_index().unwrap_or(0)); + } + + pub fn scroll_up(&mut self) { + if self.metrics.is_empty() { + self.table_state.select(None); + return; + } + + if let Some(selected) = self.get_selected_metric_index() { + if selected == 0 { + self.table_state.select(None); + } else { + self.table_state.select(Some(selected - 1)); + } + } else { + self.table_state.select(Some(self.metrics.len() - 1)); + } + + self.scrollbar_state = self + .scrollbar_state + .position(self.get_selected_metric_index().unwrap_or(0)); + } +} + +/// Newtype struct for parsing the HTTP response into a vec +pub(crate) struct MetricsResponse { + pub metrics: Vec, +} + +impl FromStr for MetricsResponse { + type Err = std::io::Error; + + fn from_str(http_response: &str) -> Result { + let mut metrics: Vec = Vec::new(); + + let lines: Vec> = http_response + .lines() + .map(|line| Ok(line.to_string())) + .collect(); + let scrape = prometheus_parse::Scrape::parse(lines.into_iter())?; + for sample in scrape.samples { + let metric = Metric { + sample: sample.clone(), + help: scrape + .docs + .get(&sample.metric) + .unwrap_or(&String::new()) + .to_string(), + }; + metrics.push(metric); + } + // metrics.sort_by(|a, b| a.sample.metric.cmp(&b.sample.metric)); + + Ok(MetricsResponse { metrics }) + } +} + +#[cfg(test)] +mod tests { + use super::*; + use std::str::FromStr; + + const PROMETHEUS_TEXT: &str = "\ +# HELP ballista_jobs_total Total number of jobs submitted +# TYPE ballista_jobs_total counter +ballista_jobs_total 42 +# HELP ballista_executors_active Active executor count +# TYPE ballista_executors_active gauge +ballista_executors_active 3 +"; + + fn parse_metrics(text: &str) -> Vec { + MetricsResponse::from_str(text).unwrap().metrics + } + + // --- MetricsResponse::from_str tests --- + + #[test] + fn parse_valid_prometheus_text_produces_metrics() { + let metrics = parse_metrics(PROMETHEUS_TEXT); + assert!(!metrics.is_empty()); + let names: Vec<&str> = metrics.iter().map(|m| m.sample.metric.as_str()).collect(); + assert!(names.contains(&"ballista_jobs_total")); + assert!(names.contains(&"ballista_executors_active")); + } + + #[test] + fn parse_empty_string_produces_empty_vec() { + let metrics = parse_metrics(""); + assert!(metrics.is_empty()); + } + + #[test] + fn parse_with_help_text_sets_help_field() { + let metrics = parse_metrics(PROMETHEUS_TEXT); + let jobs_metric = metrics + .iter() + .find(|m| m.sample.metric == "ballista_jobs_total") + .unwrap(); + assert_eq!(jobs_metric.help, "Total number of jobs submitted"); + } + + // --- sort tests --- + + fn make_metrics_data( + metrics: Vec, + sort_column: SortColumn, + sort_order: SortOrder, + ) -> MetricsData { + MetricsData { + metrics, + sort_column, + sort_order, + ..MetricsData::new() + } + } + + #[test] + fn sort_by_none_preserves_order() { + let metrics = parse_metrics(PROMETHEUS_TEXT); + let original_names: Vec = + metrics.iter().map(|m| m.sample.metric.clone()).collect(); + let mut data = make_metrics_data(metrics, SortColumn::None, SortOrder::Ascending); + data.sort(); + let after_names: Vec = data + .metrics + .iter() + .map(|m| m.sample.metric.clone()) + .collect(); + assert_eq!(original_names, after_names); + } + + #[test] + fn sort_by_name_ascending() { + let metrics = parse_metrics(PROMETHEUS_TEXT); + let mut data = make_metrics_data(metrics, SortColumn::Name, SortOrder::Ascending); + data.sort(); + let names: Vec<&str> = data + .metrics + .iter() + .map(|m| m.sample.metric.as_str()) + .collect(); + // "ballista_executors_active" < "ballista_jobs_total" lexicographically + assert_eq!(names[0], "ballista_executors_active"); + assert_eq!(names[1], "ballista_jobs_total"); + } + + #[test] + fn sort_by_name_descending() { + let metrics = parse_metrics(PROMETHEUS_TEXT); + let mut data = + make_metrics_data(metrics, SortColumn::Name, SortOrder::Descending); + data.sort(); + let names: Vec<&str> = data + .metrics + .iter() + .map(|m| m.sample.metric.as_str()) + .collect(); + assert_eq!(names[0], "ballista_jobs_total"); + assert_eq!(names[1], "ballista_executors_active"); + } + + // --- scroll_down tests --- + + #[test] + fn scroll_down_empty_list_stays_none() { + let mut data = MetricsData::new(); + data.scroll_down(); + assert_eq!(data.table_state.selected(), None); + } + + #[test] + fn scroll_down_with_no_selection_selects_first() { + let metrics = parse_metrics(PROMETHEUS_TEXT); + let mut data = make_metrics_data(metrics, SortColumn::None, SortOrder::Ascending); + data.scroll_down(); + assert_eq!(data.table_state.selected(), Some(0)); + } + + #[test] + fn scroll_down_advances_selection() { + let metrics = parse_metrics(PROMETHEUS_TEXT); + assert!(metrics.len() >= 2, "Need at least 2 metrics for this test"); + let mut data = make_metrics_data(metrics, SortColumn::None, SortOrder::Ascending); + data.table_state.select(Some(0)); + data.scroll_down(); + assert_eq!(data.table_state.selected(), Some(1)); + } + + #[test] + fn scroll_down_at_last_item_deselects() { + let metrics = parse_metrics(PROMETHEUS_TEXT); + let last = metrics.len() - 1; + let mut data = make_metrics_data(metrics, SortColumn::None, SortOrder::Ascending); + data.table_state.select(Some(last)); + data.scroll_down(); + assert_eq!(data.table_state.selected(), None); + } + + // --- scroll_up tests --- + + #[test] + fn scroll_up_empty_list_stays_none() { + let mut data = MetricsData::new(); + data.scroll_up(); + assert_eq!(data.table_state.selected(), None); + } + + #[test] + fn scroll_up_with_no_selection_selects_last() { + let metrics = parse_metrics(PROMETHEUS_TEXT); + let last = metrics.len() - 1; + let mut data = make_metrics_data(metrics, SortColumn::None, SortOrder::Ascending); + data.scroll_up(); + assert_eq!(data.table_state.selected(), Some(last)); + } + + #[test] + fn scroll_up_moves_selection_back() { + let metrics = parse_metrics(PROMETHEUS_TEXT); + let mut data = make_metrics_data(metrics, SortColumn::None, SortOrder::Ascending); + data.table_state.select(Some(1)); + data.scroll_up(); + assert_eq!(data.table_state.selected(), Some(0)); + } + + #[test] + fn scroll_up_at_first_item_deselects() { + let metrics = parse_metrics(PROMETHEUS_TEXT); + let mut data = make_metrics_data(metrics, SortColumn::None, SortOrder::Ascending); + data.table_state.select(Some(0)); + data.scroll_up(); + assert_eq!(data.table_state.selected(), None); + } +} diff --git a/ballista-cli/src/tui/domain/mod.rs b/ballista-cli/src/tui/domain/mod.rs new file mode 100644 index 000000000..468519456 --- /dev/null +++ b/ballista-cli/src/tui/domain/mod.rs @@ -0,0 +1,41 @@ +// Licensed to the Apache Software Foundation (ASF) under one +// or more contributor license agreements. See the NOTICE file +// distributed with this work for additional information +// regarding copyright ownership. The ASF licenses this file +// to you under the Apache License, Version 2.0 (the +// "License"); you may not use this file except in compliance +// with the License. You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, +// software distributed under the License is distributed on an +// "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY +// KIND, either express or implied. See the License for the +// specific language governing permissions and limitations +// under the License. + +pub mod executors; +pub mod jobs; +pub mod metrics; + +use serde::Deserialize; + +#[derive(Deserialize, Clone, Debug)] +pub struct SchedulerState { + pub started: i64, + pub version: String, + pub datafusion_version: String, + pub substrait_support: bool, + pub keda_support: bool, + pub prometheus_support: bool, + pub graphviz_support: bool, + pub spark_support: bool, + pub scheduling_policy: String, +} + +#[derive(Clone, Debug, PartialEq)] +pub enum SortOrder { + Ascending, + Descending, +} diff --git a/ballista-cli/src/tui/error.rs b/ballista-cli/src/tui/error.rs new file mode 100644 index 000000000..127d14582 --- /dev/null +++ b/ballista-cli/src/tui/error.rs @@ -0,0 +1,103 @@ +// Licensed to the Apache Software Foundation (ASF) under one +// or more contributor license agreements. See the NOTICE file +// distributed with this work for additional information +// regarding copyright ownership. The ASF licenses this file +// to you under the Apache License, Version 2.0 (the +// "License"); you may not use this file except in compliance +// with the License. You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, +// software distributed under the License is distributed on an +// "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY +// KIND, either express or implied. See the License for the +// specific language governing permissions and limitations +// under the License. + +use crate::tui::event::Event; +use config::ConfigError; +use datafusion::common::DataFusionError; +use tokio::sync::mpsc::error::SendError; +use tracing_subscriber::filter::ParseError; + +#[derive(Debug)] +pub enum TuiError { + Reqwest(reqwest::Error), + Json(serde_json::Error), + IO(std::io::Error), + SendError(Box>), + Config(Box), + Tracing(Box), + DataFusion(Box), +} + +impl std::fmt::Display for TuiError { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + match self { + TuiError::Reqwest(err) => write!(f, "Reqwest error: {err}"), + TuiError::Json(err) => write!(f, "JSON error: {err}"), + TuiError::IO(err) => write!(f, "An IO error occurred: {err}"), + TuiError::SendError(err) => write!(f, "Send error: {err}"), + TuiError::Config(err) => write!(f, "Config error: {err}"), + TuiError::Tracing(err) => write!(f, "Tracing error: {err}"), + TuiError::DataFusion(err) => write!(f, "DataFusion error: {err}"), + } + } +} + +impl std::error::Error for TuiError { + fn source(&self) -> Option<&(dyn std::error::Error + 'static)> { + match self { + TuiError::Reqwest(err) => Some(err), + TuiError::Json(err) => Some(err), + TuiError::IO(err) => Some(err), + TuiError::SendError(err) => Some(err.as_ref()), + TuiError::Config(err) => Some(err.as_ref()), + TuiError::Tracing(err) => Some(err.as_ref()), + TuiError::DataFusion(err) => Some(err.as_ref()), + } + } +} + +impl From for TuiError { + fn from(err: reqwest::Error) -> Self { + TuiError::Reqwest(err) + } +} + +impl From for TuiError { + fn from(err: serde_json::Error) -> Self { + TuiError::Json(err) + } +} + +impl From> for TuiError { + fn from(err: SendError) -> Self { + TuiError::SendError(Box::new(err)) + } +} + +impl From for TuiError { + fn from(err: std::io::Error) -> Self { + TuiError::IO(err) + } +} + +impl From for TuiError { + fn from(err: ConfigError) -> Self { + TuiError::Config(Box::new(err)) + } +} + +impl From for TuiError { + fn from(err: ParseError) -> Self { + TuiError::Tracing(Box::new(err)) + } +} + +impl From for TuiError { + fn from(err: DataFusionError) -> Self { + TuiError::DataFusion(Box::new(err)) + } +} diff --git a/ballista-cli/src/tui/event.rs b/ballista-cli/src/tui/event.rs new file mode 100644 index 000000000..220b36f77 --- /dev/null +++ b/ballista-cli/src/tui/event.rs @@ -0,0 +1,86 @@ +// Licensed to the Apache Software Foundation (ASF) under one +// or more contributor license agreements. See the NOTICE file +// distributed with this work for additional information +// regarding copyright ownership. The ASF licenses this file +// to you under the Apache License, Version 2.0 (the +// "License"); you may not use this file except in compliance +// with the License. You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, +// software distributed under the License is distributed on an +// "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY +// KIND, either express or implied. See the License for the +// specific language governing permissions and limitations +// under the License. + +use crossterm::event::{EventStream, KeyEvent}; +use futures::{FutureExt, StreamExt}; +use tokio::sync::mpsc; + +use crate::tui::domain::{ + SchedulerState, + executors::Executor, + jobs::{Job, JobDetails, StagesGraph}, + metrics::Metric, +}; + +#[derive(Clone, Debug)] +pub enum UiData { + Executors(Option, Vec, Vec), + Metrics(Vec), + Jobs(Vec), + JobDetails(JobDetails), + JobDot(StagesGraph), +} + +#[derive(Clone, Debug)] +pub enum Event { + Key(KeyEvent), + Tick, + DataLoaded { data: UiData }, +} + +#[derive(Debug)] +pub struct EventHandler { + rx: mpsc::UnboundedReceiver, +} + +impl EventHandler { + pub fn new(tick_rate: std::time::Duration) -> Self { + let (tx, rx) = mpsc::unbounded_channel(); + + tokio::spawn(async move { + let mut reader = EventStream::new(); + let mut interval = tokio::time::interval(tick_rate); + + loop { + let interval_delay = interval.tick(); + let crossterm_event = reader.next().fuse(); + + tokio::select! { + _ = interval_delay => { + if tx.send(Event::Tick).is_err() { + break; + } + } + Some(Ok(evt)) = crossterm_event => { + if let crossterm::event::Event::Key(key) = evt + && key.kind == crossterm::event::KeyEventKind::Press + && tx.send(Event::Key(key)).is_err() + { + break; + } + } + } + } + }); + + Self { rx } + } + + pub async fn next(&mut self) -> Option { + self.rx.recv().await + } +} diff --git a/ballista-cli/src/tui/http_client.rs b/ballista-cli/src/tui/http_client.rs new file mode 100644 index 000000000..038273a5b --- /dev/null +++ b/ballista-cli/src/tui/http_client.rs @@ -0,0 +1,183 @@ +// Licensed to the Apache Software Foundation (ASF) under one +// or more contributor license agreements. See the NOTICE file +// distributed with this work for additional information +// regarding copyright ownership. The ASF licenses this file +// to you under the Apache License, Version 2.0 (the +// "License"); you may not use this file except in compliance +// with the License. You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, +// software distributed under the License is distributed on an +// "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY +// KIND, either express or implied. See the License for the +// specific language governing permissions and limitations +// under the License. + +use percent_encoding::{NON_ALPHANUMERIC, percent_encode}; +use reqwest::{Client, Response}; +use serde::de::DeserializeOwned; +use std::time::Duration; + +use crate::tui::{ + TuiResult, + domain::{ + SchedulerState, + executors::Executor, + jobs::{CancelJobResponse, Job, JobDetails}, + metrics::{Metric, MetricsResponse}, + }, + error::TuiError, + infrastructure::Settings, +}; + +pub struct HttpClient { + scheduler_url: String, + client: reqwest::Client, +} + +impl HttpClient { + pub fn new(config: Settings) -> TuiResult { + Ok(Self { + scheduler_url: config.scheduler.url, + client: Client::builder() + .timeout(Duration::from_millis(config.http.timeout)) + .build()?, + }) + } + + pub fn scheduler_url(&self) -> &str { + &self.scheduler_url + } + + pub async fn get_scheduler_state(&self) -> TuiResult { + let url = self.url("state"); + self.json::(&url).await + } + + pub async fn get_executors(&self) -> TuiResult> { + let url = self.url("executors"); + self.json::>(&url).await + } + + pub async fn get_jobs(&self) -> TuiResult> { + let url = self.url("jobs"); + self.json::>(&url).await.map(|mut jobs| { + jobs.sort_by(|a, b| b.start_time.cmp(&a.start_time)); // newest first + jobs + }) + } + + pub async fn cancel_job(&self, job_id: &str) -> TuiResult { + let url = format!("{}/api/job/{}", self.scheduler_url, job_id); + tracing::trace!("Going to PATCH {}", &url); + let response = self + .client + .patch(&url) + .send() + .await + .inspect_err(|err| tracing::error!("The HTTP PATCH request failed: {err:?}")) + .map_err(TuiError::from)?; + + let response = response + .error_for_status() + .map_err(TuiError::from) + .inspect_err(|err| tracing::error!("HTTP error status: {err:?}"))?; + + response + .json::() + .await + .map_err(TuiError::from) + .inspect(|data| tracing::trace!("Cancel response: {data:?}")) + .inspect_err(|err| { + tracing::error!("Failed to parse cancel response: {err:?}") + }) + } + + pub async fn get_job_details(&self, job_id: &str) -> TuiResult { + #[derive(serde::Deserialize, Debug)] + struct JobDetailResponse { + logical_plan: Option, + physical_plan: Option, + stage_plan: Option, + } + + let url = format!("{}/api/job/{}", self.scheduler_url, self.url_encode(job_id)); + let resp = self.json::(&url).await?; + Ok(JobDetails { + job_id: job_id.to_string(), + logical_plan: resp.logical_plan, + physical_plan: resp.physical_plan, + stage_plan: resp.stage_plan, + }) + } + + pub async fn get_job_dot(&self, job_id: &str) -> TuiResult { + let url = format!( + "{}/api/job/{}/dot", + self.scheduler_url, + self.url_encode(job_id) + ); + self.text(&url).await + } + + pub async fn get_metrics(&self) -> TuiResult> { + let url = self.url("metrics"); + let body: String = self.text(&url).await?; + let response = body.parse::().map_err(TuiError::from)?; + Ok(response.metrics) + } + + async fn text(&self, url: &str) -> TuiResult { + let response = self.get(url).await?; + let response = response + .error_for_status() + .map_err(TuiError::from) + .inspect_err(|err| tracing::error!("HTTP error status: {err:?}"))?; + + response + .text() + .await + .map_err(TuiError::from) + .inspect(|data| tracing::trace!("Loaded: {data:?}")) + .inspect_err(|err| tracing::error!("The HTTP request failed: {err:?}")) + } + + async fn json(&self, url: &str) -> TuiResult + where + R: std::fmt::Debug + DeserializeOwned, + { + let response = self.get(url).await?; + let response = response + .error_for_status() + .map_err(TuiError::from) + .inspect_err(|err| tracing::error!("HTTP error status: {err:?}"))?; + + response + .json::() + .await + .map_err(TuiError::from) + .inspect(|data| tracing::trace!("Loaded: {data:?}")) + .inspect_err(|err| tracing::error!("The HTTP request failed: {err:?}")) + } + + async fn get(&self, url: &str) -> TuiResult { + tracing::trace!("Going to make a request to {}", &url); + self.client + .get(url) + .send() + .await + .inspect(|data| tracing::trace!("Got: {data:?}")) + .inspect_err(|err| tracing::error!("The HTTP GET request failed: {err:?}")) + .map_err(TuiError::from) + } + + fn url(&self, path: &str) -> String { + format!("{}/api/{}", self.scheduler_url, path) + } + + fn url_encode(&self, job_id: &str) -> String { + percent_encode(job_id.as_bytes(), NON_ALPHANUMERIC).to_string() + } +} diff --git a/ballista-cli/src/tui/infrastructure/config.rs b/ballista-cli/src/tui/infrastructure/config.rs new file mode 100644 index 000000000..5764e554f --- /dev/null +++ b/ballista-cli/src/tui/infrastructure/config.rs @@ -0,0 +1,69 @@ +// Licensed to the Apache Software Foundation (ASF) under one +// or more contributor license agreements. See the NOTICE file +// distributed with this work for additional information +// regarding copyright ownership. The ASF licenses this file +// to you under the Apache License, Version 2.0 (the +// "License"); you may not use this file except in compliance +// with the License. You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, +// software distributed under the License is distributed on an +// "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY +// KIND, either express or implied. See the License for the +// specific language governing permissions and limitations +// under the License. + +use config::{Config, ConfigError, Environment, File, FileFormat}; +use serde::Deserialize; + +#[derive(Debug, Deserialize)] +pub struct SchedulerSettings { + pub url: String, +} + +#[derive(Debug, Deserialize)] +pub struct HttpSettings { + /// Connection timeout. In millis + pub timeout: u64, +} + +#[derive(Debug, Deserialize)] +pub struct Settings { + pub scheduler: SchedulerSettings, + pub http: HttpSettings, +} + +const DEFAULT_CONFIG: &str = r#" +scheduler: + url: http://localhost:50050 + +http: + timeout: 2000 +"#; + +impl Settings { + pub(crate) fn new() -> Result { + let config_dir = dirs::config_dir() + .or_else(|| dirs::home_dir().map(|h| h.join(".config"))) + .unwrap_or_else(|| std::path::PathBuf::from(".config")) + .join("ballista"); + + let s = Config::builder() + // Start off by merging in the "default" configuration file + .add_source(File::from_str(DEFAULT_CONFIG, FileFormat::Yaml)) + // Add in user's config file + .add_source( + File::with_name(&format!("{}/config", config_dir.display())) + .required(false), + ) + // Add in settings from the environment (with a prefix of BALLISTA_) + // E.g. `BALLISTA_SCHEDULER_URL=http://localhost:50051 ballista_cli` + // would set the scheduler url key + .add_source(Environment::with_prefix("BALLISTA")) + .build()?; + + s.try_deserialize() + } +} diff --git a/ballista-cli/src/tui/infrastructure/logging.rs b/ballista-cli/src/tui/infrastructure/logging.rs new file mode 100644 index 000000000..c8d66e945 --- /dev/null +++ b/ballista-cli/src/tui/infrastructure/logging.rs @@ -0,0 +1,58 @@ +// Licensed to the Apache Software Foundation (ASF) under one +// or more contributor license agreements. See the NOTICE file +// distributed with this work for additional information +// regarding copyright ownership. The ASF licenses this file +// to you under the Apache License, Version 2.0 (the +// "License"); you may not use this file except in compliance +// with the License. You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, +// software distributed under the License is distributed on an +// "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY +// KIND, either express or implied. See the License for the +// specific language governing permissions and limitations +// under the License. + +use crate::tui::TuiResult; +use crate::tui::error::TuiError; +use tracing_subscriber::EnvFilter; + +/// Initializes a tracing subscriber that logs to a file. +/// +/// # Arguments +/// +/// * `log_file_prefix` - Prefix for the log file name (e.g., "ballista-tui") +/// * `default_level` - Default log level (e.g., "info", "debug", "trace") +/// +/// # Returns +/// +/// Returns `Ok(())` if file setup succeeds. Subscriber init failures are logged +/// to stderr but do not cause an error return (a global subscriber may already exist). +pub fn init_file_logger(log_file_prefix: &str, default_level: &str) -> TuiResult<()> { + let dir_name = "logs"; + std::fs::create_dir_all(dir_name)?; + + let env_filter = EnvFilter::try_from_default_env() + .or_else(|_| EnvFilter::try_new(default_level))?; + + let file = std::fs::OpenOptions::new() + .write(true) + .create(true) + .truncate(true) + .open(format!("{dir_name}/{log_file_prefix}.log")) + .map_err(TuiError::from)?; + + match tracing_subscriber::fmt() + .with_env_filter(env_filter) + .with_writer(file) + .with_ansi(true) + .try_init() + { + Ok(_) => {} + Err(e) => eprintln!("Failed to initialize the file logger: {:?}", e), + } + + Ok(()) +} diff --git a/ballista-cli/src/tui/infrastructure/mod.rs b/ballista-cli/src/tui/infrastructure/mod.rs new file mode 100644 index 000000000..aadce7d0a --- /dev/null +++ b/ballista-cli/src/tui/infrastructure/mod.rs @@ -0,0 +1,22 @@ +// Licensed to the Apache Software Foundation (ASF) under one +// or more contributor license agreements. See the NOTICE file +// distributed with this work for additional information +// regarding copyright ownership. The ASF licenses this file +// to you under the Apache License, Version 2.0 (the +// "License"); you may not use this file except in compliance +// with the License. You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, +// software distributed under the License is distributed on an +// "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY +// KIND, either express or implied. See the License for the +// specific language governing permissions and limitations +// under the License. + +mod config; +mod logging; + +pub use config::Settings; +pub use logging::init_file_logger; diff --git a/ballista-cli/src/tui/mod.rs b/ballista-cli/src/tui/mod.rs new file mode 100644 index 000000000..eca94b3ee --- /dev/null +++ b/ballista-cli/src/tui/mod.rs @@ -0,0 +1,127 @@ +// Licensed to the Apache Software Foundation (ASF) under one +// or more contributor license agreements. See the NOTICE file +// distributed with this work for additional information +// regarding copyright ownership. The ASF licenses this file +// to you under the Apache License, Version 2.0 (the +// "License"); you may not use this file except in compliance +// with the License. You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, +// software distributed under the License is distributed on an +// "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY +// KIND, either express or implied. See the License for the +// specific language governing permissions and limitations +// under the License. + +mod app; +mod domain; +mod error; +mod event; +mod http_client; +mod infrastructure; +mod terminal; +mod ui; + +use app::App; +use event::{Event, EventHandler}; +use ratatui::widgets::ScrollbarState; +use std::time::Duration; +use terminal::TuiWrapper; + +use crate::tui::domain::{ + executors::ExecutorsData, jobs::JobsData, metrics::MetricsData, +}; +use crate::tui::{error::TuiError, event::UiData, infrastructure::Settings}; + +pub type TuiResult = Result; + +pub async fn tui_main() -> TuiResult<()> { + infrastructure::init_file_logger("ballista", "info")?; + tracing::info!("Starting the Ballista TUI application"); + + let config = Settings::new()?; + + let mut tui_wrapper = TuiWrapper::new()?; + let mut app = App::new(config)?; + let mut events = EventHandler::new(Duration::from_millis(2000)); + + let (app_tx, mut app_rx) = tokio::sync::mpsc::channel(16); + app.set_event_tx(app_tx); + let _ = crate::tui::ui::load_executors_data(&app).await; + + loop { + tui_wrapper.terminal.draw(|f| ui::render(f, &app))?; + + tokio::select! { + maybe_event = events.next() => { + match maybe_event { + Some(Event::Key(key)) => app.on_key(key).await?, + Some(Event::Tick) => app.on_tick().await, + Some(evt) => tracing::debug!("Unexpected event: {evt:?}"), + None => break, + } + } + Some(app_event) = app_rx.recv() => { + if let Event::DataLoaded { data } = app_event { + match data { + UiData::Executors(state, executors, jobs) => { + let old_scrollbar_position = app.executors_data.scrollbar_state.get_position(); + let scrollbar_state = ScrollbarState::new(executors.len()).position(old_scrollbar_position); + app.executors_data = ExecutorsData { + executors, + scrollbar_state, + table_state: app.executors_data.table_state, + sort_column: app.executors_data.sort_column, + sort_order: app.executors_data.sort_order, + scheduler_state: state, + jobs, + }; + app.executors_data.sort(); + }, + UiData::Metrics(metrics) => { + let old_scrollbar_position = app.metrics_data.scrollbar_state.get_position(); + let scrollbar_state = ScrollbarState::new(metrics.len()).position(old_scrollbar_position); + app.metrics_data = MetricsData { + metrics, + scrollbar_state, + table_state: app.metrics_data.table_state, + sort_column: app.metrics_data.sort_column, + sort_order: app.metrics_data.sort_order + }; + app.metrics_data.sort(); + } + UiData::Jobs(jobs) => { + let old_scrollbar_position = app.jobs_data.scrollbar_state.get_position(); + let scrollbar_state = ScrollbarState::new(jobs.len()).position(old_scrollbar_position); + app.jobs_data = JobsData { + jobs, + scrollbar_state, + table_state: app.jobs_data.table_state, + sort_column: app.jobs_data.sort_column, + sort_order: app.jobs_data.sort_order + }; + } + UiData::JobDetails(details) => { + app.job_details = Some(details); + } + UiData::JobDot(graph) => { + app.job_dot_popup = Some(graph); + app.job_dot_scroll = 0; + } + } + } + } + } + + tokio::task::yield_now().await; + + if app.should_quit() { + tracing::info!("Stopping the Ballista TUI application!"); + break; + } + } + + Ok(()) +} diff --git a/ballista-cli/src/tui/terminal.rs b/ballista-cli/src/tui/terminal.rs new file mode 100644 index 000000000..73c61f76f --- /dev/null +++ b/ballista-cli/src/tui/terminal.rs @@ -0,0 +1,67 @@ +// Licensed to the Apache Software Foundation (ASF) under one +// or more contributor license agreements. See the NOTICE file +// distributed with this work for additional information +// regarding copyright ownership. The ASF licenses this file +// to you under the Apache License, Version 2.0 (the +// "License"); you may not use this file except in compliance +// with the License. You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, +// software distributed under the License is distributed on an +// "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY +// KIND, either express or implied. See the License for the +// specific language governing permissions and limitations +// under the License. + +use crate::tui::TuiResult; +use crossterm::{ + execute, + terminal::{ + EnterAlternateScreen, LeaveAlternateScreen, disable_raw_mode, enable_raw_mode, + }, +}; +use ratatui::{Terminal, backend::CrosstermBackend}; +use std::io::{self, Stdout}; + +pub type Tui = Terminal>; + +fn init() -> TuiResult { + execute!(io::stdout(), EnterAlternateScreen)?; + enable_raw_mode()?; + let backend = CrosstermBackend::new(io::stdout()); + let mut terminal = Terminal::new(backend)?; + terminal.hide_cursor()?; + terminal.clear()?; + + let original_hook = std::panic::take_hook(); + std::panic::set_hook(Box::new(move |panic_info| { + let _ = restore(); + original_hook(panic_info); + })); + + Ok(terminal) +} + +fn restore() -> TuiResult<()> { + execute!(io::stdout(), LeaveAlternateScreen)?; + disable_raw_mode()?; + Ok(()) +} + +pub struct TuiWrapper { + pub terminal: Tui, +} + +impl TuiWrapper { + pub fn new() -> TuiResult { + Ok(Self { terminal: init()? }) + } +} + +impl Drop for TuiWrapper { + fn drop(&mut self) { + let _ = restore(); + } +} diff --git a/ballista-cli/src/tui/ui/cancel_result_popup.rs b/ballista-cli/src/tui/ui/cancel_result_popup.rs new file mode 100644 index 000000000..15cfe1bee --- /dev/null +++ b/ballista-cli/src/tui/ui/cancel_result_popup.rs @@ -0,0 +1,58 @@ +// Licensed to the Apache Software Foundation (ASF) under one +// or more contributor license agreements. See the NOTICE file +// distributed with this work for additional information +// regarding copyright ownership. The ASF licenses this file +// to you under the Apache License, Version 2.0 (the +// "License"); you may not use this file except in compliance +// with the License. You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, +// software distributed under the License is distributed on an +// "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY +// KIND, either express or implied. See the License for the +// specific language governing permissions and limitations +// under the License. + +use crate::tui::app::App; +use crate::tui::domain::jobs::CancelJobResult; +use ratatui::Frame; +use ratatui::prelude::{Color, Line, Span, Style}; +use ratatui::widgets::{Block, Borders, Clear, Paragraph, Wrap}; + +pub(crate) fn render_cancel_result_popup(f: &mut Frame, app: &App) { + if let Some(result) = app.cancel_job_result.as_ref() { + let area = crate::tui::ui::centered_rect(40, 20, f.area()); + f.render_widget(Clear, area); + + let (message, color) = match result { + CancelJobResult::Success { job_id } => ( + format!("Job '{job_id}' was successfully canceled."), + Color::Green, + ), + CancelJobResult::NotCanceled { job_id } => ( + format!("Job '{job_id}' could not be canceled."), + Color::Yellow, + ), + CancelJobResult::Failure { job_id, error } => ( + format!("Failed to cancel job '{job_id}': {error}"), + Color::Red, + ), + }; + + let text = vec![ + Line::from(""), + Line::from(Span::styled(message, Style::default().fg(color))), + ]; + + let block = Block::default() + .title(" Cancel Job (Press any key to close) ") + .borders(Borders::ALL) + .border_style(Style::default().fg(Color::Cyan)); + + let para = Paragraph::new(text).block(block).wrap(Wrap { trim: false }); + + f.render_widget(para, area); + } +} diff --git a/ballista-cli/src/tui/ui/footer.rs b/ballista-cli/src/tui/ui/footer.rs new file mode 100644 index 000000000..412788614 --- /dev/null +++ b/ballista-cli/src/tui/ui/footer.rs @@ -0,0 +1,100 @@ +// Licensed to the Apache Software Foundation (ASF) under one +// or more contributor license agreements. See the NOTICE file +// distributed with this work for additional information +// regarding copyright ownership. The ASF licenses this file +// to you under the Apache License, Version 2.0 (the +// "License"); you may not use this file except in compliance +// with the License. You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, +// software distributed under the License is distributed on an +// "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY +// KIND, either express or implied. See the License for the +// specific language governing permissions and limitations +// under the License. + +use crate::tui::app::App; +use ratatui::Frame; +use ratatui::layout::{Constraint, Direction, Layout, Rect}; +use ratatui::prelude::Style; +use ratatui::text::{Line, Span}; +use ratatui::widgets::{Block, Paragraph}; + +pub(super) fn render_footer(f: &mut Frame, area: Rect, app: &App) { + let mut page_key_bindings = Vec::with_capacity(10); + let mut global_key_bindings = Vec::with_capacity(10); + + if app.is_edit_mode() { + page_key_bindings.push(Span::from("[Esc] Quit edit mode, ")); + } else { + global_key_bindings.push(Span::from("Global key bindings: ")); + + if app.is_scheduler_up() { + global_key_bindings.push(Span::from("[j] Jobs, ")); + global_key_bindings.push(Span::from("[e] Executors, ")); + global_key_bindings.push(Span::from("[m] Metrics, ")); + if app.is_jobs_view() { + if app.has_more_than_one_job() { + page_key_bindings.push(Span::from("[/] Search jobs, ")); + page_key_bindings.push(Span::from( + "[1,2,3] Sort by first/second/third/... column, ", + )); + } + if app.has_selected_job() { + page_key_bindings.push(Span::from("[g] View job stages, ")); + page_key_bindings.push(Span::from("[c] Cancel job, ")); + } + if app.has_selected_completed_job() { + page_key_bindings.push(Span::from("[D] View job plans, ")); + } + if !page_key_bindings.is_empty() { + page_key_bindings.insert(0, Span::from("Jobs key bindings: ")); + } + } else if app.is_executors_view() { + page_key_bindings + .push(Span::from("[1,2,...] Sort by first/second/... column, ")); + } else if app.is_metrics_view() { + page_key_bindings.push(Span::from("Metrics key bindings: ")); + page_key_bindings.push(Span::from("[/] Search metrics, ")); + } + global_key_bindings.push(Span::from("[i] Scheduler info, ")); + } + + global_key_bindings.push(Span::from("[?/h] Help, ")); + global_key_bindings.push(Span::from("[q/Esc] Quit")); + } + + let global_area = if !page_key_bindings.is_empty() { + let areas = Layout::default() + .direction(Direction::Vertical) + .constraints([ + Constraint::Min(1), // page keybindings + Constraint::Min(1), // global keybindings + ]) + .split(area); + + let line = Line::from(page_key_bindings); + + let block = Block::default(); + let paragraph = Paragraph::new(line) + .style(Style::default().bold()) + .block(block) + .centered(); + f.render_widget(paragraph, areas[0]); + + areas[1] + } else { + area + }; + + let line = Line::from(global_key_bindings); + + let block = Block::default(); + let paragraph = Paragraph::new(line) + .style(Style::default().bold()) + .block(block) + .centered(); + f.render_widget(paragraph, global_area); +} diff --git a/ballista-cli/src/tui/ui/header/mod.rs b/ballista-cli/src/tui/ui/header/mod.rs new file mode 100644 index 000000000..d57e0941b --- /dev/null +++ b/ballista-cli/src/tui/ui/header/mod.rs @@ -0,0 +1,121 @@ +// Licensed to the Apache Software Foundation (ASF) under one +// or more contributor license agreements. See the NOTICE file +// distributed with this work for additional information +// regarding copyright ownership. The ASF licenses this file +// to you under the Apache License, Version 2.0 (the +// "License"); you may not use this file except in compliance +// with the License. You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, +// software distributed under the License is distributed on an +// "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY +// KIND, either express or implied. See the License for the +// specific language governing permissions and limitations +// under the License. + +use crate::tui::app::App; +use crate::tui::ui::header::scheduler_state::render_scheduler_state; +use ratatui::widgets::BorderType; +use ratatui::{ + Frame, + layout::{Alignment, Constraint, Direction, Layout, Rect}, + style::{Style, Stylize}, + text::{Line, Text}, + widgets::{Block, Borders, Paragraph}, +}; + +pub mod scheduler_state; + +const MENU_ITEMS: [&str; 3] = ["Jobs", "Executors", "Metrics"]; +const PERCENTAGE: u16 = 100 / MENU_ITEMS.len() as u16; +const MENU_CONSTRAINTS: [Constraint; MENU_ITEMS.len()] = + [Constraint::Percentage(PERCENTAGE); MENU_ITEMS.len()]; + +pub(super) fn render_header(f: &mut Frame, area: Rect, app: &App) { + tracing::debug!("render_header: {area:?}"); + let banner_percentage = match area.width { + 0..200 => 30, + 220..230 => 40, + _ => 45, + }; + let chunks = Layout::default() + .direction(Direction::Horizontal) + .constraints([ + Constraint::Percentage(banner_percentage), // banner + Constraint::Percentage(100 - banner_percentage), // scheduler info and menu + ]) + .split(area); + + render_banner(f, chunks[0]); + render_navbar(f, chunks[1], app); +} + +fn render_banner(f: &mut Frame, area: Rect) { + use tui_big_text::{BigText, PixelSize}; + + let banner_size = match area.width { + 0..70 => PixelSize::Octant, + 70..80 => PixelSize::QuarterHeight, + _ => PixelSize::ThirdHeight, + }; + tracing::debug!("render_header: {area:?}, banner_size: {banner_size:?},"); + let big_text = BigText::builder() + .pixel_size(banner_size) + .style(Style::new().yellow()) + .lines(vec![" Apache".into(), " DataFusion".into()]) + .build(); + f.render_widget(big_text, area); +} + +fn render_navbar(f: &mut Frame, area: Rect, app: &App) { + let chunks = Layout::default() + .direction(Direction::Vertical) + .constraints([ + Constraint::Min(0), // Scheduler info + Constraint::Min(0), // navigation + ]) + .split(area); + + render_scheduler_state(f, chunks[0], app); + render_menu(f, chunks[1], app); +} + +fn render_menu(f: &mut Frame, area: Rect, app: &App) { + let chunks = Layout::default() + .direction(Direction::Horizontal) + .constraints(MENU_CONSTRAINTS) + .split(area); + + for (index, menu_item) in MENU_ITEMS.iter().enumerate() { + let mut block = Block::default() + .borders(Borders::ALL) + .border_style(Style::default().dark_gray()); + + let first_char = menu_item.chars().next().unwrap().underlined(); + let rest_chars = menu_item.chars().skip(1).collect::(); + let line = Line::from(vec![first_char, rest_chars.into()]); + let text = Text::from(line); + + let is_active = (app.is_executors_view() && *menu_item == "Executors") + || (app.is_jobs_view() && *menu_item == "Jobs") + || (app.is_metrics_view() && *menu_item == "Metrics"); + + let style = if is_active && app.is_scheduler_up() { + block = block + .border_style(Style::default().white()) + .border_type(BorderType::Thick); + Style::default().white() + } else { + Style::default().dark_gray() + }; + + let paragraph = Paragraph::new(text) + .style(style) + .block(block.clone()) + .alignment(Alignment::Center); + + f.render_widget(paragraph, chunks[index]); + } +} diff --git a/ballista-cli/src/tui/ui/header/scheduler_state.rs b/ballista-cli/src/tui/ui/header/scheduler_state.rs new file mode 100644 index 000000000..06473d844 --- /dev/null +++ b/ballista-cli/src/tui/ui/header/scheduler_state.rs @@ -0,0 +1,120 @@ +// Licensed to the Apache Software Foundation (ASF) under one +// or more contributor license agreements. See the NOTICE file +// distributed with this work for additional information +// regarding copyright ownership. The ASF licenses this file +// to you under the Apache License, Version 2.0 (the +// "License"); you may not use this file except in compliance +// with the License. You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, +// software distributed under the License is distributed on an +// "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY +// KIND, either express or implied. See the License for the +// specific language governing permissions and limitations +// under the License. + +use crate::tui::app::App; +use chrono::DateTime; +use ratatui::style::Style; +use ratatui::{ + Frame, + layout::{Alignment, Constraint, Direction, Layout, Rect}, + widgets::{Block, Borders, Paragraph}, +}; + +pub fn render_scheduler_state(f: &mut Frame, area: Rect, app: &App) -> bool { + let (started, ballista_version, df_version, is_up) = + match &app.executors_data.scheduler_state { + Some(state) => { + let started = DateTime::from_timestamp_millis(state.started) + .map(|dt| dt.format("%Y-%m-%d %H:%M:%S UTC").to_string()) + .unwrap_or_else(|| "Invalid Date".to_string()); + ( + started, + state.version.clone(), + state.datafusion_version.clone(), + true, + ) + } + None => ( + "-".to_string(), + "unknown".to_string(), + "unknown".to_string(), + false, + ), + }; + + let scheduler_url = app.http_client.scheduler_url(); + + if is_up { + render_scheduler_state_up( + f, + area, + scheduler_url, + started, + ballista_version, + df_version, + ); + } else { + render_scheduler_state_down(f, area, scheduler_url); + } + + is_up +} + +fn render_scheduler_state_down(f: &mut Frame, area: Rect, scheduler_url: &str) { + let scheduler_url_block = Block::default() + .borders(Borders::ALL) + .style(Style::new().red()) + .title(" Scheduler down "); + let scheduler_url_paragraph = Paragraph::new(scheduler_url) + .block(scheduler_url_block) + .alignment(Alignment::Left); + f.render_widget(scheduler_url_paragraph, area); +} + +fn render_scheduler_state_up( + f: &mut Frame, + area: Rect, + scheduler_url: &str, + started: String, + ballista_version: String, + df_version: String, +) { + let chunks = Layout::default() + .direction(Direction::Horizontal) + .constraints([ + Constraint::Percentage(45), // url + Constraint::Percentage(25), // started at + Constraint::Percentage(15), // Ballista version + Constraint::Percentage(15), // DataFusion version + ]) + .split(area); + + let scheduler_url_block = Block::default() + .borders(Borders::ALL) + .title(" Scheduler URL "); + let scheduler_url_paragraph = Paragraph::new(scheduler_url) + .block(scheduler_url_block) + .left_aligned(); + f.render_widget(scheduler_url_paragraph, chunks[0]); + + let started_block = Block::default().borders(Borders::ALL).title(" Started at "); + let started_paragraph = Paragraph::new(started).block(started_block).left_aligned(); + f.render_widget(started_paragraph, chunks[1]); + + let ballista_version_block = + Block::default().borders(Borders::ALL).title(" Ballista "); + let ballista_version_paragraph = Paragraph::new(format!("v{ballista_version}")) + .block(ballista_version_block) + .left_aligned(); + f.render_widget(ballista_version_paragraph, chunks[2]); + + let df_version_block = Block::default().borders(Borders::ALL).title(" DataFusion "); + let df_version_paragraph = Paragraph::new(format!("v{df_version}")) + .block(df_version_block) + .left_aligned(); + f.render_widget(df_version_paragraph, chunks[3]); +} diff --git a/ballista-cli/src/tui/ui/help_overlay.rs b/ballista-cli/src/tui/ui/help_overlay.rs new file mode 100644 index 000000000..081f767d4 --- /dev/null +++ b/ballista-cli/src/tui/ui/help_overlay.rs @@ -0,0 +1,92 @@ +// Licensed to the Apache Software Foundation (ASF) under one +// or more contributor license agreements. See the NOTICE file +// distributed with this work for additional information +// regarding copyright ownership. The ASF licenses this file +// to you under the Apache License, Version 2.0 (the +// "License"); you may not use this file except in compliance +// with the License. You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, +// software distributed under the License is distributed on an +// "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY +// KIND, either express or implied. See the License for the +// specific language governing permissions and limitations +// under the License. + +use crate::tui::app::App; +use ratatui::Frame; +use ratatui::prelude::{Color, Line, Modifier, Span, Style}; +use ratatui::widgets::{Block, Borders, Clear, Paragraph, Wrap}; + +pub(crate) fn render_help_overlay(f: &mut Frame, app: &App) { + let area = crate::tui::ui::centered_rect(25, 50, f.area()); + + f.render_widget(Clear, area); + + let style = if app.is_scheduler_up() { + Style::default() + } else { + Style::default().fg(Color::Gray) + }; + + let help_text = vec![ + Line::from(""), + Line::from(Span::styled( + "KEYBOARD SHORTCUTS", + Style::default() + .fg(Color::Cyan) + .add_modifier(Modifier::BOLD), + )), + Line::from(""), + Line::from(vec![Span::styled( + " Navigation", + Style::default().fg(Color::Yellow), + )]), + Line::from(Span::styled(" j Show Jobs", style)), + Line::from(Span::styled(" e Show Executors", style)), + Line::from(Span::styled(" m Show Metrics", style)), + Line::from(Span::styled(" / Search in Jobs or Metrics", style)), + Line::from(""), + Line::from(vec![Span::styled( + " Jobs view:", + Style::default().fg(Color::Yellow), + )]), + Line::from(Span::styled( + " 1/2/... Sort by first/second/... column", + style, + )), + Line::from(Span::styled( + " g Dot graph if a completed job is selected", + style, + )), + Line::from(Span::styled( + " D View plans if a completed job is selected", + style, + )), + Line::from(Span::styled( + " c Cancel selected Running/Queued job", + style, + )), + Line::from(""), + Line::from(vec![Span::styled( + " General", + Style::default().fg(Color::Yellow), + )]), + Line::from(Span::styled(" i Show Scheduler Info", style)), + Line::from(" ?/h Show this help"), + Line::from(" q/Esc Quit"), + ]; + + let block = Block::default() + .title(" Help (Press any key to close) ") + .borders(Borders::ALL) + .border_style(Style::default().fg(Color::Cyan)); + + let para = Paragraph::new(help_text) + .block(block) + .wrap(Wrap { trim: false }); + + f.render_widget(para, area); +} diff --git a/ballista-cli/src/tui/ui/main/executors/executors_table.rs b/ballista-cli/src/tui/ui/main/executors/executors_table.rs new file mode 100644 index 000000000..603f6e47e --- /dev/null +++ b/ballista-cli/src/tui/ui/main/executors/executors_table.rs @@ -0,0 +1,128 @@ +// Licensed to the Apache Software Foundation (ASF) under one +// or more contributor license agreements. See the NOTICE file +// distributed with this work for additional information +// regarding copyright ownership. The ASF licenses this file +// to you under the Apache License, Version 2.0 (the +// "License"); you may not use this file except in compliance +// with the License. You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, +// software distributed under the License is distributed on an +// "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY +// KIND, either express or implied. See the License for the +// specific language governing permissions and limitations +// under the License. + +use crate::tui::app::App; +use crate::tui::domain::executors::{Executor, SortColumn}; +use crate::tui::ui::vertical_scrollbar::render_scrollbar; +use ratatui::layout::Constraint; +use ratatui::prelude::{Color, Text}; +use ratatui::style::Style; +use ratatui::widgets::{Cell, HighlightSpacing, Row, Table}; +use ratatui::{ + Frame, + layout::Rect, + widgets::{Block, Borders, Paragraph}, +}; + +pub fn render_executors(f: &mut Frame, area: Rect, app: &App) { + let block = Block::default().borders(Borders::ALL).title("Executors"); + + match &app.executors_data.executors { + executors if !executors.is_empty() => { + let mut scroll_state = app.executors_data.scrollbar_state; + render_executors_table(f, area, app); + render_scrollbar(f, area, &mut scroll_state); + } + _no_executors => { + f.render_widget(no_live_executors(block), area); + } + }; +} + +fn render_executors_table(frame: &mut Frame, area: Rect, app: &App) { + let header_style = Style::default().fg(Color::Yellow).bg(Color::Black); + + let sort_column = &app.executors_data.sort_column; + let sort_order = &app.executors_data.sort_order; + + let host_suffix = match (sort_column, sort_order) { + (SortColumn::Host, crate::tui::domain::SortOrder::Ascending) => " ▲", + (SortColumn::Host, crate::tui::domain::SortOrder::Descending) => " ▼", + _ => "", + }; + let id_suffix = match (sort_column, sort_order) { + (SortColumn::Id, crate::tui::domain::SortOrder::Ascending) => " ▲", + (SortColumn::Id, crate::tui::domain::SortOrder::Descending) => " ▼", + _ => "", + }; + let last_seen_suffix = match (sort_column, sort_order) { + (SortColumn::LastSeen, crate::tui::domain::SortOrder::Ascending) => " ▲", + (SortColumn::LastSeen, crate::tui::domain::SortOrder::Descending) => " ▼", + _ => "", + }; + + let header = [ + format!("Host{host_suffix}"), + format!("Id{id_suffix}"), + format!("Last seen{last_seen_suffix}"), + ] + .into_iter() + .map(|item| Text::from(item).centered()) + .map(Cell::from) + .collect::() + .style(header_style) + .height(1); + + let rows = app + .executors_data + .executors + .iter() + .enumerate() + .map(|(i, executor)| { + let color = match i % 2 { + 0 => Color::DarkGray, + _ => Color::Black, + }; + + let host_cell = Cell::from( + Text::from(format!("{}:{}", executor.host, executor.port)).centered(), + ); + let id_cell = Cell::from(Text::from(executor.id.clone()).centered()); + let last_seen_cell = render_last_seen_cell(executor); + + let cells = vec![host_cell, id_cell, last_seen_cell]; + Row::new(cells).style(Style::default().bg(color)) + }); + + let t = Table::new( + rows, + [ + Constraint::Percentage(33), // Host + Constraint::Percentage(33), // Id + Constraint::Percentage(33), // Last seen + ], + ) + .block(Block::default().borders(Borders::all())) + .header(header) + .row_highlight_style(Style::default().bg(Color::Indexed(29))) + .highlight_spacing(HighlightSpacing::Always); + let mut table_state = app.executors_data.table_state; + frame.render_stateful_widget(t, area, &mut table_state); +} + +fn render_last_seen_cell(executor: &Executor) -> Cell<'_> { + let last_seen = chrono::DateTime::from_timestamp_millis(executor.last_seen) + .map(|dt| dt.format("%Y-%m-%d %H:%M:%S UTC").to_string()) + .unwrap_or_else(|| "Invalid Date".to_string()); + Cell::from(Text::from(last_seen).centered()) +} + +fn no_live_executors(block: Block<'_>) -> Paragraph<'_> { + Paragraph::new("No live executors") + .block(block.border_style(Style::new().red())) + .centered() +} diff --git a/ballista-cli/src/tui/ui/main/executors/jobs.rs b/ballista-cli/src/tui/ui/main/executors/jobs.rs new file mode 100644 index 000000000..bcc934b57 --- /dev/null +++ b/ballista-cli/src/tui/ui/main/executors/jobs.rs @@ -0,0 +1,116 @@ +// Licensed to the Apache Software Foundation (ASF) under one +// or more contributor license agreements. See the NOTICE file +// distributed with this work for additional information +// regarding copyright ownership. The ASF licenses this file +// to you under the Apache License, Version 2.0 (the +// "License"); you may not use this file except in compliance +// with the License. You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, +// software distributed under the License is distributed on an +// "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY +// KIND, either express or implied. See the License for the +// specific language governing permissions and limitations +// under the License. + +use crate::tui::app::App; +use ratatui::layout::{Constraint, Direction, Layout}; +use ratatui::style::Style; +use ratatui::{ + Frame, + layout::Rect, + widgets::{Block, Borders, Paragraph}, +}; + +pub fn render_jobs(f: &mut Frame, area: Rect, app: &App) { + fn no_jobs(block: Block<'_>) -> Paragraph<'_> { + Paragraph::new("No Jobs data") + .block(block.border_style(Style::new().gray())) + .centered() + } + + let block = Block::default().borders(Borders::ALL).title("Jobs"); + + match &app.executors_data.jobs { + jobs if !jobs.is_empty() => { + let mut running_jobs = 0; + let mut completed_jobs = 0; + let mut failed_jobs = 0; + let mut queued_jobs = 0; + + for job in jobs { + match job.status.as_str() { + "Running" => running_jobs += 1, + "Completed" => completed_jobs += 1, + "Failed" => failed_jobs += 1, + "Queued" => queued_jobs += 1, + _ => {} + } + } + + let chunks = Layout::default() + .direction(Direction::Horizontal) + .constraints([ + Constraint::Percentage(25), // Running jobs + Constraint::Percentage(25), // Queued jobs + Constraint::Percentage(25), // Completed jobs + Constraint::Percentage(25), // Failed jobs + ]) + .split(area); + + render_running_jobs(f, chunks[0], running_jobs); + render_queued_jobs(f, chunks[1], queued_jobs); + render_completed_jobs(f, chunks[2], completed_jobs); + render_failed_jobs(f, chunks[3], failed_jobs); + } + _no_jobs => { + f.render_widget(no_jobs(block), area); + } + } +} + +fn render_running_jobs(f: &mut Frame, area: Rect, running_jobs: usize) { + let block = Block::default() + .borders(Borders::ALL) + .title(" Running Jobs ") + .style(Style::new().light_blue()); + f.render_widget( + Paragraph::new(format!("Running jobs: {running_jobs}")).block(block), + area, + ); +} + +fn render_queued_jobs(f: &mut Frame, area: Rect, queued_jobs: usize) { + let block = Block::default() + .borders(Borders::ALL) + .title(" Queued Jobs ") + .style(Style::new().magenta()); + f.render_widget( + Paragraph::new(format!("Queued jobs: {queued_jobs}")).block(block), + area, + ); +} + +fn render_completed_jobs(f: &mut Frame, area: Rect, completed_jobs: usize) { + let block = Block::default() + .borders(Borders::ALL) + .title(" Completed Jobs ") + .style(Style::new().green()); + f.render_widget( + Paragraph::new(format!("Completed jobs: {completed_jobs}")).block(block), + area, + ); +} + +fn render_failed_jobs(f: &mut Frame, area: Rect, failed_jobs: usize) { + let block = Block::default() + .borders(Borders::ALL) + .title(" Failed Jobs ") + .style(Style::new().red()); + f.render_widget( + Paragraph::new(format!("Failed jobs: {failed_jobs}")).block(block), + area, + ); +} diff --git a/ballista-cli/src/tui/ui/main/executors/mod.rs b/ballista-cli/src/tui/ui/main/executors/mod.rs new file mode 100644 index 000000000..9a372b0cc --- /dev/null +++ b/ballista-cli/src/tui/ui/main/executors/mod.rs @@ -0,0 +1,75 @@ +// Licensed to the Apache Software Foundation (ASF) under one +// or more contributor license agreements. See the NOTICE file +// distributed with this work for additional information +// regarding copyright ownership. The ASF licenses this file +// to you under the Apache License, Version 2.0 (the +// "License"); you may not use this file except in compliance +// with the License. You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, +// software distributed under the License is distributed on an +// "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY +// KIND, either express or implied. See the License for the +// specific language governing permissions and limitations +// under the License. + +mod executors_table; +mod jobs; + +use jobs::render_jobs; + +use ratatui::{ + Frame, + layout::{Constraint, Direction, Layout, Rect}, + widgets::Clear, +}; + +use crate::tui::{ + TuiResult, + app::App, + event::{Event, UiData}, +}; + +pub fn render_executors(f: &mut Frame, area: Rect, app: &App) { + f.render_widget(Clear, area); + + let chunks = Layout::default() + .direction(Direction::Vertical) + .constraints([ + Constraint::Min(0), // Executors + Constraint::Min(0), // Jobs + ]) + .split(area); + + if app.is_scheduler_up() { + executors_table::render_executors(f, chunks[0], app); + render_jobs(f, chunks[1], app); + } +} + +pub async fn load_executors_data(app: &App) -> TuiResult<()> { + let (scheduler_result, executors_result, jobs_result) = tokio::join!( + app.http_client.get_scheduler_state(), + app.http_client.get_executors(), + app.http_client.get_jobs(), + ); + + let scheduler_state = scheduler_result + .map_err(|e| tracing::error!("Failed to load the scheduler state: {e:?}")) + .ok(); + let executors_data = executors_result.unwrap_or_else(|e| { + tracing::error!("Failed to load the executors data: {e:?}"); + vec![] + }); + let jobs_data = jobs_result.unwrap_or_else(|e| { + tracing::error!("Failed to load the jobs data: {e:?}"); + vec![] + }); + + app.send_event(Event::DataLoaded { + data: UiData::Executors(scheduler_state, executors_data, jobs_data), + }) + .await +} diff --git a/ballista-cli/src/tui/ui/main/jobs/dot_parser.rs b/ballista-cli/src/tui/ui/main/jobs/dot_parser.rs new file mode 100644 index 000000000..40cc71eb3 --- /dev/null +++ b/ballista-cli/src/tui/ui/main/jobs/dot_parser.rs @@ -0,0 +1,171 @@ +// Licensed to the Apache Software Foundation (ASF) under one +// or more contributor license agreements. See the NOTICE file +// distributed with this work for additional information +// regarding copyright ownership. The ASF licenses this file +// to you under the Apache License, Version 2.0 (the +// "License"); you may not use this file except in compliance +// with the License. You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, +// software distributed under the License is distributed on an +// "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY +// KIND, either express or implied. See the License for the +// specific language governing permissions and limitations +// under the License. + +use crate::tui::domain::jobs::{GraphNode, GraphStage, StagesGraph}; +use dotparser::{GraphEvent, dot}; +use std::collections::BTreeMap; + +pub fn parse_dot(job_id: &str, content: &str) -> StagesGraph { + let events = dot::parse(content); + + let unknown_key = u32::MAX; + let mut stages: BTreeMap> = BTreeMap::new(); + let mut edges: Vec<(String, String)> = Vec::new(); + + for event in events { + match event { + GraphEvent::AddNode { id, label, .. } => { + let stage_num = if id.starts_with("stage_") { + id.split('_') + .nth(1) + .and_then(|s| s.parse::().ok()) + .unwrap_or(unknown_key) + } else { + unknown_key + }; + let node = GraphNode { + label: label.unwrap_or_else(|| id.clone()), + id, + }; + stages.entry(stage_num).or_default().push(node); + } + GraphEvent::AddEdge { from, to, .. } => { + edges.push((from, to)); + } + _ => {} + } + } + + let stages = stages + .into_iter() + .filter_map(|(k, nodes)| { + if k == unknown_key { + None + } else { + Some(GraphStage { + label: format!("Stage {k}"), + nodes, + }) + } + }) + .collect(); + + StagesGraph { + job_id: job_id.to_string(), + stages, + edges, + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn empty_content_produces_empty_graph() { + let graph = parse_dot("job1", ""); + assert_eq!(graph.job_id, "job1"); + assert!(graph.stages.is_empty()); + assert!(graph.edges.is_empty()); + } + + #[test] + fn node_without_stage_prefix_is_filtered_out() { + let dot = r#"digraph { root [label="RootNode"] }"#; + let graph = parse_dot("job1", dot); + assert!(graph.stages.is_empty()); + } + + #[test] + fn single_stage_node_no_label_uses_id_as_label() { + let dot = r#"digraph { + stage_0 [label="stage_0"] + }"#; + let graph = parse_dot("job1", dot); + assert_eq!(graph.stages.len(), 1); + assert_eq!(graph.stages[0].label, "Stage 0"); + assert_eq!(graph.stages[0].nodes[0].label, "stage_0"); + assert_eq!(graph.stages[0].nodes[0].id, "stage_0"); + } + + #[test] + fn single_stage_node_with_label_uses_label() { + let dot = r#"digraph { + stage_0 [label="HashAggregate"] + }"#; + let graph = parse_dot("job1", dot); + assert_eq!(graph.stages.len(), 1); + assert_eq!(graph.stages[0].nodes[0].label, "HashAggregate"); + assert_eq!(graph.stages[0].nodes[0].id, "stage_0"); + } + + #[test] + fn nodes_in_different_stages_are_ordered() { + let dot = r#"digraph { + stage_1 [label="SortExec"] + stage_0 [label="HashAgg"] + }"#; + let graph = parse_dot("job1", dot); + assert_eq!(graph.stages.len(), 2); + // BTreeMap ensures stage 0 comes before stage 1 + assert_eq!(graph.stages[0].label, "Stage 0"); + assert_eq!(graph.stages[1].label, "Stage 1"); + } + + #[test] + fn edge_between_nodes_is_captured() { + let dot = r#"digraph { + stage_0 [label="A"] + stage_1 [label="B"] + stage_0 -> stage_1 + }"#; + let graph = parse_dot("job1", dot); + assert_eq!(graph.edges.len(), 1); + assert_eq!( + graph.edges[0], + ("stage_0".to_string(), "stage_1".to_string()) + ); + } + + #[test] + fn realistic_multi_stage_dot() { + let dot = r#"digraph { + stage_0 [label="HashAggregate"] + stage_1 [label="SortExec"] + stage_2 [label="ProjectionExec"] + stage_0 -> stage_1 + stage_1 -> stage_2 + }"#; + let graph = parse_dot("realistic_job", dot); + assert_eq!(graph.job_id, "realistic_job"); + assert_eq!(graph.stages.len(), 3); + assert_eq!(graph.stages[0].nodes[0].label, "HashAggregate"); + assert_eq!(graph.stages[1].nodes[0].label, "SortExec"); + assert_eq!(graph.stages[2].nodes[0].label, "ProjectionExec"); + assert_eq!(graph.edges.len(), 2); + } + + #[test] + fn non_numeric_stage_suffix_goes_to_unknown_and_is_filtered() { + // stage_abc cannot be parsed as u32, so it's filtered out + let dot = r#"digraph { + stage_abc [label="Mystery"] + }"#; + let graph = parse_dot("job1", dot); + assert!(graph.stages.is_empty()); + } +} diff --git a/ballista-cli/src/tui/ui/main/jobs/job_dot_popup.rs b/ballista-cli/src/tui/ui/main/jobs/job_dot_popup.rs new file mode 100644 index 000000000..c9ec2ee1f --- /dev/null +++ b/ballista-cli/src/tui/ui/main/jobs/job_dot_popup.rs @@ -0,0 +1,385 @@ +// Licensed to the Apache Software Foundation (ASF) under one +// or more contributor license agreements. See the NOTICE file +// distributed with this work for additional information +// regarding copyright ownership. The ASF licenses this file +// to you under the Apache License, Version 2.0 (the +// "License"); you may not use this file except in compliance +// with the License. You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, +// software distributed under the License is distributed on an +// "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY +// KIND, either express or implied. See the License for the +// specific language governing permissions and limitations +// under the License. + +use crate::tui::app::App; +use crate::tui::domain::jobs::GraphNode; +use ratatui::Frame; +use ratatui::prelude::{Color, Line, Modifier, Span, Style}; +use ratatui::widgets::{Block, Borders, Clear, Paragraph}; +use std::collections::{HashMap, HashSet}; + +pub(crate) fn render_job_dot_popup(f: &mut Frame, app: &App) { + let Some(graph) = &app.job_dot_popup else { + return; + }; + + let area = crate::tui::ui::centered_rect(60, 60, f.area()); + f.render_widget(Clear, area); + + let block = Block::default() + .title(format!( + " Job Stages: {} (↑↓ scroll, any other key to close) ", + graph.job_id + )) + .borders(Borders::ALL) + .border_style(Style::default().fg(Color::Cyan)); + + // Width of the area inside the popup block borders. + let inner_width = area.width.saturating_sub(2) as usize; + // Node box line structure: ' ┌' + inner + '┐ ' → inner_width = node_inner_width + 4 + let node_inner_width = inner_width.saturating_sub(4); + // Position of ┬/┴ junction within the inner content (0-indexed). + let junction_col = node_inner_width / 2; + // Column of the ↓ arrow within the full inner_width line. + // 1 (indent space) + 1 (┌/└) + junction_col + let arrow_col = 2 + junction_col; + + let border_style = Style::default().fg(Color::Cyan); + let label_style = Style::default().fg(Color::White); + let stage_style = Style::default() + .fg(Color::Yellow) + .add_modifier(Modifier::BOLD); + let arrow_style = Style::default().fg(Color::Green); + + let mut lines: Vec = Vec::new(); + + for stage in graph.stages.iter() { + lines.push(Line::from("")); + + lines.push(Line::from(Span::styled( + format!(" {}:", stage.label), + stage_style, + ))); + lines.push(Line::from("")); + + if stage.nodes.is_empty() { + continue; + } + + let stage_node_ids: HashSet<&str> = + stage.nodes.iter().map(|n| n.id.as_str()).collect(); + + // Intra-stage edges only. + let intra_edges: Vec<(&str, &str)> = graph + .edges + .iter() + .filter(|(from, to)| { + stage_node_ids.contains(from.as_str()) + && stage_node_ids.contains(to.as_str()) + }) + .map(|(from, to)| (from.as_str(), to.as_str())) + .collect(); + + // Build successor map and in-degree map for Kahn's topological sort. + let mut successors: HashMap<&str, Vec<&str>> = HashMap::new(); + let mut in_degree: HashMap<&str, usize> = HashMap::new(); + for node in &stage.nodes { + successors.entry(node.id.as_str()).or_default(); + in_degree.entry(node.id.as_str()).or_insert(0); + } + for &(from, to) in &intra_edges { + successors.entry(from).or_default().push(to); + *in_degree.entry(to).or_insert(0) += 1; + } + + // Kahn's algorithm — initialise queue in original node order. + let mut queue: Vec<&str> = stage + .nodes + .iter() + .filter(|n| in_degree[n.id.as_str()] == 0) + .map(|n| n.id.as_str()) + .collect(); + let mut qi = 0; + let mut ordered_ids: Vec<&str> = Vec::new(); + while qi < queue.len() { + let id = queue[qi]; + qi += 1; + ordered_ids.push(id); + if let Some(succs) = successors.get(id) { + for &succ in succs { + let deg = in_degree.get_mut(succ).unwrap(); + *deg -= 1; + if *deg == 0 { + queue.push(succ); + } + } + } + } + // Append nodes unreachable via the sort (disconnected or cycle). + let ordered_set: HashSet<&str> = ordered_ids.iter().copied().collect(); + for node in &stage.nodes { + if !ordered_set.contains(node.id.as_str()) { + ordered_ids.push(node.id.as_str()); + } + } + + let node_map: HashMap<&str, &GraphNode> = + stage.nodes.iter().map(|n| (n.id.as_str(), n)).collect(); + let edge_set: HashSet<(&str, &str)> = intra_edges.into_iter().collect(); + + for (i, &node_id) in ordered_ids.iter().enumerate() { + let Some(node) = node_map.get(node_id) else { + continue; + }; + + let prev_id = if i > 0 { + Some(ordered_ids[i - 1]) + } else { + None + }; + let next_id = ordered_ids.get(i + 1).copied(); + + let connect_top = + prev_id.is_some_and(|prev| edge_set.contains(&(prev, node_id))); + let connect_bottom = + next_id.is_some_and(|next| edge_set.contains(&(node_id, next))); + + lines.push(node_top_border( + node_inner_width, + junction_col, + connect_top, + border_style, + )); + lines.push(node_text_row( + node_inner_width, + &node.label, + label_style, + border_style, + )); + lines.push(node_bottom_border( + node_inner_width, + junction_col, + connect_bottom, + border_style, + )); + + if connect_bottom { + lines.push(arrow_connector(inner_width, arrow_col, arrow_style)); + } + } + } + + let paragraph = Paragraph::new(lines) + .block(block) + .scroll((app.job_dot_scroll, 0)); + + f.render_widget(paragraph, area); +} + +fn truncate_to_width(s: &str, max: usize) -> String { + let count = s.chars().count(); + if count <= max { + s.to_string() + } else if max > 1 { + let truncated: String = s.chars().take(max - 1).collect(); + format!("{}…", truncated) + } else { + s.chars().take(max).collect() + } +} + +/// Top border of a node box: ` ┌─…─┐ ` or ` ┌─…─┴─…─┐ ` when connected from above. +fn node_top_border( + inner_w: usize, + junction_col: usize, + connect_top: bool, + style: Style, +) -> Line<'static> { + let border = if connect_top && inner_w > 0 { + let j = junction_col.min(inner_w.saturating_sub(1)); + format!(" ┌{}┴{}┐ ", "─".repeat(j), "─".repeat(inner_w - j - 1)) + } else { + format!(" ┌{}┐ ", "─".repeat(inner_w)) + }; + Line::from(Span::styled(border, style)) +} + +/// Bottom border of a node box: ` └─…─┘ ` or ` └─…─┬─…─┘ ` when connected below. +fn node_bottom_border( + inner_w: usize, + junction_col: usize, + connect_bottom: bool, + style: Style, +) -> Line<'static> { + let border = if connect_bottom && inner_w > 0 { + let j = junction_col.min(inner_w.saturating_sub(1)); + format!(" └{}┬{}┘ ", "─".repeat(j), "─".repeat(inner_w - j - 1)) + } else { + format!(" └{}┘ ", "─".repeat(inner_w)) + }; + Line::from(Span::styled(border, style)) +} + +/// A content row inside a node box: ` │ │ `. +fn node_text_row( + inner_w: usize, + text: &str, + text_style: Style, + border_style: Style, +) -> Line<'static> { + // Available chars for text = inner_w - 1 (leading space already included). + let available = inner_w.saturating_sub(1); + let truncated = truncate_to_width(text, available); + let content = format!(" {: Line<'static> { + let before = arrow_col.min(total_width); + let after = total_width.saturating_sub(before + 1); + Line::from(vec![ + Span::raw(" ".repeat(before)), + Span::styled("↓".to_string(), style), + Span::raw(" ".repeat(after)), + ]) +} + +#[cfg(test)] +mod tests { + use super::*; + + // --- truncate_to_width --- + + #[test] + fn truncate_shorter_than_max_unchanged() { + assert_eq!(truncate_to_width("hello", 10), "hello"); + } + + #[test] + fn truncate_exact_length_unchanged() { + assert_eq!(truncate_to_width("hello", 5), "hello"); + } + + #[test] + fn truncate_longer_adds_ellipsis() { + assert_eq!(truncate_to_width("hello world", 6), "hello…"); + } + + #[test] + fn truncate_max_zero_is_empty() { + assert_eq!(truncate_to_width("abc", 0), ""); + } + + #[test] + fn truncate_max_one_is_first_char_no_ellipsis() { + assert_eq!(truncate_to_width("abc", 1), "a"); + } + + #[test] + fn truncate_max_two_is_one_char_plus_ellipsis() { + assert_eq!(truncate_to_width("abc", 2), "a…"); + } + + // --- node_top_border --- + + #[test] + fn top_border_no_connect_has_only_dashes() { + let style = Style::default(); + let line = node_top_border(4, 2, false, style); + let content = line.spans[0].content.as_ref(); + assert_eq!(content, " ┌────┐ "); + assert!(!content.contains('┴')); + } + + #[test] + fn top_border_with_connect_has_junction() { + let style = Style::default(); + let line = node_top_border(4, 2, true, style); + let content = line.spans[0].content.as_ref(); + assert!(content.contains('┴'), "expected ┴ in {content:?}"); + assert_eq!(content, " ┌──┴─┐ "); + } + + #[test] + fn top_border_zero_width_no_panic() { + let style = Style::default(); + let line = node_top_border(0, 0, true, style); + // connect_top branch requires inner_w > 0, so falls back to plain border + assert_eq!(line.spans[0].content.as_ref(), " ┌┐ "); + } + + // --- node_bottom_border --- + + #[test] + fn bottom_border_no_connect_has_only_dashes() { + let style = Style::default(); + let line = node_bottom_border(4, 2, false, style); + let content = line.spans[0].content.as_ref(); + assert_eq!(content, " └────┘ "); + assert!(!content.contains('┬')); + } + + #[test] + fn bottom_border_with_connect_has_junction() { + let style = Style::default(); + let line = node_bottom_border(4, 2, true, style); + let content = line.spans[0].content.as_ref(); + assert!(content.contains('┬'), "expected ┬ in {content:?}"); + assert_eq!(content, " └──┬─┘ "); + } + + #[test] + fn bottom_border_zero_width_no_panic() { + let style = Style::default(); + let line = node_bottom_border(0, 0, true, style); + assert_eq!(line.spans[0].content.as_ref(), " └┘ "); + } + + // --- node_text_row --- + + #[test] + fn text_row_short_text_is_padded() { + let style = Style::default(); + let line = node_text_row(10, "Hi", style, style); + // available = inner_w - 1 = 9; content = " Hi " (1 leading + 8 trailing spaces) + let content = line.spans[1].content.as_ref(); + assert_eq!(content.len(), 10); // 1 space + 9 chars padded + assert!(content.contains("Hi")); + } + + #[test] + fn text_row_long_text_is_truncated() { + let style = Style::default(); + let line = node_text_row(6, "Hello World", style, style); + // available = 5; truncated = "Hell…"; content = " Hell…" + let content = line.spans[1].content.as_ref(); + assert!(content.contains('…'), "expected ellipsis in {content:?}"); + } + + // --- arrow_connector --- + + #[test] + fn arrow_connector_middle_span_is_down_arrow() { + let style = Style::default(); + let line = arrow_connector(10, 5, style); + assert_eq!(line.spans[1].content.as_ref(), "↓"); + } + + #[test] + fn arrow_connector_positions_arrow_correctly() { + let style = Style::default(); + let line = arrow_connector(10, 3, style); + // before = 3 spaces, arrow, after = 10 - 3 - 1 = 6 spaces + assert_eq!(line.spans[0].content.as_ref(), " "); + assert_eq!(line.spans[2].content.as_ref(), " "); + } +} diff --git a/ballista-cli/src/tui/ui/main/jobs/job_plan_popup.rs b/ballista-cli/src/tui/ui/main/jobs/job_plan_popup.rs new file mode 100644 index 000000000..7c31ac7f9 --- /dev/null +++ b/ballista-cli/src/tui/ui/main/jobs/job_plan_popup.rs @@ -0,0 +1,102 @@ +// Licensed to the Apache Software Foundation (ASF) under one +// or more contributor license agreements. See the NOTICE file +// distributed with this work for additional information +// regarding copyright ownership. The ASF licenses this file +// to you under the Apache License, Version 2.0 (the +// "License"); you may not use this file except in compliance +// with the License. You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, +// software distributed under the License is distributed on an +// "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY +// KIND, either express or implied. See the License for the +// specific language governing permissions and limitations +// under the License. + +use crate::tui::app::{App, PlanTab}; +use crate::tui::domain::jobs::JobDetails; +use ratatui::Frame; +use ratatui::layout::{Constraint, Layout, Rect}; +use ratatui::prelude::{Color, Style}; +use ratatui::text::{Line, Span}; +use ratatui::widgets::{Block, Borders, Clear, Paragraph}; + +pub(crate) fn render_job_plan_popup(f: &mut Frame, app: &App) { + let Some((details, tab)) = &app.job_plan_popup else { + return; + }; + + let area = crate::tui::ui::centered_rect(80, 70, f.area()); + f.render_widget(Clear, area); + + let areas = Layout::vertical([ + Constraint::Min(0), // Plans + Constraint::Length(3), // Navigation + ]) + .split(area); + + render_plans(f, areas[0], details, tab, app); + render_navigation(f, areas[1], tab); +} + +fn render_navigation(f: &mut Frame, area: Rect, tab: &PlanTab) { + let stage_label = if *tab == PlanTab::Stage { + Span::styled(" [s] Stage", Style::default().bold()) + } else { + Span::from(" [s] Stage") + }; + let physical_label = if *tab == PlanTab::Physical { + Span::styled("[p] Physical", Style::default().bold()) + } else { + Span::from("[p] Physical") + }; + let logical_label = if *tab == PlanTab::Logical { + Span::styled("[l] Logical", Style::default().bold()) + } else { + Span::from("[l] Logical") + }; + let navigation = Line::from(vec![ + stage_label, + Span::from(" | "), + physical_label, + Span::from(" | "), + logical_label, + Span::from(" | ↑↓ scroll | Esc close "), + ]); + let block = Block::default() + .borders(Borders::ALL) + .border_style(Style::default().fg(Color::Cyan)); + + let paragraph = Paragraph::new(navigation).block(block); + + f.render_widget(paragraph, area); +} + +fn render_plans( + f: &mut Frame, + area: Rect, + details: &JobDetails, + tab: &PlanTab, + app: &App, +) { + let plan = match tab { + PlanTab::Stage => details.stage_plan.as_deref().unwrap_or("N/A"), + PlanTab::Physical => details.physical_plan.as_deref().unwrap_or("N/A"), + PlanTab::Logical => details.logical_plan.as_deref().unwrap_or("N/A"), + }; + + let title = format!(" Job: {} ", details.job_id,); + + let block = Block::default() + .title(title) + .borders(Borders::ALL) + .border_style(Style::default().fg(Color::Cyan)); + + let paragraph = Paragraph::new(plan) + .block(block) + .scroll((app.job_plan_popup_scroll, 0)); + + f.render_widget(paragraph, area); +} diff --git a/ballista-cli/src/tui/ui/main/jobs/mod.rs b/ballista-cli/src/tui/ui/main/jobs/mod.rs new file mode 100644 index 000000000..25e843ca6 --- /dev/null +++ b/ballista-cli/src/tui/ui/main/jobs/mod.rs @@ -0,0 +1,308 @@ +// Licensed to the Apache Software Foundation (ASF) under one +// or more contributor license agreements. See the NOTICE file +// distributed with this work for additional information +// regarding copyright ownership. The ASF licenses this file +// to you under the Apache License, Version 2.0 (the +// "License"); you may not use this file except in compliance +// with the License. You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, +// software distributed under the License is distributed on an +// "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY +// KIND, either express or implied. See the License for the +// specific language governing permissions and limitations +// under the License. + +mod dot_parser; +pub mod job_dot_popup; +pub mod job_plan_popup; + +use crate::tui::{ + TuiResult, + app::App, + domain::{ + SortOrder, + jobs::{Job, SortColumn}, + }, + event::{Event, UiData}, + ui::search_box::render_search_box, + ui::vertical_scrollbar::render_scrollbar, +}; + +use ratatui::style::Color; +use ratatui::{ + Frame, + layout::{Constraint, Layout, Rect}, + style::Style, + text::Text, + widgets::{ + Block, Borders, Cell, Clear, HighlightSpacing, Paragraph, Row, Table, TableState, + }, +}; + +pub async fn load_jobs_data(app: &App) -> TuiResult<()> { + let jobs = match app.http_client.get_jobs().await { + Ok(jobs) => jobs, + Err(e) => { + tracing::error!("Failed to load the jobs: {e:?}"); + Vec::new() + } + }; + + app.send_event(Event::DataLoaded { + data: UiData::Jobs(jobs), + }) + .await +} + +pub async fn load_job_dot(app: &App, job_id: &str) -> TuiResult<()> { + match app.http_client.get_job_dot(job_id).await { + Ok(dot_content) => { + let graph = dot_parser::parse_dot(job_id, &dot_content); + app.send_event(Event::DataLoaded { + data: UiData::JobDot(graph), + }) + .await + } + Err(e) => { + tracing::error!("Failed to load job dot for {job_id}: {e:?}"); + Ok(()) + } + } +} + +pub async fn load_job_details(app: &App, job_id: &str) -> TuiResult<()> { + let details = match app.http_client.get_job_details(job_id).await { + Ok(d) => d, + Err(e) => { + tracing::error!("Failed to load job details for {job_id}: {e:?}"); + return Ok(()); + } + }; + app.send_event(Event::DataLoaded { + data: UiData::JobDetails(details), + }) + .await +} + +pub fn render_jobs(f: &mut Frame, area: Rect, app: &App) { + f.render_widget(Clear, area); + + let search_term = app.search_term.to_lowercase(); + let filtered_jobs: Vec<&Job> = if search_term.is_empty() { + app.jobs_data.jobs.iter().collect() + } else { + app.jobs_data + .jobs + .iter() + .filter(|j| { + j.job_id.to_lowercase().contains(&search_term) + || j.job_name.to_lowercase().contains(&search_term) + }) + .collect() + }; + + let rects = Layout::vertical([ + Constraint::Length(3), // Search box + Constraint::Min(5), // Table + Constraint::Length(4), // Scrollbar padding + ]) + .split(area); + + render_search_box(f, rects[0], app); + + let mut sorted_jobs = filtered_jobs; + app.jobs_data.sort_jobs(&mut sorted_jobs); + + if !sorted_jobs.is_empty() { + let mut scroll_state = app.jobs_data.scrollbar_state; + let mut table_state = app.jobs_data.table_state; + render_jobs_table( + f, + rects[1], + &sorted_jobs, + &mut table_state, + &app.jobs_data.sort_column, + &app.jobs_data.sort_order, + ); + render_scrollbar(f, rects[1], &mut scroll_state); + } else { + render_no_jobs(f, rects[1]); + } +} + +fn render_no_jobs(f: &mut Frame, area: Rect) { + let block = Block::default().borders(Borders::all()); + let paragraph = Paragraph::new("No registered jobs in the scheduler!") + .style(Style::default().bold()) + .centered() + .block(block); + f.render_widget(paragraph, area); +} + +fn column_suffix( + active_sort_column: &SortColumn, + sort_order: &SortOrder, + sort_column: &SortColumn, +) -> &'static str { + match (active_sort_column, sort_order) { + (sc, SortOrder::Ascending) if sc == sort_column => " ▲", + (sc, SortOrder::Descending) if sc == sort_column => " ▼", + _ => "", + } +} + +fn render_jobs_table( + frame: &mut Frame, + area: Rect, + jobs: &[&Job], + state: &mut TableState, + sort_column: &SortColumn, + sort_order: &SortOrder, +) { + let header_style = Style::default().fg(Color::Yellow).bg(Color::Black); + + let id_suffix = column_suffix(sort_column, sort_order, &SortColumn::Id); + let name_suffix = column_suffix(sort_column, sort_order, &SortColumn::Name); + let status_suffix = column_suffix(sort_column, sort_order, &SortColumn::Status); + let stages_suffix = + column_suffix(sort_column, sort_order, &SortColumn::StagesCompleted); + let percent_suffix = + column_suffix(sort_column, sort_order, &SortColumn::PercentComplete); + let start_time_suffix = + column_suffix(sort_column, sort_order, &SortColumn::StartTime); + + let header = [ + format!("Id{id_suffix}"), + format!("Name{name_suffix}"), + format!("Status{status_suffix}"), + format!("Stages Completes{stages_suffix}"), + format!("Percent Completed{percent_suffix}"), + format!("Start time{start_time_suffix}"), + ] + .into_iter() + .map(|item| Text::from(item).centered()) + .map(Cell::from) + .collect::() + .style(header_style) + .height(1); + + let rows = jobs.iter().enumerate().map(|(i, job)| { + let color = match i % 2 { + 0 => Color::DarkGray, + _ => Color::Black, + }; + + let id_cell = Cell::from(Text::from(job.job_id.clone()).centered()); + let name_cell = Cell::from(Text::from(job.job_name.clone()).centered()); + let status_cell = render_job_status_cell(job); + let stage_completion_cell = render_job_stage_completion_cell(job); + let percent_completion_cell = render_job_percent_completion_cell(job); + let start_time_cell = render_job_start_time_cell(job); + + let cells = vec![ + id_cell, + name_cell, + status_cell, + stage_completion_cell, + percent_completion_cell, + start_time_cell, + ]; + Row::new(cells).style(Style::default().bg(color)) + }); + + let t = Table::new( + rows, + [ + Constraint::Percentage(10), // Id + Constraint::Percentage(20), // Name + Constraint::Percentage(10), // Status + Constraint::Percentage(20), // Stages Completed + Constraint::Percentage(20), // Percent Completed + Constraint::Percentage(20), // Start time + ], + ) + .block(Block::default().borders(Borders::all())) + .header(header) + .row_highlight_style(Style::default().bg(Color::Indexed(29))) + .highlight_spacing(HighlightSpacing::Always); + frame.render_stateful_widget(t, area, state); +} + +fn render_job_start_time_cell(job: &Job) -> Cell<'_> { + let start_time = chrono::DateTime::from_timestamp_millis(job.start_time) + .map(|dt| dt.format("%Y-%m-%d %H:%M:%S UTC").to_string()) + .unwrap_or_else(|| "Invalid Date".to_string()); + Cell::from(Text::from(start_time).centered()) +} + +fn render_job_percent_completion_cell(job: &Job) -> Cell<'_> { + Cell::from(Text::from(format!("{}%", job.percent_complete)).centered()) +} + +fn render_job_stage_completion_cell(job: &Job) -> Cell<'_> { + let stage_completion = if job.num_stages == 0 { + format!("0.00% ({} / {})", job.completed_stages, job.num_stages) + } else { + let stages_completion = job.completed_stages as f32 / job.num_stages as f32; + format!( + "{:.2}% ({} / {})", + stages_completion * 100.0, + job.completed_stages, + job.num_stages + ) + }; + Cell::from(Text::from(stage_completion).centered()) +} + +fn render_job_status_cell(job: &Job) -> Cell<'_> { + let color = match job.status.as_str() { + "Running" => Color::LightBlue, + "Queued" => Color::Magenta, + "Failed" => Color::Red, + "Completed" => Color::Green, + _ => Color::Gray, + }; + let text = Text::from(job.status.clone()).style(Style::default().fg(color)); + Cell::from(text.centered()) +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::tui::domain::SortOrder; + + #[test] + fn column_suffix_active_ascending_returns_up_arrow() { + assert_eq!( + column_suffix(&SortColumn::Id, &SortOrder::Ascending, &SortColumn::Id), + " ▲" + ); + } + + #[test] + fn column_suffix_active_descending_returns_down_arrow() { + assert_eq!( + column_suffix(&SortColumn::Id, &SortOrder::Descending, &SortColumn::Id), + " ▼" + ); + } + + #[test] + fn column_suffix_different_column_returns_empty() { + assert_eq!( + column_suffix(&SortColumn::Name, &SortOrder::Ascending, &SortColumn::Id), + "" + ); + } + + #[test] + fn column_suffix_none_vs_id_returns_empty() { + assert_eq!( + column_suffix(&SortColumn::None, &SortOrder::Ascending, &SortColumn::Id), + "" + ); + } +} diff --git a/ballista-cli/src/tui/ui/main/metrics/mod.rs b/ballista-cli/src/tui/ui/main/metrics/mod.rs new file mode 100644 index 000000000..bf6bad9ce --- /dev/null +++ b/ballista-cli/src/tui/ui/main/metrics/mod.rs @@ -0,0 +1,196 @@ +// Licensed to the Apache Software Foundation (ASF) under one +// or more contributor license agreements. See the NOTICE file +// distributed with this work for additional information +// regarding copyright ownership. The ASF licenses this file +// to you under the Apache License, Version 2.0 (the +// "License"); you may not use this file except in compliance +// with the License. You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, +// software distributed under the License is distributed on an +// "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY +// KIND, either express or implied. See the License for the +// specific language governing permissions and limitations +// under the License. + +use crate::tui::{ + TuiResult, + app::App, + domain::{ + SortOrder, + metrics::{Metric, SortColumn}, + }, + event::{Event, UiData}, + ui::search_box::render_search_box, + ui::vertical_scrollbar::render_scrollbar, +}; +use prometheus_parse::HistogramCount; + +use ratatui::style::Color; +use ratatui::{ + Frame, + layout::{Constraint, Layout, Rect}, + style::Style, + text::Text, + widgets::{ + Block, Borders, Cell, Clear, HighlightSpacing, Paragraph, Row, Table, TableState, + }, +}; + +pub async fn load_metrics_data(app: &App) -> TuiResult<()> { + let metrics = match app.http_client.get_metrics().await { + Ok(metrics) => metrics, + Err(e) => { + tracing::error!("Failed to load the metrics: {e:?}"); + Vec::new() + } + }; + + app.send_event(Event::DataLoaded { + data: UiData::Metrics(metrics), + }) + .await +} + +pub fn render_metrics(f: &mut Frame, area: Rect, app: &App) { + f.render_widget(Clear, area); + + let search_term = app.search_term.to_lowercase(); + let filtered_metrics: Vec<&Metric> = if search_term.is_empty() { + app.metrics_data.metrics.iter().collect() + } else { + app.metrics_data + .metrics + .iter() + .filter(|m| m.sample.metric.to_lowercase().contains(&search_term)) + .collect() + }; + + let vertical = Layout::vertical([ + Constraint::Length(3), // Search box + Constraint::Min(5), // Table + Constraint::Length(4), // Scrollbar + ]); + let rects = vertical.split(area); + + render_search_box(f, rects[0], app); + + if !filtered_metrics.is_empty() { + let mut scroll_state = app.metrics_data.scrollbar_state; + let mut table_state = app.metrics_data.table_state; + render_metrics_table(f, rects[1], app, &filtered_metrics, &mut table_state); + render_scrollbar(f, rects[1], &mut scroll_state); + } else if are_metrics_enabled(app) { + render_no_metrics(f, rects[1], "No metrics."); + } else { + render_no_metrics( + f, + rects[1], + "The scheduler is built with 'prometheus_metric' feature disabled.", + ); + } +} + +fn are_metrics_enabled(app: &App) -> bool { + if let Some(scheduler_state) = app.executors_data.scheduler_state.as_ref() { + scheduler_state.prometheus_support + } else { + false + } +} + +fn render_no_metrics(f: &mut Frame, area: Rect, reason: &str) { + let block = Block::default() + .borders(Borders::all()) + .style(Style::default().fg(Color::Red)); + let paragraph = Paragraph::new(reason) + .style(Style::default().bold()) + .centered() + .block(block); + f.render_widget(paragraph, area); +} + +fn column_suffix( + active_sort_column: &SortColumn, + sort_order: &SortOrder, + sort_column: &SortColumn, +) -> &'static str { + match (active_sort_column, sort_order) { + (sc, SortOrder::Ascending) if sc == sort_column => " ▲", + (sc, SortOrder::Descending) if sc == sort_column => " ▼", + _ => "", + } +} + +fn render_metrics_table( + frame: &mut Frame, + area: Rect, + app: &App, + metrics: &[&Metric], + state: &mut TableState, +) { + let header_style = Style::default().fg(Color::Yellow).bg(Color::Black); + + let sort_column = &app.metrics_data.sort_column; + let sort_order = &app.metrics_data.sort_order; + + let name_suffix = column_suffix(sort_column, sort_order, &SortColumn::Name); + + let header = [ + format!("Name{name_suffix}"), + "Value".to_string(), + "Description".to_string(), + ] + .into_iter() + .map(Cell::from) + .collect::() + .style(header_style) + .height(1); + + let rows = metrics.iter().enumerate().map(|(i, metric)| { + use prometheus_parse::Value; + + let color = match i % 2 { + 0 => Color::DarkGray, + _ => Color::Black, + }; + + let name_cell = Cell::from(Text::from(metric.sample.metric.clone())); + let value_cell = match &metric.sample.value { + Value::Counter(v) => Cell::from(Text::from(format!("Counter: {v}"))), + Value::Gauge(v) => Cell::from(Text::from(format!("Gauge: {v}"))), + Value::Histogram(histograms) => histogram_cell(histograms), + Value::Summary(v) => Cell::from(Text::from(format!("Summary: {v:?}"))), + Value::Untyped(v) => Cell::from(Text::from(format!("Untyped: {v}"))), + }; + let description_cell = Cell::from(Text::from(metric.help.clone())); + + Row::new(vec![name_cell, value_cell, description_cell]) + .style(Style::default().bg(color)) + }); + + let t = Table::new( + rows, + [ + Constraint::Percentage(25), // Name + Constraint::Percentage(35), // Value + Constraint::Percentage(40), // Description + ], + ) + .block(Block::default().borders(Borders::all())) + .header(header) + .row_highlight_style(Style::default().bg(Color::Indexed(29))) + .highlight_spacing(HighlightSpacing::Always); + frame.render_stateful_widget(t, area, state); +} + +fn histogram_cell(histograms: &[HistogramCount]) -> Cell<'_> { + let mut data = Vec::new(); + for histogram in histograms { + let item = format!("<{} ={}", histogram.less_than, histogram.count); + data.push(item); + } + Cell::from(Text::from(format!("Histogram: {}", data.join(", ")))) +} diff --git a/ballista-cli/src/tui/ui/main/mod.rs b/ballista-cli/src/tui/ui/main/mod.rs new file mode 100644 index 000000000..c7bc4de9a --- /dev/null +++ b/ballista-cli/src/tui/ui/main/mod.rs @@ -0,0 +1,27 @@ +// Licensed to the Apache Software Foundation (ASF) under one +// or more contributor license agreements. See the NOTICE file +// distributed with this work for additional information +// regarding copyright ownership. The ASF licenses this file +// to you under the Apache License, Version 2.0 (the +// "License"); you may not use this file except in compliance +// with the License. You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, +// software distributed under the License is distributed on an +// "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY +// KIND, either express or implied. See the License for the +// specific language governing permissions and limitations +// under the License. + +mod executors; +mod jobs; +mod metrics; + +pub use executors::{load_executors_data, render_executors}; +pub use jobs::{ + job_dot_popup, job_plan_popup, load_job_details, load_job_dot, load_jobs_data, + render_jobs, +}; +pub use metrics::{load_metrics_data, render_metrics}; diff --git a/ballista-cli/src/tui/ui/mod.rs b/ballista-cli/src/tui/ui/mod.rs new file mode 100644 index 000000000..e5e2bb505 --- /dev/null +++ b/ballista-cli/src/tui/ui/mod.rs @@ -0,0 +1,98 @@ +// Licensed to the Apache Software Foundation (ASF) under one +// or more contributor license agreements. See the NOTICE file +// distributed with this work for additional information +// regarding copyright ownership. The ASF licenses this file +// to you under the Apache License, Version 2.0 (the +// "License"); you may not use this file except in compliance +// with the License. You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, +// software distributed under the License is distributed on an +// "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY +// KIND, either express or implied. See the License for the +// specific language governing permissions and limitations +// under the License. + +mod cancel_result_popup; +mod footer; +mod header; +mod help_overlay; +mod main; +mod scheduler_info_popup; +mod search_box; +mod vertical_scrollbar; + +use crate::tui::app::App; +use crate::tui::ui::header::render_header; +use footer::render_footer; +pub use main::{ + job_dot_popup, job_plan_popup, load_executors_data, load_job_details, load_job_dot, + load_jobs_data, load_metrics_data, +}; +use main::{render_executors, render_jobs, render_metrics}; +use ratatui::{ + Frame, + layout::{Constraint, Direction, Layout, Rect}, +}; + +pub(crate) fn render(f: &mut Frame, app: &App) { + let chunks = Layout::default() + .direction(Direction::Vertical) + .constraints([ + Constraint::Length(6), // Header + Constraint::Min(0), // Main view + Constraint::Length(2), // Footer + ]) + .split(f.area()); + + render_header(f, chunks[0], app); + render_main_view(f, app, chunks[1]); + render_footer(f, chunks[2], app); + + // Overlay help if active + if app.show_help { + help_overlay::render_help_overlay(f, app); + } else if app.show_scheduler_info { + scheduler_info_popup::render_scheduler_info(f, app); + } else if app.cancel_job_result.is_some() { + cancel_result_popup::render_cancel_result_popup(f, app); + } else if app.job_dot_popup.is_some() { + job_dot_popup::render_job_dot_popup(f, app); + } else if app.job_plan_popup.is_some() { + job_plan_popup::render_job_plan_popup(f, app); + } +} + +fn render_main_view(f: &mut Frame, app: &App, area: Rect) { + if app.is_executors_view() { + render_executors(f, area, app); + } else if app.is_jobs_view() { + render_jobs(f, area, app); + } else if app.is_metrics_view() { + render_metrics(f, area, app); + } +} + +// Helper functions + +pub(crate) fn centered_rect(percent_x: u16, percent_y: u16, r: Rect) -> Rect { + let popup_layout = Layout::default() + .direction(Direction::Vertical) + .constraints([ + Constraint::Percentage((100 - percent_y) / 2), + Constraint::Percentage(percent_y), + Constraint::Percentage((100 - percent_y) / 2), + ]) + .split(r); + + Layout::default() + .direction(Direction::Horizontal) + .constraints([ + Constraint::Percentage((100 - percent_x) / 2), + Constraint::Percentage(percent_x), + Constraint::Percentage((100 - percent_x) / 2), + ]) + .split(popup_layout[1])[1] +} diff --git a/ballista-cli/src/tui/ui/scheduler_info_popup.rs b/ballista-cli/src/tui/ui/scheduler_info_popup.rs new file mode 100644 index 000000000..0a03733cf --- /dev/null +++ b/ballista-cli/src/tui/ui/scheduler_info_popup.rs @@ -0,0 +1,96 @@ +// Licensed to the Apache Software Foundation (ASF) under one +// or more contributor license agreements. See the NOTICE file +// distributed with this work for additional information +// regarding copyright ownership. The ASF licenses this file +// to you under the Apache License, Version 2.0 (the +// "License"); you may not use this file except in compliance +// with the License. You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, +// software distributed under the License is distributed on an +// "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY +// KIND, either express or implied. See the License for the +// specific language governing permissions and limitations +// under the License. + +use crate::tui::app::App; +use ratatui::Frame; +use ratatui::prelude::{Color, Line, Span, Style}; +use ratatui::widgets::{Block, Borders, Clear, Paragraph, Wrap}; + +pub(crate) fn render_scheduler_info(f: &mut Frame, app: &App) { + if let Some(scheduler_state) = app.executors_data.scheduler_state.as_ref() { + let area = crate::tui::ui::centered_rect(25, 35, f.area()); + f.render_widget(Clear, area); + + let mut enabled_features = Vec::new(); + enabled_features.push("rest-api".to_string()); + let mut disabled_features = Vec::new(); + + let features = [ + (scheduler_state.prometheus_support, "prometheus-metrics"), + (scheduler_state.keda_support, "keda-scaler"), + (scheduler_state.spark_support, "spark-compat"), + (scheduler_state.substrait_support, "substrait"), + (scheduler_state.graphviz_support, "graphviz-support"), + ]; + + for (enabled, name) in features { + if enabled { + enabled_features.push(name.to_string()); + } else { + disabled_features.push(name.to_string()); + } + } + + let mut info_text = vec![ + Line::from(""), + Line::from(format!(" Policy: {}", scheduler_state.scheduling_policy)), + ]; + + if !enabled_features.is_empty() { + info_text.push(Line::from("")); + info_text.push(Line::from(vec![Span::styled( + " Enabled features", + Style::default().fg(Color::Green), + )])); + enabled_features.sort(); + for feature in enabled_features { + info_text.push(Line::from(vec![Span::styled( + format!(" - {feature}"), + Style::default().fg(Color::Green), + )])); + } + } + + if !disabled_features.is_empty() { + info_text.push(Line::from("")); + info_text.push(Line::from(vec![Span::styled( + " Disabled features", + Style::default().fg(Color::Red), + )])); + disabled_features.sort(); + for feature in disabled_features { + info_text.push(Line::from(vec![Span::styled( + format!(" - {feature}"), + Style::default().fg(Color::Red), + )])); + } + } + + info_text.push(Line::from("")); + + let block = Block::default() + .title(" Scheduler Information (Press any key to close) ") + .borders(Borders::ALL) + .border_style(Style::default().fg(Color::Cyan)); + + let para = Paragraph::new(info_text) + .block(block) + .wrap(Wrap { trim: false }); + + f.render_widget(para, area); + } +} diff --git a/ballista-cli/src/tui/ui/search_box.rs b/ballista-cli/src/tui/ui/search_box.rs new file mode 100644 index 000000000..2bbb47514 --- /dev/null +++ b/ballista-cli/src/tui/ui/search_box.rs @@ -0,0 +1,45 @@ +// Licensed to the Apache Software Foundation (ASF) under one +// or more contributor license agreements. See the NOTICE file +// distributed with this work for additional information +// regarding copyright ownership. The ASF licenses this file +// to you under the Apache License, Version 2.0 (the +// "License"); you may not use this file except in compliance +// with the License. You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, +// software distributed under the License is distributed on an +// "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY +// KIND, either express or implied. See the License for the +// specific language governing permissions and limitations +// under the License. + +use ratatui::Frame; +use ratatui::layout::Rect; +use ratatui::prelude::{Color, Line, Span, Style}; +use ratatui::widgets::{Block, Borders, Paragraph}; + +pub(crate) fn render_search_box(f: &mut Frame, area: Rect, app: &crate::tui::app::App) { + let (title, border_style) = if app.is_edit_mode() { + (" Search ", Style::default().fg(Color::Yellow)) + } else { + (" Search [/ to activate] ", Style::default().dim()) + }; + + let display_text = if app.is_edit_mode() { + let search_term = Span::from(app.search_term.clone()); + let cursor = Span::from("_").style(Style::default().bold().yellow()); + Line::from(vec![search_term, cursor]) + } else { + Line::from(Span::from(app.search_term.clone())) + }; + + let block = Block::default() + .borders(Borders::ALL) + .border_style(border_style) + .title(title); + + let paragraph = Paragraph::new(display_text).block(block); + f.render_widget(paragraph, area); +} diff --git a/ballista-cli/src/tui/ui/vertical_scrollbar.rs b/ballista-cli/src/tui/ui/vertical_scrollbar.rs new file mode 100644 index 000000000..f2bead8f0 --- /dev/null +++ b/ballista-cli/src/tui/ui/vertical_scrollbar.rs @@ -0,0 +1,38 @@ +// Licensed to the Apache Software Foundation (ASF) under one +// or more contributor license agreements. See the NOTICE file +// distributed with this work for additional information +// regarding copyright ownership. The ASF licenses this file +// to you under the Apache License, Version 2.0 (the +// "License"); you may not use this file except in compliance +// with the License. You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, +// software distributed under the License is distributed on an +// "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY +// KIND, either express or implied. See the License for the +// specific language governing permissions and limitations +// under the License. + +use ratatui::Frame; +use ratatui::layout::{Margin, Rect}; +use ratatui::widgets::{Scrollbar, ScrollbarOrientation, ScrollbarState}; + +pub(crate) fn render_scrollbar( + frame: &mut Frame, + area: Rect, + scroll_state: &mut ScrollbarState, +) { + frame.render_stateful_widget( + Scrollbar::default() + .orientation(ScrollbarOrientation::VerticalRight) + .begin_symbol(Some("▲")) + .end_symbol(Some("▼")), + area.inner(Margin { + vertical: 1, + horizontal: 1, + }), + scroll_state, + ); +}