diff --git a/Cargo.lock b/Cargo.lock index 8da4c06e..6277f83d 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -429,6 +429,21 @@ version = "1.7.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "8318a53db07bb3f8dca91a600466bdb3f2eaadeedfdbcf02e1accbad9271ba50" +[[package]] +name = "cassowary" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "df8670b8c7b9dae1793364eafadf7239c40d669904660c5960d74cfd80b46a53" + +[[package]] +name = "castaway" +version = "0.2.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0abae9be0aaf9ea96a3b1b8b1b55c602ca751eba1b1500220cea4ecbafe7c0d5" +dependencies = [ + "rustversion", +] + [[package]] name = "cc" version = "1.1.15" @@ -582,10 +597,24 @@ checksum = "b34115915337defe99b2aff5c2ce6771e5fbc4079f4b506301f5cf394c8452f7" dependencies = [ "ansi-str", "console", - "crossterm", + "crossterm 0.27.0", "strum", "strum_macros", - "unicode-width", + "unicode-width 0.1.13", +] + +[[package]] +name = "compact_str" +version = "0.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6050c3a16ddab2e412160b31f2c871015704239bca62f72f6e5f0be631d3f644" +dependencies = [ + "castaway", + "cfg-if", + "itoa", + "rustversion", + "ryu", + "static_assertions", ] [[package]] @@ -606,7 +635,7 @@ dependencies = [ "encode_unicode", "lazy_static", "libc", - "unicode-width", + "unicode-width 0.1.13", "windows-sys 0.52.0", ] @@ -711,6 +740,22 @@ dependencies = [ "winapi", ] +[[package]] +name = "crossterm" +version = "0.28.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "829d955a0bb380ef178a640b91779e3987da38c9aea133b20614cfed8cdea9c6" +dependencies = [ + "bitflags 2.6.0", + "crossterm_winapi", + "mio", + "parking_lot", + "rustix", + "signal-hook", + "signal-hook-mio", + "winapi", +] + [[package]] name = "crossterm_winapi" version = "0.9.1" @@ -996,6 +1041,12 @@ version = "1.0.7" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "3f9eec918d3f24069decb9af1554cad7c880e2da24a9afd88aca000531ab82c1" +[[package]] +name = "foldhash" +version = "0.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f81ec6369c545a7d40e4589b5597581fa1c441fe1cce96dd1de43159910a36a2" + [[package]] name = "form_urlencoded" version = "1.2.1" @@ -1100,6 +1151,15 @@ dependencies = [ "slab", ] +[[package]] +name = "fxhash" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c31b6d751ae2c7f11320402d34e41349dd1016f8d5d45e48c4312bc8625af50c" +dependencies = [ + "byteorder", +] + [[package]] name = "generic-array" version = "0.14.7" @@ -1186,6 +1246,17 @@ dependencies = [ "allocator-api2", ] +[[package]] +name = "hashbrown" +version = "0.15.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3a9bfc1af68b1726ea47d3d5109de126281def866b33970e10fbab11b5dafab3" +dependencies = [ + "allocator-api2", + "equivalent", + "foldhash", +] + [[package]] name = "headers" version = "0.4.0" @@ -1451,10 +1522,16 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "68b900aa2f7301e21c36462b170ee99994de34dff39a4a6a528e80e7376d07e5" dependencies = [ "equivalent", - "hashbrown", + "hashbrown 0.14.5", "serde", ] +[[package]] +name = "indoc" +version = "2.0.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b248f5224d1d606005e02c97f5aa4e88eeb230488bcc03bc9ca4d7991399f2b5" + [[package]] name = "inout" version = "0.1.3" @@ -1464,6 +1541,16 @@ dependencies = [ "generic-array", ] +[[package]] +name = "instability" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b23a0c8dfe501baac4adf6ebbfa6eddf8f0c07f56b058cc1288017e32397846c" +dependencies = [ + "quote", + "syn 2.0.77", +] + [[package]] name = "instant" version = "0.1.13" @@ -1674,7 +1761,7 @@ dependencies = [ "backoff", "derivative", "futures", - "hashbrown", + "hashbrown 0.14.5", "json-patch", "jsonptr", "k8s-openapi", @@ -1708,7 +1795,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "4979f22fdb869068da03c9f7528f8297c6fd2606bc3a4affe42e6a823fdb8da4" dependencies = [ "cfg-if", - "windows-targets 0.52.6", + "windows-targets 0.48.5", ] [[package]] @@ -1760,6 +1847,15 @@ version = "0.4.22" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a7a70ba024b9dc04c27ea2f0c0548feb474ec5c54bba33a7f72f873a39d07b24" +[[package]] +name = "lru" +version = "0.12.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "234cf4f4a04dc1f57e24b96cc0cd600cf2af460d4161ac5ecdd0af8e1f3b2a38" +dependencies = [ + "hashbrown 0.15.1", +] + [[package]] name = "matchers" version = "0.1.0" @@ -1829,6 +1925,7 @@ checksum = "80e04d1dcff3aae0704555fe5fee3bcfaf3d1fdf8a7e521d5b9d2b42acb52cec" dependencies = [ "hermit-abi", "libc", + "log", "wasi", "windows-sys 0.52.0", ] @@ -2052,6 +2149,12 @@ dependencies = [ "regex", ] +[[package]] +name = "paste" +version = "1.0.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "57c0d7b74b563b49d38dae00a0c37d4d6de9b432382b2892f0574ddcae73fd0a" + [[package]] name = "pem" version = "3.0.4" @@ -2353,6 +2456,27 @@ dependencies = [ "getrandom", ] +[[package]] +name = "ratatui" +version = "0.29.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "eabd94c2f37801c20583fc49dd5cd6b0ba68c716787c2dd6ed18571e1e63117b" +dependencies = [ + "bitflags 2.6.0", + "cassowary", + "compact_str", + "crossterm 0.28.1", + "indoc", + "instability", + "itertools", + "lru", + "paste", + "strum", + "unicode-segmentation", + "unicode-truncate", + "unicode-width 0.2.0", +] + [[package]] name = "redox_syscall" version = "0.4.1" @@ -2889,6 +3013,27 @@ version = "1.3.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0fda2ff0d084019ba4d7c6f371c95d8fd75ce3524c3cb8fb653a3023f6323e64" +[[package]] +name = "signal-hook" +version = "0.3.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8621587d4798caf8eb44879d42e56b9a93ea5dcd315a6487c357130095b62801" +dependencies = [ + "libc", + "signal-hook-registry", +] + +[[package]] +name = "signal-hook-mio" +version = "0.2.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "34db1a06d485c9142248b7a054f034b349b212551f3dfd19c94d45a754a217cd" +dependencies = [ + "libc", + "mio", + "signal-hook", +] + [[package]] name = "signal-hook-registry" version = "1.4.2" @@ -3108,7 +3253,9 @@ dependencies = [ "indexmap", "lazy_static", "libc", + "log", "rand", + "ratatui", "reqwest", "semver", "serde", @@ -3117,13 +3264,21 @@ dependencies = [ "snafu 0.8.4", "stackable-cockpit", "stackable-operator", + "strum", "tera", "termion", "tokio", "tracing", "tracing-subscriber", + "tui-logger", ] +[[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" @@ -3579,6 +3734,21 @@ version = "0.2.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e421abadd41a4225275504ea4d6566923418b7f05506fbc9c0fe86ba7396114b" +[[package]] +name = "tui-logger" +version = "0.13.3" +source = "git+https://github.com/gin66/tui-logger?rev=f2bb9848667ba4ac36b2571e05e2a3fd0413de0d#f2bb9848667ba4ac36b2571e05e2a3fd0413de0d" +dependencies = [ + "chrono", + "fxhash", + "lazy_static", + "log", + "parking_lot", + "ratatui", + "tracing", + "tracing-subscriber", +] + [[package]] name = "tungstenite" version = "0.23.0" @@ -3689,12 +3859,35 @@ dependencies = [ "tinyvec", ] +[[package]] +name = "unicode-segmentation" +version = "1.12.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f6ccf251212114b54433ec949fd6a7841275f9ada20dddd2f29e9ceea4501493" + +[[package]] +name = "unicode-truncate" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b3644627a5af5fa321c95b9b235a72fd24cd29c648c2c379431e6628655627bf" +dependencies = [ + "itertools", + "unicode-segmentation", + "unicode-width 0.1.13", +] + [[package]] name = "unicode-width" version = "0.1.13" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0336d538f7abc86d282a4189614dfaa90810dfc2c6f6427eaf88e16311dd225d" +[[package]] +name = "unicode-width" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1fc81956842c57dac11422a97c3b8195a1ff727f06e85c84ed2e8aa277c9a0fd" + [[package]] name = "unicode-xid" version = "0.2.5" diff --git a/Cargo.toml b/Cargo.toml index 8fb068cb..ef098337 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -31,11 +31,13 @@ indexmap = { version = "2.2", features = ["serde"] } k8s-openapi = { version = "0.22", default-features = false, features = ["v1_30"] } kube = { version = "0.93", default-features = false, features = ["client", "rustls-tls", "ws"] } lazy_static = "1.5" +log = "0.4" libc = "0.2" once_cell = "1.19" phf = "0.11" phf_codegen = "0.11" rand = "0.8" +ratatui = "0.29" regex = "1.10" reqwest = { version = "0.12", default-features = false, features = ["rustls-tls"] } rstest = "0.22" @@ -45,6 +47,7 @@ serde_json = "1.0" serde_yaml = "0.9" sha2 = "0.10" snafu = { version = "0.8", features = ["futures"] } +strum = "0.26" stackable-operator = { git = "https://github.com/stackabletech/operator-rs.git", tag = "stackable-operator-0.74.0" } tera = "1.20" termion = "4.0" @@ -52,6 +55,7 @@ tokio = { version = "1.38", features = ["rt-multi-thread", "macros", "fs", "proc tower-http = { version = "0.5", features = ["validate-request"] } tracing = "0.1" tracing-subscriber = "0.3" +tui-logger = { version = "0.13", features = ["tracing-support"] } url = "2.5" utoipa = { version = "4.2", features = ["indexmap"] } utoipa-swagger-ui = { version = "7.1", features = ["axum"] } @@ -61,6 +65,10 @@ which = "6.0" # [patch."https://github.com/stackabletech/operator-rs.git"] # stackable-operator = { git = "https://github.com/stackabletech//operator-rs.git", branch = "main" } +[patch.crates-io] +# Needed for ratatui 0.29 support +tui-logger = {git = "https://github.com/gin66/tui-logger", rev ="f2bb9848667ba4ac36b2571e05e2a3fd0413de0d" } + [profile.release.package.stackablectl] # opt-level = "z" # We don't use that as the binary saving is not *that* big (think of 1MB) and it's not worth it risiking performance for this strip = true diff --git a/rust/stackable-cockpit/src/constants.rs b/rust/stackable-cockpit/src/constants.rs index f7ad785d..b95ce1cd 100644 --- a/rust/stackable-cockpit/src/constants.rs +++ b/rust/stackable-cockpit/src/constants.rs @@ -24,17 +24,18 @@ pub const HELM_REPO_INDEX_FILE: &str = "index.yaml"; pub const HELM_DEFAULT_CHART_VERSION: &str = ">0.0.0-0"; +// Sorted from top level to low level, internal service pub const PRODUCT_NAMES: &[&str] = &[ + "spark-history-server", + "superset", + "nifi", "airflow", + "trino", "druid", + "kafka", "hbase", "hdfs", "hive", - "kafka", - "nifi", - "opa", - "spark-history-server", - "superset", - "trino", "zookeeper", + "opa", ]; diff --git a/rust/stackable-cockpit/src/platform/stacklet/grafana.rs b/rust/stackable-cockpit/src/platform/stacklet/grafana.rs index 941035d8..d971ae65 100644 --- a/rust/stackable-cockpit/src/platform/stacklet/grafana.rs +++ b/rust/stackable-cockpit/src/platform/stacklet/grafana.rs @@ -25,11 +25,12 @@ pub(super) async fn list(client: &Client, namespace: Option<&str>) -> Result) -> Result, /// Multiple cluster conditions. - pub conditions: Vec, + pub conditions: Vec, + + /// Multiple cluster conditions meant for displaying in CLI. + pub display_conditions: Vec, } #[derive(Debug, Snafu)] @@ -125,7 +128,7 @@ async fn list_stackable_stacklets( { Some(obj) => obj, None => { - info!( + debug!( "Failed to list stacklets because the gvk {product_gvk:?} can not be resolved" ); continue; @@ -155,9 +158,10 @@ async fn list_stackable_stacklets( stacklets.push(Stacklet { namespace: Some(object_namespace), product: product_name.to_string(), - conditions: conditions.plain(), name: object_name, endpoints, + conditions: conditions.clone(), + display_conditions: conditions.plain(), }); } } diff --git a/rust/stackable-cockpit/src/platform/stacklet/opensearch.rs b/rust/stackable-cockpit/src/platform/stacklet/opensearch.rs index 2e6f4b86..821b6000 100644 --- a/rust/stackable-cockpit/src/platform/stacklet/opensearch.rs +++ b/rust/stackable-cockpit/src/platform/stacklet/opensearch.rs @@ -28,11 +28,12 @@ pub(super) async fn list(client: &Client, namespace: Option<&str>) -> Result) -> Result Some(resolved), None => { - info!(?gvk, "discovery did not include gvk"); + debug!(?gvk, "discovery did not include gvk"); // We take the lock early here to avoid running multiple discoveries in parallel (as they are expensive) let mut old_discovery = self.discovery.write().await; @@ -424,7 +425,7 @@ impl Client { /// Creates a new [`Discovery`] object and immediatly runs a discovery. #[tracing::instrument(skip_all)] async fn run_discovery(client: kube::client::Client) -> Result { - info!("running discovery"); + debug!("running discovery"); Discovery::new(client) .run() .await diff --git a/rust/stackable-cockpit/src/utils/k8s/conditions.rs b/rust/stackable-cockpit/src/utils/k8s/conditions.rs index e0a36099..9dd827a5 100644 --- a/rust/stackable-cockpit/src/utils/k8s/conditions.rs +++ b/rust/stackable-cockpit/src/utils/k8s/conditions.rs @@ -3,7 +3,9 @@ use k8s_openapi::{ apimachinery::pkg::apis::meta::v1::Condition, }; use serde::Serialize; -use stackable_operator::status::condition::ClusterCondition; +use stackable_operator::status::condition::{ + ClusterCondition, ClusterConditionStatus, ClusterConditionType, +}; #[cfg(feature = "openapi")] use utoipa::ToSchema; @@ -104,3 +106,22 @@ impl ConditionExt for ClusterCondition { Some(self.is_good()) } } + +pub trait StackletConditionsExt { + fn is_stacklet_healthy(&self) -> bool; + fn is_reconciliation_paused(&self) -> bool; +} + +impl StackletConditionsExt for Vec { + fn is_stacklet_healthy(&self) -> bool { + self.iter().all(|condition| condition.is_good()) + } + + fn is_reconciliation_paused(&self) -> bool { + self.iter() + .find(|condition| condition.type_ == ClusterConditionType::ReconciliationPaused) + .map(|condition| condition.status == ClusterConditionStatus::True) + // Reconciliation is definitely not paused + .unwrap_or_default() + } +} diff --git a/rust/stackablectl/Cargo.toml b/rust/stackablectl/Cargo.toml index f6084a00..3af78854 100644 --- a/rust/stackablectl/Cargo.toml +++ b/rust/stackablectl/Cargo.toml @@ -20,17 +20,21 @@ directories.workspace = true dotenvy.workspace = true indexmap.workspace = true lazy_static.workspace = true +log.workspace = true rand.workspace = true +ratatui.workspace = true reqwest.workspace = true semver.workspace = true serde_json.workspace = true serde_yaml.workspace = true serde.workspace = true snafu.workspace = true +strum.workspace = true tera.workspace = true tokio.workspace = true tracing-subscriber.workspace = true tracing.workspace = true +tui-logger.workspace = true futures.workspace = true termion.workspace = true libc.workspace = true diff --git a/rust/stackablectl/src/cli/mod.rs b/rust/stackablectl/src/cli/mod.rs index 268e1a1c..85bd67c7 100644 --- a/rust/stackablectl/src/cli/mod.rs +++ b/rust/stackablectl/src/cli/mod.rs @@ -17,7 +17,11 @@ use stackable_cockpit::{ use crate::{ args::{CommonFileArgs, CommonRepoArgs}, - cmds::{cache, completions, debug, demo, operator, release, stack, stacklet}, + cmds::{ + cache, completions, + control_center::{self, ControlCenterArgs}, + debug, demo, operator, release, stack, stacklet, + }, constants::{ ENV_KEY_DEMO_FILES, ENV_KEY_RELEASE_FILES, ENV_KEY_STACK_FILES, REMOTE_DEMO_FILE, REMOTE_RELEASE_FILE, REMOTE_STACK_FILE, USER_DIR_APPLICATION_NAME, @@ -52,6 +56,9 @@ pub enum Error { #[snafu(display("debug command error"))] Debug { source: debug::CmdError }, + #[snafu(display("control center command error"))] + ControlCenter { source: control_center::CmdError }, + #[snafu(display("helm error"))] Helm { source: helm::Error }, } @@ -195,6 +202,12 @@ impl Cli { Commands::Completions(args) => args.run().context(CompletionsSnafu), Commands::Cache(args) => args.run(self, cache).await.context(CacheSnafu), Commands::ExperimentalDebug(args) => args.run(self).await.context(DebugSnafu), + Commands::ExperimentalControlCenter(args) => args + .run(self) + .await + .context(ControlCenterSnafu) + // It seems like we also need to return *some* string + .map(|()| String::new()), } } @@ -250,6 +263,9 @@ CRDs." This container will have access to the same data volumes as the primary container.")] ExperimentalDebug(debug::DebugArgs), + + /// EXPERIMENTAL: Control center with interactive UI + ExperimentalControlCenter(ControlCenterArgs), } #[derive(Clone, Debug, Default, ValueEnum)] diff --git a/rust/stackablectl/src/cmds/control_center/input_handling.rs b/rust/stackablectl/src/cmds/control_center/input_handling.rs new file mode 100644 index 00000000..957aa722 --- /dev/null +++ b/rust/stackablectl/src/cmds/control_center/input_handling.rs @@ -0,0 +1,100 @@ +use std::time::Duration; + +use ratatui::crossterm::event::{self, Event, KeyCode, KeyModifiers}; +use snafu::{ResultExt, Snafu}; +use tokio::sync::mpsc::Sender; +use tui_logger::TuiWidgetEvent; + +use super::{ + rendering::tabs::SelectedTab, + state::{Message, Model}, +}; + +#[derive(Debug, Snafu)] +pub enum Error { + #[snafu(display("failed to read event"))] + ReadEvent { source: std::io::Error }, +} + +pub async fn handle_event(model: &Model, message_tx: Sender) -> Result<(), Error> { + if event::poll(Duration::from_millis(250)).context(ReadEventSnafu)? { + if let Event::Key(key) = event::read().context(ReadEventSnafu)? { + if key.kind == event::KeyEventKind::Press { + for message in handle_key(model, key) { + message_tx.send(message).await.unwrap(); + } + } + } + } + + Ok(()) +} + +pub fn handle_key(model: &Model, key: event::KeyEvent) -> Vec { + let shift_pressed = key.modifiers.contains(KeyModifiers::SHIFT); + let ctrl_pressed = key.modifiers.contains(KeyModifiers::CONTROL); + + match key.code { + KeyCode::Char('c') if ctrl_pressed => { return vec![Message::Quit]; } + KeyCode::Char('q') /*| KeyCode::Esc*/ => { return vec![Message::Quit]; } + KeyCode::Tab if shift_pressed => { return vec![Message::PreviousTab]; } + KeyCode::Tab => { return vec![Message::NextTab]; } + _ => {}, + }; + + match model.selected_tab { + SelectedTab::Stacklets => handle_key_in_stacklets(key), + SelectedTab::Logs => handle_key_in_logs(key), + } +} + +pub fn handle_key_in_stacklets(key: event::KeyEvent) -> Vec { + match key.code { + KeyCode::Char('k') | KeyCode::Up => vec![Message::StackletListUp { steps: 1 }], + KeyCode::Char('j') | KeyCode::Down => vec![Message::StackletListDown { steps: 1 }], + KeyCode::Home => vec![Message::StackletListStart], + KeyCode::End => vec![Message::StackletListEnd], + KeyCode::PageUp => { + // FIXME: Determine actual table height + let table_height = 10; + vec![Message::StackletListUp { + steps: table_height, + }] + } + KeyCode::PageDown => { + // FIXME: Determine actual table height + let table_height = 10; + vec![Message::StackletListDown { + steps: table_height, + }] + } + _ => vec![], + } +} + +pub fn handle_key_in_logs(key: event::KeyEvent) -> Vec { + match key.code { + KeyCode::Char('k') | KeyCode::Up => { + vec![Message::LoggingWidgetMessage(TuiWidgetEvent::UpKey)] + } + KeyCode::Char('j') | KeyCode::Down => { + vec![Message::LoggingWidgetMessage(TuiWidgetEvent::DownKey)] + } + KeyCode::Char('h') | KeyCode::Left => { + vec![Message::LoggingWidgetMessage(TuiWidgetEvent::LeftKey)] + } + KeyCode::Char('l') | KeyCode::Right => { + vec![Message::LoggingWidgetMessage(TuiWidgetEvent::RightKey)] + } + KeyCode::PageUp => { + vec![Message::LoggingWidgetMessage(TuiWidgetEvent::PrevPageKey)] + } + KeyCode::PageDown => { + vec![Message::LoggingWidgetMessage(TuiWidgetEvent::NextPageKey)] + } + KeyCode::Esc | KeyCode::End => { + vec![Message::LoggingWidgetMessage(TuiWidgetEvent::EscapeKey)] + } + _ => vec![], + } +} diff --git a/rust/stackablectl/src/cmds/control_center/logging.rs b/rust/stackablectl/src/cmds/control_center/logging.rs new file mode 100644 index 00000000..6a74b32d --- /dev/null +++ b/rust/stackablectl/src/cmds/control_center/logging.rs @@ -0,0 +1,34 @@ +use log::LevelFilter; +use ratatui::prelude::*; +use tracing_subscriber::{layer::SubscriberExt, util::SubscriberInitExt}; +use tui_logger::{TuiLoggerLevelOutput, TuiLoggerSmartWidget}; + +use super::state::Model; + +pub fn init_logging() { + tracing_subscriber::registry() + .with(tui_logger::tracing_subscriber_layer()) + .init(); + + // FIXME: Error handling + tui_logger::init_logger(LevelFilter::Debug).unwrap(); + tui_logger::set_default_level(LevelFilter::Debug); + // tui_logger::set_level_for_target("tower::buffer::worker", LevelFilter::Warn); +} + +pub fn render_logs(model: &Model, area: Rect, buf: &mut Buffer) { + TuiLoggerSmartWidget::default() + .style_error(Style::default().fg(Color::Red)) + .style_debug(Style::default().fg(Color::Green)) + .style_warn(Style::default().fg(Color::Yellow)) + .style_trace(Style::default().fg(Color::Magenta)) + .style_info(Style::default().fg(Color::Cyan)) + .output_separator(':') + .output_timestamp(Some("%H:%M:%S".to_string())) + .output_level(Some(TuiLoggerLevelOutput::Abbreviated)) + .output_target(true) + .output_file(false) + .output_line(false) + .state(&model.logging_widget_state) + .render(area, buf); +} diff --git a/rust/stackablectl/src/cmds/control_center/mod.rs b/rust/stackablectl/src/cmds/control_center/mod.rs new file mode 100644 index 00000000..57a69e1e --- /dev/null +++ b/rust/stackablectl/src/cmds/control_center/mod.rs @@ -0,0 +1,67 @@ +use clap::Args; +use snafu::{ResultExt, Snafu}; +use tokio::sync::mpsc; + +use crate::cli::Cli; + +use self::{ + input_handling::handle_event, + logging::init_logging, + rendering::render, + state::{update, update_stacklets_loop, Message, Model, RunningState}, +}; + +mod input_handling; +mod logging; +mod rendering; +mod state; + +#[derive(Debug, Snafu)] +pub enum CmdError { + #[snafu(display("failed to draw to terminal"))] + DrawToTerminal { source: std::io::Error }, + + #[snafu(display("failed to handle user input"))] + InputHandling { source: input_handling::Error }, +} + +#[derive(Debug, Args)] +pub struct ControlCenterArgs {} + +impl ControlCenterArgs { + pub async fn run(&self, _cli: &Cli) -> Result<(), CmdError> { + init_logging(); + + let result = self.inner_run().await; + ratatui::restore(); + result + } + + pub async fn inner_run(&self) -> Result<(), CmdError> { + let mut terminal = ratatui::init(); + + let mut model = Model::default(); + let (message_tx, mut message_rx) = mpsc::channel::(10); + + let message_tx_for_loop = message_tx.clone(); + tokio::spawn(async { update_stacklets_loop(message_tx_for_loop).await }); + + while model.running_state != RunningState::Done { + // Render the current view + terminal + .draw(|frame| render(&mut model, frame)) + .context(DrawToTerminalSnafu)?; + + // Handle events and map to a Message + handle_event(&model, message_tx.clone()) + .await + .context(InputHandlingSnafu)?; + + while let Ok(message) = message_rx.try_recv() { + update(&mut model, message); + } + } + + Ok(()) + } +} diff --git a/rust/stackablectl/src/cmds/control_center/rendering/mod.rs b/rust/stackablectl/src/cmds/control_center/rendering/mod.rs new file mode 100644 index 00000000..9eeb2141 --- /dev/null +++ b/rust/stackablectl/src/cmds/control_center/rendering/mod.rs @@ -0,0 +1,19 @@ +use ratatui::prelude::*; +use tracing::instrument; + +use super::state::Model; + +mod stacklets; +pub mod tabs; + +// const INFO_TEXT: [&str; 1] = ["(Esc or q) quit | (↑) move up | (↓) move down"]; + +#[instrument(skip(model))] +pub fn render(model: &mut Model, frame: &mut Frame) { + let [tabs_header_area, tabs_body_area] = + Layout::vertical([Constraint::Length(1), Constraint::Fill(100)]).areas(frame.area()); + + let selected_tab = model.selected_tab; + selected_tab.render_header(model, tabs_header_area, frame.buffer_mut()); + selected_tab.render_body(model, tabs_body_area, frame.buffer_mut()); +} diff --git a/rust/stackablectl/src/cmds/control_center/rendering/stacklets.rs b/rust/stackablectl/src/cmds/control_center/rendering/stacklets.rs new file mode 100644 index 00000000..f4210771 --- /dev/null +++ b/rust/stackablectl/src/cmds/control_center/rendering/stacklets.rs @@ -0,0 +1,146 @@ +use std::cmp::max; + +use ratatui::{ + prelude::*, + widgets::{Block, Cell, Paragraph, Row, Table, Wrap}, +}; +use stackable_cockpit::{platform::stacklet::Stacklet, utils::k8s::StackletConditionsExt}; + +use crate::cmds::control_center::state::Model; + +pub fn render_stacklets(model: &mut Model, area: Rect, buf: &mut Buffer) { + let [table_area, details_area] = Layout::vertical([Constraint::Fill(70), Constraint::Fill(30)]) + .spacing(1) + .areas(area); + + render_table(model, table_area, buf); + render_details(model, details_area, buf); +} + +fn render_table(model: &mut Model, area: Rect, buf: &mut Buffer) { + let mut rows = Vec::with_capacity(model.stacklets.len()); + + let healthy_style = Style::default().fg(Color::Indexed(153)); + let unhealthy_style = Style::default().fg(Color::LightRed); + let reconciliation_paused_style = Style::default().fg(Color::Gray); + + if model.stacklets_table_state.selected().is_none() { + model.stacklets_table_state.select(Some(0)); + } + + let selected = model + .stacklets_table_state + .selected() + .expect("There must always be a row selected in the stacklets table"); + + for (index, stacklet) in model.stacklets.iter().enumerate() { + let stacklet_healthy = stacklet.conditions.is_stacklet_healthy(); + let reconciliation_paused = stacklet.conditions.is_reconciliation_paused(); + + let mut style = match (reconciliation_paused, stacklet_healthy) { + (true, _) => reconciliation_paused_style, + (false, true) => healthy_style, + (false, false) => unhealthy_style, + }; + + if index == selected { + style = style.reversed(); + } + + let namespace_cell = Cell::new(stacklet.namespace.clone().unwrap_or_default()); + let product_cell = Cell::new(stacklet.product.as_str()); + let name_cell = Cell::new(stacklet.name.as_str()); + let endpoint_lines = stacklet + .endpoints + .iter() + .map(|(name, endpoint)| Line::from(format!("{name}: {endpoint}"))) + .collect::>(); + let num_endpoint_lines = max(endpoint_lines.len(), 1); + let endpoints_cell = Cell::new(endpoint_lines); + + rows.push( + Row::new(vec![ + namespace_cell, + product_cell, + name_cell, + endpoints_cell, + ]) + .style(style) + .height( + u16::try_from(num_endpoint_lines) + .expect("The height of endpoint lines needs to fit in a u16") + .saturating_add(1), + ), + ); + } + + let widths = calculate_row_widths(&model.stacklets); + + let table = Table::new(rows, widths) + // ...and they can be separated by a fixed spacing. + .column_spacing(2) + // You can set the style of the entire Table. + .style(Style::new()) + // It has an optional header, which is simply a Row always visible at the top. + .header( + Row::new(vec!["Namespace", "Product", "Name", "Endpoints"]) + .style(Style::new().bold()) + // To add space between the header and the rest of the rows, specify the margin + .bottom_margin(1), + ) + // We don't use the `row_highlight_style` function, as we need a more flexible way. + // .row_highlight_style(healthy_style.reversed()) + .block(Block::bordered().title("Stacklets")); + + StatefulWidget::render(table, area, buf, &mut model.stacklets_table_state); +} + +fn render_details(model: &mut Model, area: Rect, buf: &mut Buffer) { + let maybe_selected_stacklet = model + .stacklets_table_state + .selected() + .and_then(|selected| model.stacklets.get(selected)); + + // It might be the case stacklets have not been loaded yet + let paragraph = if let Some(stacklet) = maybe_selected_stacklet { + Paragraph::new(format!("{:?}", stacklet)) + } else { + Paragraph::new("") + }; + paragraph + .wrap(Wrap { trim: false }) + .block(Block::bordered().title("Stacklet details")) + .render(area, buf); +} + +fn calculate_row_widths(stacklets: &[Stacklet]) -> [Constraint; 4] { + let namespaces = stacklets.iter().filter_map(|s| s.namespace.as_ref()); + let product_names = stacklets.iter().map(|s| &s.product); + let names = stacklets.iter().map(|s| &s.name); + + [ + Constraint::Length(max( + longest_string(namespaces) + .and_then(|l| l.try_into().ok()) + .unwrap_or_default(), + "Namespace".len() as u16, + )), + Constraint::Length(max( + longest_string(product_names) + .and_then(|l| l.try_into().ok()) + .unwrap_or_default(), + "Product".len() as u16, + )), + Constraint::Length(max( + longest_string(names) + .and_then(|l| l.try_into().ok()) + .unwrap_or_default(), + "Name".len() as u16, + )), + Constraint::Fill(100), + ] +} + +fn longest_string<'a>(strings: impl IntoIterator) -> Option { + strings.into_iter().map(|s| s.len()).max() +} diff --git a/rust/stackablectl/src/cmds/control_center/rendering/tabs.rs b/rust/stackablectl/src/cmds/control_center/rendering/tabs.rs new file mode 100644 index 00000000..c35559c9 --- /dev/null +++ b/rust/stackablectl/src/cmds/control_center/rendering/tabs.rs @@ -0,0 +1,88 @@ +use ratatui::{ + prelude::*, + style::palette::tailwind, + widgets::{Block, Padding, Tabs}, +}; +use strum::{Display, EnumCount, EnumIter, FromRepr, IntoEnumIterator}; + +use crate::cmds::control_center::{logging::render_logs, state::Model}; + +use super::stacklets::render_stacklets; + +#[derive(Default, Clone, Copy, Display, FromRepr, EnumIter, EnumCount)] +pub enum SelectedTab { + #[default] + #[strum(to_string = "Stacklets")] + Stacklets, + #[strum(to_string = "Logs")] + Logs, +} + +impl SelectedTab { + pub fn render_header(&self, model: &Model, area: Rect, buf: &mut Buffer) { + let titles = SelectedTab::iter().map(SelectedTab::title); + let highlight_style = (Color::default(), model.selected_tab.palette().c700); + let selected_tab_index = model.selected_tab as usize; + Tabs::new(titles) + .highlight_style(highlight_style) + .select(selected_tab_index) + .padding("", "") + .divider(" ") + .render(area, buf); + } + + pub fn render_body(&self, model: &mut Model, area: Rect, buf: &mut Buffer) { + let tab_body_block = self.block(); + + match self { + SelectedTab::Stacklets => { + render_stacklets(model, tab_body_block.inner(area), buf); + } + SelectedTab::Logs => { + render_logs(model, area, buf); + } + } + tab_body_block.render(area, buf); + } + + const fn palette(&self) -> tailwind::Palette { + match self { + Self::Stacklets => tailwind::BLUE, + Self::Logs => tailwind::EMERALD, + // Self::Tab3 => tailwind::INDIGO, + // Self::Tab4 => tailwind::RED, + } + } + + /// A block surrounding the tab's content + fn block(self) -> Block<'static> { + Block::bordered() + .border_set(symbols::border::PROPORTIONAL_TALL) + .padding(Padding::horizontal(1)) + .border_style(self.palette().c700) + } + + /// Return tab's name as a styled `Line` + fn title(self) -> Line<'static> { + format!(" {self} ") + .fg(tailwind::SLATE.c200) + .bg(self.palette().c900) + .into() + } + + pub fn previous(self) -> Self { + let current = self as usize; + + if current == 0 { + Self::from_repr(Self::COUNT - 1).expect("Tab enum variant must be known") + } else { + Self::from_repr(current - 1).expect("Tab enum variant must be known") + } + } + + pub fn next(self) -> Self { + let current = self as usize; + Self::from_repr(current.saturating_add(1) % Self::COUNT) + .expect("Tab enum variant must be known") + } +} diff --git a/rust/stackablectl/src/cmds/control_center/state.rs b/rust/stackablectl/src/cmds/control_center/state.rs new file mode 100644 index 00000000..ef3bed68 --- /dev/null +++ b/rust/stackablectl/src/cmds/control_center/state.rs @@ -0,0 +1,130 @@ +use std::{cmp::min, time::Duration}; + +use ratatui::widgets::TableState; +use snafu::{ResultExt, Snafu}; +use stackable_cockpit::{ + platform::stacklet::{self, list_stacklets, Stacklet}, + utils::k8s::{self, Client}, +}; +use tokio::{sync::mpsc::Sender, time::MissedTickBehavior}; +use tracing::instrument; +use tui_logger::{TuiWidgetEvent, TuiWidgetState}; + +use super::rendering::tabs::SelectedTab; + +#[derive(Debug, Snafu)] +pub enum Error { + #[snafu(display("failed to create Kubernetes client"))] + KubeClientCreate { source: k8s::Error }, + + #[snafu(display("failed to list stacklets"))] + StackletList { source: stacklet::Error }, +} + +#[derive(Default)] +pub struct Model { + pub running_state: RunningState, + pub selected_tab: SelectedTab, + pub stacklets: Vec, + pub stacklets_table_state: TableState, + pub logging_widget_state: TuiWidgetState, +} + +#[derive(Debug, Default, PartialEq)] +pub enum RunningState { + #[default] + Running, + Done, +} + +#[derive(Debug)] +pub enum Message { + Quit, + StackletListUp { + steps: usize, + }, + StackletListDown { + steps: usize, + }, + StackletListStart, + StackletListEnd, + StackletUpdate { + stacklets: Vec, + }, + NextTab, + PreviousTab, + #[allow(clippy::enum_variant_names)] + LoggingWidgetMessage(TuiWidgetEvent), +} + +#[instrument(skip(model))] +pub fn update(model: &mut Model, message: Message) -> Option { + match message { + Message::Quit => model.running_state = RunningState::Done, + Message::StackletUpdate { stacklets } => model.stacklets = stacklets, + Message::StackletListUp { steps } => { + let new_selected = match model.stacklets_table_state.selected() { + Some(index) => { + if index == 0 { + model.stacklets.len().saturating_sub(1) + } else { + index.saturating_sub(steps) + } + } + None => 0, + }; + model.stacklets_table_state.select(Some(new_selected)); + } + Message::StackletListDown { steps } => { + let new_selected = match model.stacklets_table_state.selected() { + Some(index) => { + if index >= model.stacklets.len().saturating_sub(1) { + 0 + } else { + min(index + steps, model.stacklets.len().saturating_sub(1)) + } + } + None => 0, + }; + model.stacklets_table_state.select(Some(new_selected)); + } + Message::StackletListStart => { + model.stacklets_table_state.select(Some(0)); + } + Message::StackletListEnd => { + model + .stacklets_table_state + .select(Some(model.stacklets.len().saturating_sub(1))); + } + Message::NextTab => { + model.selected_tab = model.selected_tab.next(); + } + Message::PreviousTab => { + model.selected_tab = model.selected_tab.previous(); + } + Message::LoggingWidgetMessage(message) => model.logging_widget_state.transition(message), + }; + + None +} + +#[instrument] +pub async fn update_stacklets_loop(message_tx: Sender) -> Result<(), Error> { + let client = Client::new().await.context(KubeClientCreateSnafu)?; + + let mut interval = tokio::time::interval(Duration::from_secs(1)); + interval.set_missed_tick_behavior(MissedTickBehavior::Delay); + + loop { + let stacklets = list_stacklets(&client, None /* We list them in all namespaces */) + .await + .context(StackletListSnafu)?; + + message_tx + .send(Message::StackletUpdate { stacklets }) + .await + .unwrap(); + + interval.tick().await; + } +} diff --git a/rust/stackablectl/src/cmds/mod.rs b/rust/stackablectl/src/cmds/mod.rs index 039e1fd8..b889e9b1 100644 --- a/rust/stackablectl/src/cmds/mod.rs +++ b/rust/stackablectl/src/cmds/mod.rs @@ -1,5 +1,6 @@ pub mod cache; pub mod completions; +pub mod control_center; pub mod debug; pub mod demo; pub mod operator; diff --git a/rust/stackablectl/src/cmds/stacklet.rs b/rust/stackablectl/src/cmds/stacklet.rs index 55c3b6e8..b25356e5 100644 --- a/rust/stackablectl/src/cmds/stacklet.rs +++ b/rust/stackablectl/src/cmds/stacklet.rs @@ -156,7 +156,7 @@ async fn list_cmd(args: &StackletListArgs, cli: &Cli) -> Result Result<(), Error> { todo!() } - // Construct the tracing subscriber - let format = fmt::format() - .with_ansi(true) - .without_time() - .with_target(false); - - tracing_subscriber::fmt() - .with_max_level(match app.log_level { - Some(level) => LevelFilter::from_level(level), - None => LevelFilter::WARN, - }) - .event_format(format) - .pretty() - .init(); + // The control center does it's own logging, we don't want to mess up the screen for it + if !matches!(app.subcommand, Commands::ExperimentalControlCenter { .. }) { + // Construct the tracing subscriber + let format = fmt::format() + .with_ansi(true) + .without_time() + .with_target(false); + + tracing_subscriber::fmt() + .with_max_level(match app.log_level { + Some(level) => LevelFilter::from_level(level), + None => LevelFilter::WARN, + }) + .event_format(format) + .pretty() + .init(); + } // Load env vars from optional .env file match dotenv() {