From d6f6ac6459e92d4375063080ae0983b518896aea Mon Sep 17 00:00:00 2001 From: Sebastian Bernauer Date: Tue, 12 Nov 2024 13:40:40 +0100 Subject: [PATCH 1/5] WIP, display stacklets --- Cargo.lock | 204 +++++++++++++++++- Cargo.toml | 7 + rust/stackable-cockpit/src/constants.rs | 13 +- .../src/platform/stacklet/grafana.rs | 3 +- .../src/platform/stacklet/minio.rs | 3 +- .../src/platform/stacklet/mod.rs | 8 +- .../src/platform/stacklet/opensearch.rs | 3 +- .../src/platform/stacklet/prometheus.rs | 3 +- .../src/utils/k8s/conditions.rs | 23 +- rust/stackablectl/Cargo.toml | 3 + rust/stackablectl/src/cli/mod.rs | 18 +- .../src/cmds/control_center/input_handling.rs | 41 ++++ .../src/cmds/control_center/logging.rs | 32 +++ .../src/cmds/control_center/message.rs | 7 + .../src/cmds/control_center/mod.rs | 70 ++++++ .../src/cmds/control_center/rendering.rs | 121 +++++++++++ .../src/cmds/control_center/state.rs | 66 ++++++ rust/stackablectl/src/cmds/mod.rs | 1 + rust/stackablectl/src/cmds/stacklet.rs | 2 +- rust/stackablectl/src/main.rs | 35 +-- 20 files changed, 626 insertions(+), 37 deletions(-) create mode 100644 rust/stackablectl/src/cmds/control_center/input_handling.rs create mode 100644 rust/stackablectl/src/cmds/control_center/logging.rs create mode 100644 rust/stackablectl/src/cmds/control_center/message.rs create mode 100644 rust/stackablectl/src/cmds/control_center/mod.rs create mode 100644 rust/stackablectl/src/cmds/control_center/rendering.rs create mode 100644 rust/stackablectl/src/cmds/control_center/state.rs diff --git a/Cargo.lock b/Cargo.lock index 8da4c06e..6c478f23 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", @@ -3122,8 +3269,15 @@ dependencies = [ "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 +3733,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 +3858,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..de66bc71 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" @@ -52,6 +54,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 +64,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)] @@ -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 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..1c6f5e0f 100644 --- a/rust/stackablectl/Cargo.toml +++ b/rust/stackablectl/Cargo.toml @@ -20,7 +20,9 @@ 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 @@ -31,6 +33,7 @@ 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..5f4f4b10 --- /dev/null +++ b/rust/stackablectl/src/cmds/control_center/input_handling.rs @@ -0,0 +1,41 @@ +use std::time::Duration; + +use ratatui::crossterm::event::{self, Event, KeyCode, KeyModifiers}; +use snafu::{ResultExt, Snafu}; +use tokio::sync::mpsc::Sender; + +use super::{message::Message, state::Model}; + +#[derive(Debug, Snafu)] +pub enum Error { + #[snafu(display("failed to read event"))] + ReadEvent { source: std::io::Error }, +} + +pub async fn handle_event(_: &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 { + let maybe_message = handle_key(key); + if let Some(message) = maybe_message { + message_tx.send(message).await.unwrap(); + } + } + } + } + + Ok(()) +} + +pub fn handle_key(key: event::KeyEvent) -> Option { + 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 => Some(Message::Quit), + KeyCode::Char('q') | KeyCode::Esc => Some(Message::Quit), + KeyCode::Char('j') | KeyCode::Down => todo!(), + KeyCode::Char('k') | KeyCode::Up => todo!(), + _ => None, + } +} 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..44ee9a3a --- /dev/null +++ b/rust/stackablectl/src/cmds/control_center/logging.rs @@ -0,0 +1,32 @@ +use ratatui::prelude::*; +use tracing_subscriber::{layer::SubscriberExt, util::SubscriberInitExt}; +use tui_logger::{TuiLoggerLevelOutput, TuiLoggerSmartWidget, TuiWidgetState}; + +pub fn init_logging() { + tracing_subscriber::registry() + .with(tui_logger::tracing_subscriber_layer()) + .init(); + + tui_logger::init_logger(log::LevelFilter::Trace).unwrap(); + tui_logger::set_default_level(log::LevelFilter::Info); + tracing::error!("Ich war hier"); +} + +pub fn render_logging(area: Rect, state: &TuiWidgetState, 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(true) + // .output_line(true) + .output_file(false) + .output_line(false) + .state(state) + .render(area, buf); +} diff --git a/rust/stackablectl/src/cmds/control_center/message.rs b/rust/stackablectl/src/cmds/control_center/message.rs new file mode 100644 index 00000000..5c397f11 --- /dev/null +++ b/rust/stackablectl/src/cmds/control_center/message.rs @@ -0,0 +1,7 @@ +use stackable_cockpit::platform::stacklet::Stacklet; + +#[derive(Debug)] +pub enum Message { + StackletUpdate { stacklets: Vec }, + Quit, +} 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..285e87d9 --- /dev/null +++ b/rust/stackablectl/src/cmds/control_center/mod.rs @@ -0,0 +1,70 @@ +use clap::Args; +use snafu::{ResultExt, Snafu}; +use tokio::sync::mpsc; + +use crate::cli::Cli; + +use self::{ + input_handling::handle_event, + logging::init_logging, + message::Message, + rendering::render, + state::{update, update_stacklets_loop, Model, RunningState}, +}; + +mod input_handling; +mod logging; +mod message; +mod rendering; +mod state; + +#[derive(Debug, Snafu)] +// #[snafu(module)] +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.rs b/rust/stackablectl/src/cmds/control_center/rendering.rs new file mode 100644 index 00000000..641bdf10 --- /dev/null +++ b/rust/stackablectl/src/cmds/control_center/rendering.rs @@ -0,0 +1,121 @@ +use std::cmp::max; + +use ratatui::{ + prelude::*, + widgets::{Block, Paragraph, Row, Table}, +}; +use stackable_cockpit::{platform::stacklet::Stacklet, utils::k8s::StackletConditionsExt}; +use tracing::instrument; +use tui_logger::TuiWidgetState; + +use super::{logging::render_logging, state::Model}; + +// const INFO_TEXT: [&str; 1] = ["(Esc or q) quit | (↑) move up | (↓) move down"]; + +#[instrument] +pub fn render(model: &mut Model, frame: &mut Frame) { + let main_layout = Layout::vertical([ + Constraint::Fill(40), + Constraint::Fill(20), + Constraint::Fill(40), + ]) + .spacing(1) + .split(frame.area()); + + let table = get_table(&model.stacklets); + + model.stacklets_table_state.select(Some(3)); // select the forth row (0-indexed) + + frame.render_stateful_widget(table, main_layout[0], &mut model.stacklets_table_state); + frame.render_widget( + Paragraph::new(format!("Number of stacklets: {}", model.stacklets.len())) + .block(Block::bordered().title("Stacklet details")), + main_layout[1], + ); + render_logging(main_layout[2], &TuiWidgetState::new(), frame.buffer_mut()); +} + +fn get_table<'a>(stacklets: &Vec) -> Table<'a> { + let mut rows = Vec::with_capacity(stacklets.len()); + + // let stacklets = stacklets.iter(); + // .chain(stacklets.iter()) + // .chain(stacklets.iter()) + // .chain(stacklets.iter()) + // .chain(stacklets.iter()) + // .chain(stacklets.iter()); + + for (_index, stacklet) in stacklets.iter().enumerate() { + let stacklet_healthy = stacklet.conditions.is_stacklet_healthy(); + let reconciliation_paused = stacklet.conditions.is_reconciliation_paused(); + + // let color = match index % 2 { + // 0 => stacklet_table_colors.normal_row_color, + // _ => stacklet_table_colors.alt_row_color, + // }; + let (fg_color, bg_color) = if stacklet_healthy { + if reconciliation_paused { + (Color::Gray, Color::Black) + } else { + (Color::Indexed(153), Color::Black) + } + } else { + (Color::LightRed, Color::Black) + }; + rows.push( + Row::new(vec![ + stacklet.namespace.clone().unwrap_or_default(), + stacklet.product.clone(), + stacklet.name.clone(), + ]) + .style(Style::new().fg(fg_color).bg(bg_color)), + ); + } + + let widths = calculate_row_widths(stacklets); + Table::new(rows, widths) + // ...and they can be separated by a fixed spacing. + .column_spacing(1) + // 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"]) + .style(Style::new().bold()) + // To add space between the header and the rest of the rows, specify the margin + .bottom_margin(1), + ) + // As any other widget, a Table can be wrapped in a Block. + .block(Block::bordered().title("Stacklets")) +} + +fn calculate_row_widths(stacklets: &Vec) -> [Constraint; 3] { + 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, + )), + ] +} + +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/state.rs b/rust/stackablectl/src/cmds/control_center/state.rs new file mode 100644 index 00000000..63426ec9 --- /dev/null +++ b/rust/stackablectl/src/cmds/control_center/state.rs @@ -0,0 +1,66 @@ +use std::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 super::message::Message; + +#[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(Debug, Default)] +pub struct Model { + pub running_state: RunningState, + pub stacklets: Vec, + pub stacklets_table_state: TableState, +} + +#[derive(Debug, Default, PartialEq)] +pub enum RunningState { + #[default] + Running, + Done, +} + +#[instrument] +pub fn update(model: &mut Model, message: Message) -> Option { + match message { + Message::StackletUpdate { stacklets } => model.stacklets = stacklets, + Message::Quit => model.running_state = RunningState::Done, + }; + + 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 */) + // list_stackable_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() { From bfec19889f17e45c681e724319621f66e96d7e33 Mon Sep 17 00:00:00 2001 From: Sebastian Bernauer Date: Tue, 12 Nov 2024 13:46:10 +0100 Subject: [PATCH 2/5] fix: Store logging widget state --- rust/stackablectl/src/cmds/control_center/logging.rs | 2 -- .../stackablectl/src/cmds/control_center/rendering.rs | 11 +++++++---- rust/stackablectl/src/cmds/control_center/state.rs | 6 ++++-- 3 files changed, 11 insertions(+), 8 deletions(-) diff --git a/rust/stackablectl/src/cmds/control_center/logging.rs b/rust/stackablectl/src/cmds/control_center/logging.rs index 44ee9a3a..faa2f7d5 100644 --- a/rust/stackablectl/src/cmds/control_center/logging.rs +++ b/rust/stackablectl/src/cmds/control_center/logging.rs @@ -23,8 +23,6 @@ pub fn render_logging(area: Rect, state: &TuiWidgetState, buf: &mut Buffer) { .output_timestamp(Some("%H:%M:%S".to_string())) .output_level(Some(TuiLoggerLevelOutput::Abbreviated)) .output_target(true) - // .output_file(true) - // .output_line(true) .output_file(false) .output_line(false) .state(state) diff --git a/rust/stackablectl/src/cmds/control_center/rendering.rs b/rust/stackablectl/src/cmds/control_center/rendering.rs index 641bdf10..9bd4014f 100644 --- a/rust/stackablectl/src/cmds/control_center/rendering.rs +++ b/rust/stackablectl/src/cmds/control_center/rendering.rs @@ -6,13 +6,12 @@ use ratatui::{ }; use stackable_cockpit::{platform::stacklet::Stacklet, utils::k8s::StackletConditionsExt}; use tracing::instrument; -use tui_logger::TuiWidgetState; use super::{logging::render_logging, state::Model}; // const INFO_TEXT: [&str; 1] = ["(Esc or q) quit | (↑) move up | (↓) move down"]; -#[instrument] +#[instrument(skip(model))] pub fn render(model: &mut Model, frame: &mut Frame) { let main_layout = Layout::vertical([ Constraint::Fill(40), @@ -24,7 +23,7 @@ pub fn render(model: &mut Model, frame: &mut Frame) { let table = get_table(&model.stacklets); - model.stacklets_table_state.select(Some(3)); // select the forth row (0-indexed) + model.stacklets_table_state.select(Some(2)); frame.render_stateful_widget(table, main_layout[0], &mut model.stacklets_table_state); frame.render_widget( @@ -32,7 +31,11 @@ pub fn render(model: &mut Model, frame: &mut Frame) { .block(Block::bordered().title("Stacklet details")), main_layout[1], ); - render_logging(main_layout[2], &TuiWidgetState::new(), frame.buffer_mut()); + render_logging( + main_layout[2], + &mut model.logging_widget_state, + frame.buffer_mut(), + ); } fn get_table<'a>(stacklets: &Vec) -> Table<'a> { diff --git a/rust/stackablectl/src/cmds/control_center/state.rs b/rust/stackablectl/src/cmds/control_center/state.rs index 63426ec9..5e060a22 100644 --- a/rust/stackablectl/src/cmds/control_center/state.rs +++ b/rust/stackablectl/src/cmds/control_center/state.rs @@ -8,6 +8,7 @@ use stackable_cockpit::{ }; use tokio::{sync::mpsc::Sender, time::MissedTickBehavior}; use tracing::instrument; +use tui_logger::TuiWidgetState; use super::message::Message; @@ -20,11 +21,12 @@ pub enum Error { StackletList { source: stacklet::Error }, } -#[derive(Debug, Default)] +#[derive(Default)] pub struct Model { pub running_state: RunningState, pub stacklets: Vec, pub stacklets_table_state: TableState, + pub logging_widget_state: TuiWidgetState, } #[derive(Debug, Default, PartialEq)] @@ -34,7 +36,7 @@ pub enum RunningState { Done, } -#[instrument] +#[instrument(skip(model))] pub fn update(model: &mut Model, message: Message) -> Option { match message { Message::StackletUpdate { stacklets } => model.stacklets = stacklets, From 7332c00d3396a41026539054eb2894d013e5ab69 Mon Sep 17 00:00:00 2001 From: Sebastian Bernauer Date: Thu, 14 Nov 2024 12:00:33 +0100 Subject: [PATCH 3/5] WIP, added 2 Tabs for stacklets and logs --- Cargo.lock | 1 + Cargo.toml | 1 + rust/stackablectl/Cargo.toml | 1 + .../src/cmds/control_center/input_handling.rs | 37 +++-- .../src/cmds/control_center/logging.rs | 11 +- .../src/cmds/control_center/message.rs | 7 - .../src/cmds/control_center/mod.rs | 5 +- .../src/cmds/control_center/rendering.rs | 124 --------------- .../src/cmds/control_center/rendering/mod.rs | 19 +++ .../control_center/rendering/stacklets.rs | 146 ++++++++++++++++++ .../src/cmds/control_center/rendering/tabs.rs | 88 +++++++++++ .../src/cmds/control_center/state.rs | 61 +++++++- 12 files changed, 347 insertions(+), 154 deletions(-) delete mode 100644 rust/stackablectl/src/cmds/control_center/message.rs delete mode 100644 rust/stackablectl/src/cmds/control_center/rendering.rs create mode 100644 rust/stackablectl/src/cmds/control_center/rendering/mod.rs create mode 100644 rust/stackablectl/src/cmds/control_center/rendering/stacklets.rs create mode 100644 rust/stackablectl/src/cmds/control_center/rendering/tabs.rs diff --git a/Cargo.lock b/Cargo.lock index 6c478f23..6277f83d 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -3264,6 +3264,7 @@ dependencies = [ "snafu 0.8.4", "stackable-cockpit", "stackable-operator", + "strum", "tera", "termion", "tokio", diff --git a/Cargo.toml b/Cargo.toml index de66bc71..ef098337 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -47,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" diff --git a/rust/stackablectl/Cargo.toml b/rust/stackablectl/Cargo.toml index 1c6f5e0f..3af78854 100644 --- a/rust/stackablectl/Cargo.toml +++ b/rust/stackablectl/Cargo.toml @@ -29,6 +29,7 @@ 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 diff --git a/rust/stackablectl/src/cmds/control_center/input_handling.rs b/rust/stackablectl/src/cmds/control_center/input_handling.rs index 5f4f4b10..f2610e90 100644 --- a/rust/stackablectl/src/cmds/control_center/input_handling.rs +++ b/rust/stackablectl/src/cmds/control_center/input_handling.rs @@ -4,7 +4,7 @@ use ratatui::crossterm::event::{self, Event, KeyCode, KeyModifiers}; use snafu::{ResultExt, Snafu}; use tokio::sync::mpsc::Sender; -use super::{message::Message, state::Model}; +use super::state::{Message, Model}; #[derive(Debug, Snafu)] pub enum Error { @@ -16,8 +16,7 @@ pub async fn handle_event(_: &Model, message_tx: Sender) -> Result<(), if event::poll(Duration::from_millis(250)).context(ReadEventSnafu)? { if let Event::Key(key) = event::read().context(ReadEventSnafu)? { if key.kind == event::KeyEventKind::Press { - let maybe_message = handle_key(key); - if let Some(message) = maybe_message { + for message in handle_key(key) { message_tx.send(message).await.unwrap(); } } @@ -27,15 +26,33 @@ pub async fn handle_event(_: &Model, message_tx: Sender) -> Result<(), Ok(()) } -pub fn handle_key(key: event::KeyEvent) -> Option { - let _shift_pressed = key.modifiers.contains(KeyModifiers::SHIFT); +pub fn handle_key(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 => Some(Message::Quit), - KeyCode::Char('q') | KeyCode::Esc => Some(Message::Quit), - KeyCode::Char('j') | KeyCode::Down => todo!(), - KeyCode::Char('k') | KeyCode::Up => todo!(), - _ => None, + KeyCode::Char('c') if ctrl_pressed => vec![Message::Quit], + KeyCode::Char('q') /*| KeyCode::Esc*/ => vec![Message::Quit], + 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, + }] + }, + KeyCode::Tab if shift_pressed => vec![Message::PreviousTab], + KeyCode::Tab => vec![Message::NextTab], + _ => vec![], } } diff --git a/rust/stackablectl/src/cmds/control_center/logging.rs b/rust/stackablectl/src/cmds/control_center/logging.rs index faa2f7d5..8affc53b 100644 --- a/rust/stackablectl/src/cmds/control_center/logging.rs +++ b/rust/stackablectl/src/cmds/control_center/logging.rs @@ -1,6 +1,8 @@ use ratatui::prelude::*; use tracing_subscriber::{layer::SubscriberExt, util::SubscriberInitExt}; -use tui_logger::{TuiLoggerLevelOutput, TuiLoggerSmartWidget, TuiWidgetState}; +use tui_logger::{TuiLoggerLevelOutput, TuiLoggerSmartWidget}; + +use super::state::Model; pub fn init_logging() { tracing_subscriber::registry() @@ -8,11 +10,10 @@ pub fn init_logging() { .init(); tui_logger::init_logger(log::LevelFilter::Trace).unwrap(); - tui_logger::set_default_level(log::LevelFilter::Info); - tracing::error!("Ich war hier"); + tui_logger::set_default_level(log::LevelFilter::Trace); } -pub fn render_logging(area: Rect, state: &TuiWidgetState, buf: &mut Buffer) { +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)) @@ -25,6 +26,6 @@ pub fn render_logging(area: Rect, state: &TuiWidgetState, buf: &mut Buffer) { .output_target(true) .output_file(false) .output_line(false) - .state(state) + .state(&model.logging_widget_state) .render(area, buf); } diff --git a/rust/stackablectl/src/cmds/control_center/message.rs b/rust/stackablectl/src/cmds/control_center/message.rs deleted file mode 100644 index 5c397f11..00000000 --- a/rust/stackablectl/src/cmds/control_center/message.rs +++ /dev/null @@ -1,7 +0,0 @@ -use stackable_cockpit::platform::stacklet::Stacklet; - -#[derive(Debug)] -pub enum Message { - StackletUpdate { stacklets: Vec }, - Quit, -} diff --git a/rust/stackablectl/src/cmds/control_center/mod.rs b/rust/stackablectl/src/cmds/control_center/mod.rs index 285e87d9..57a69e1e 100644 --- a/rust/stackablectl/src/cmds/control_center/mod.rs +++ b/rust/stackablectl/src/cmds/control_center/mod.rs @@ -7,19 +7,16 @@ use crate::cli::Cli; use self::{ input_handling::handle_event, logging::init_logging, - message::Message, rendering::render, - state::{update, update_stacklets_loop, Model, RunningState}, + state::{update, update_stacklets_loop, Message, Model, RunningState}, }; mod input_handling; mod logging; -mod message; mod rendering; mod state; #[derive(Debug, Snafu)] -// #[snafu(module)] pub enum CmdError { #[snafu(display("failed to draw to terminal"))] DrawToTerminal { source: std::io::Error }, diff --git a/rust/stackablectl/src/cmds/control_center/rendering.rs b/rust/stackablectl/src/cmds/control_center/rendering.rs deleted file mode 100644 index 9bd4014f..00000000 --- a/rust/stackablectl/src/cmds/control_center/rendering.rs +++ /dev/null @@ -1,124 +0,0 @@ -use std::cmp::max; - -use ratatui::{ - prelude::*, - widgets::{Block, Paragraph, Row, Table}, -}; -use stackable_cockpit::{platform::stacklet::Stacklet, utils::k8s::StackletConditionsExt}; -use tracing::instrument; - -use super::{logging::render_logging, state::Model}; - -// 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 main_layout = Layout::vertical([ - Constraint::Fill(40), - Constraint::Fill(20), - Constraint::Fill(40), - ]) - .spacing(1) - .split(frame.area()); - - let table = get_table(&model.stacklets); - - model.stacklets_table_state.select(Some(2)); - - frame.render_stateful_widget(table, main_layout[0], &mut model.stacklets_table_state); - frame.render_widget( - Paragraph::new(format!("Number of stacklets: {}", model.stacklets.len())) - .block(Block::bordered().title("Stacklet details")), - main_layout[1], - ); - render_logging( - main_layout[2], - &mut model.logging_widget_state, - frame.buffer_mut(), - ); -} - -fn get_table<'a>(stacklets: &Vec) -> Table<'a> { - let mut rows = Vec::with_capacity(stacklets.len()); - - // let stacklets = stacklets.iter(); - // .chain(stacklets.iter()) - // .chain(stacklets.iter()) - // .chain(stacklets.iter()) - // .chain(stacklets.iter()) - // .chain(stacklets.iter()); - - for (_index, stacklet) in stacklets.iter().enumerate() { - let stacklet_healthy = stacklet.conditions.is_stacklet_healthy(); - let reconciliation_paused = stacklet.conditions.is_reconciliation_paused(); - - // let color = match index % 2 { - // 0 => stacklet_table_colors.normal_row_color, - // _ => stacklet_table_colors.alt_row_color, - // }; - let (fg_color, bg_color) = if stacklet_healthy { - if reconciliation_paused { - (Color::Gray, Color::Black) - } else { - (Color::Indexed(153), Color::Black) - } - } else { - (Color::LightRed, Color::Black) - }; - rows.push( - Row::new(vec![ - stacklet.namespace.clone().unwrap_or_default(), - stacklet.product.clone(), - stacklet.name.clone(), - ]) - .style(Style::new().fg(fg_color).bg(bg_color)), - ); - } - - let widths = calculate_row_widths(stacklets); - Table::new(rows, widths) - // ...and they can be separated by a fixed spacing. - .column_spacing(1) - // 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"]) - .style(Style::new().bold()) - // To add space between the header and the rest of the rows, specify the margin - .bottom_margin(1), - ) - // As any other widget, a Table can be wrapped in a Block. - .block(Block::bordered().title("Stacklets")) -} - -fn calculate_row_widths(stacklets: &Vec) -> [Constraint; 3] { - 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, - )), - ] -} - -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/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..a631d92a --- /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)).bg(Color::Black); + let unhealthy_style = Style::default().fg(Color::LightRed).bg(Color::Black); + let reconciliation_paused_style = Style::default().fg(Color::Gray).bg(Color::Black); + + 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 index 5e060a22..39cad76c 100644 --- a/rust/stackablectl/src/cmds/control_center/state.rs +++ b/rust/stackablectl/src/cmds/control_center/state.rs @@ -1,4 +1,4 @@ -use std::time::Duration; +use std::{cmp::min, time::Duration}; use ratatui::widgets::TableState; use snafu::{ResultExt, Snafu}; @@ -10,7 +10,7 @@ use tokio::{sync::mpsc::Sender, time::MissedTickBehavior}; use tracing::instrument; use tui_logger::TuiWidgetState; -use super::message::Message; +use super::rendering::tabs::SelectedTab; #[derive(Debug, Snafu)] pub enum Error { @@ -24,6 +24,7 @@ pub enum 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, @@ -36,11 +37,63 @@ pub enum RunningState { Done, } +#[derive(Debug)] +pub enum Message { + Quit, + StackletListUp { steps: usize }, + StackletListDown { steps: usize }, + StackletListStart, + StackletListEnd, + StackletUpdate { stacklets: Vec }, + NextTab, + PreviousTab, +} + #[instrument(skip(model))] pub fn update(model: &mut Model, message: Message) -> Option { match message { - Message::StackletUpdate { stacklets } => model.stacklets = stacklets, 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(); + } }; None @@ -55,9 +108,9 @@ pub async fn update_stacklets_loop(message_tx: Sender) -> Result<(), Er loop { let stacklets = list_stacklets(&client, None /* We list them in all namespaces */) - // list_stackable_stacklets(&client, None /* We list them in all namespaces */) .await .context(StackletListSnafu)?; + message_tx .send(Message::StackletUpdate { stacklets }) .await From 8c08b46b557e091102d849d8795dee24164519a9 Mon Sep 17 00:00:00 2001 From: Sebastian Bernauer Date: Thu, 14 Nov 2024 15:04:56 +0100 Subject: [PATCH 4/5] fix: Don't set background to black explecitely --- .../src/cmds/control_center/rendering/stacklets.rs | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/rust/stackablectl/src/cmds/control_center/rendering/stacklets.rs b/rust/stackablectl/src/cmds/control_center/rendering/stacklets.rs index a631d92a..f4210771 100644 --- a/rust/stackablectl/src/cmds/control_center/rendering/stacklets.rs +++ b/rust/stackablectl/src/cmds/control_center/rendering/stacklets.rs @@ -20,9 +20,9 @@ pub fn render_stacklets(model: &mut Model, area: Rect, buf: &mut Buffer) { 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)).bg(Color::Black); - let unhealthy_style = Style::default().fg(Color::LightRed).bg(Color::Black); - let reconciliation_paused_style = Style::default().fg(Color::Gray).bg(Color::Black); + 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)); From fa4ed5deed130ab1806b62df8af80700e85f7f35 Mon Sep 17 00:00:00 2001 From: Sebastian Bernauer Date: Thu, 14 Nov 2024 15:52:47 +0100 Subject: [PATCH 5/5] fix: Handle key events in logging tab --- .../src/platform/stacklet/mod.rs | 4 +- .../stackable-cockpit/src/utils/k8s/client.rs | 7 ++- .../src/cmds/control_center/input_handling.rs | 60 ++++++++++++++++--- .../src/cmds/control_center/logging.rs | 7 ++- .../src/cmds/control_center/state.rs | 17 ++++-- 5 files changed, 75 insertions(+), 20 deletions(-) diff --git a/rust/stackable-cockpit/src/platform/stacklet/mod.rs b/rust/stackable-cockpit/src/platform/stacklet/mod.rs index 37aca427..be363011 100644 --- a/rust/stackable-cockpit/src/platform/stacklet/mod.rs +++ b/rust/stackable-cockpit/src/platform/stacklet/mod.rs @@ -3,7 +3,7 @@ use kube::{core::GroupVersionKind, ResourceExt}; use serde::Serialize; use snafu::{ResultExt, Snafu}; use stackable_operator::status::condition::ClusterCondition; -use tracing::info; +use tracing::{debug, info}; #[cfg(feature = "openapi")] use utoipa::ToSchema; @@ -128,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; diff --git a/rust/stackable-cockpit/src/utils/k8s/client.rs b/rust/stackable-cockpit/src/utils/k8s/client.rs index 07b5f484..c429810a 100644 --- a/rust/stackable-cockpit/src/utils/k8s/client.rs +++ b/rust/stackable-cockpit/src/utils/k8s/client.rs @@ -14,7 +14,7 @@ use serde::Deserialize; use snafu::{OptionExt, ResultExt, Snafu}; use stackable_operator::{commons::listener::Listener, kvp::Labels}; use tokio::sync::RwLock; -use tracing::info; +use tracing::{debug, instrument}; use crate::{ platform::{cluster, credentials::Credentials}, @@ -396,6 +396,7 @@ impl Client { /// Try to resolve the given [`GroupVersionKind`]. In case the resolution fails a discovery is run to pull in new /// GVKs that are not present in the [`Discovery`] cache. Afterwards a normal resolution is issued. + #[instrument(skip(self))] async fn resolve_gvk( &self, gvk: &GroupVersionKind, @@ -405,7 +406,7 @@ impl Client { Ok(match resolved { Some(resolved) => 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/stackablectl/src/cmds/control_center/input_handling.rs b/rust/stackablectl/src/cmds/control_center/input_handling.rs index f2610e90..957aa722 100644 --- a/rust/stackablectl/src/cmds/control_center/input_handling.rs +++ b/rust/stackablectl/src/cmds/control_center/input_handling.rs @@ -3,8 +3,12 @@ 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::state::{Message, Model}; +use super::{ + rendering::tabs::SelectedTab, + state::{Message, Model}, +}; #[derive(Debug, Snafu)] pub enum Error { @@ -12,11 +16,11 @@ pub enum Error { ReadEvent { source: std::io::Error }, } -pub async fn handle_event(_: &Model, message_tx: Sender) -> Result<(), 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(key) { + for message in handle_key(model, key) { message_tx.send(message).await.unwrap(); } } @@ -26,13 +30,26 @@ pub async fn handle_event(_: &Model, message_tx: Sender) -> Result<(), Ok(()) } -pub fn handle_key(key: event::KeyEvent) -> Vec { +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 => vec![Message::Quit], - KeyCode::Char('q') /*| KeyCode::Esc*/ => vec![Message::Quit], + 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], @@ -50,9 +67,34 @@ pub fn handle_key(key: event::KeyEvent) -> Vec { vec![Message::StackletListDown { steps: table_height, }] - }, - KeyCode::Tab if shift_pressed => vec![Message::PreviousTab], - KeyCode::Tab => vec![Message::NextTab], + } + _ => 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 index 8affc53b..6a74b32d 100644 --- a/rust/stackablectl/src/cmds/control_center/logging.rs +++ b/rust/stackablectl/src/cmds/control_center/logging.rs @@ -1,3 +1,4 @@ +use log::LevelFilter; use ratatui::prelude::*; use tracing_subscriber::{layer::SubscriberExt, util::SubscriberInitExt}; use tui_logger::{TuiLoggerLevelOutput, TuiLoggerSmartWidget}; @@ -9,8 +10,10 @@ pub fn init_logging() { .with(tui_logger::tracing_subscriber_layer()) .init(); - tui_logger::init_logger(log::LevelFilter::Trace).unwrap(); - tui_logger::set_default_level(log::LevelFilter::Trace); + // 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) { diff --git a/rust/stackablectl/src/cmds/control_center/state.rs b/rust/stackablectl/src/cmds/control_center/state.rs index 39cad76c..ef3bed68 100644 --- a/rust/stackablectl/src/cmds/control_center/state.rs +++ b/rust/stackablectl/src/cmds/control_center/state.rs @@ -8,7 +8,7 @@ use stackable_cockpit::{ }; use tokio::{sync::mpsc::Sender, time::MissedTickBehavior}; use tracing::instrument; -use tui_logger::TuiWidgetState; +use tui_logger::{TuiWidgetEvent, TuiWidgetState}; use super::rendering::tabs::SelectedTab; @@ -40,13 +40,21 @@ pub enum RunningState { #[derive(Debug)] pub enum Message { Quit, - StackletListUp { steps: usize }, - StackletListDown { steps: usize }, + StackletListUp { + steps: usize, + }, + StackletListDown { + steps: usize, + }, StackletListStart, StackletListEnd, - StackletUpdate { stacklets: Vec }, + StackletUpdate { + stacklets: Vec, + }, NextTab, PreviousTab, + #[allow(clippy::enum_variant_names)] + LoggingWidgetMessage(TuiWidgetEvent), } #[instrument(skip(model))] @@ -94,6 +102,7 @@ pub fn update(model: &mut Model, message: Message) -> Option { Message::PreviousTab => { model.selected_tab = model.selected_tab.previous(); } + Message::LoggingWidgetMessage(message) => model.logging_widget_state.transition(message), }; None