├── .gitignore ├── src └── cli │ ├── commands │ ├── node │ │ ├── mod.rs │ │ ├── output.rs │ │ ├── json.rs │ │ └── command.rs │ ├── tui │ │ ├── flows │ │ │ ├── mod.rs │ │ │ └── state.rs │ │ ├── node │ │ │ ├── mod.rs │ │ │ ├── state.rs │ │ │ └── ui.rs │ │ ├── threads │ │ │ ├── mod.rs │ │ │ ├── state.rs │ │ │ └── ui.rs │ │ ├── pipelines │ │ │ ├── mod.rs │ │ │ ├── state.rs │ │ │ └── graph.rs │ │ ├── mod.rs │ │ ├── events.rs │ │ ├── command.rs │ │ ├── widgets.rs │ │ ├── backend.rs │ │ ├── data_decorator.rs │ │ ├── flow_charts.rs │ │ ├── charts.rs │ │ ├── ui.rs │ │ ├── shared_state.rs │ │ ├── app.rs │ │ └── data_fetcher.rs │ ├── traits.rs │ ├── mod.rs │ └── formatter.rs │ ├── config.rs │ ├── output.rs │ ├── errors.rs │ ├── cli.rs │ ├── api │ ├── hot_threads.rs │ ├── tls.rs │ ├── node_api.rs │ ├── mod.rs │ ├── node.rs │ └── stats.rs │ └── main.rs ├── docs └── img │ └── demo.gif ├── .github └── workflows │ ├── homebrew.yml │ ├── ci.yml │ └── cd.yml ├── Cargo.toml ├── CHANGELOG.md ├── README.md └── LICENSE /.gitignore: -------------------------------------------------------------------------------- 1 | /target 2 | -------------------------------------------------------------------------------- /src/cli/commands/node/mod.rs: -------------------------------------------------------------------------------- 1 | pub mod command; 2 | mod json; 3 | mod output; 4 | -------------------------------------------------------------------------------- /src/cli/commands/tui/flows/mod.rs: -------------------------------------------------------------------------------- 1 | pub(crate) mod state; 2 | pub(crate) mod ui; 3 | -------------------------------------------------------------------------------- /src/cli/commands/tui/node/mod.rs: -------------------------------------------------------------------------------- 1 | pub(crate) mod state; 2 | pub(crate) mod ui; 3 | -------------------------------------------------------------------------------- /docs/img/demo.gif: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/edmocosta/tuistash/HEAD/docs/img/demo.gif -------------------------------------------------------------------------------- /src/cli/commands/tui/threads/mod.rs: -------------------------------------------------------------------------------- 1 | pub(crate) mod state; 2 | pub(crate) mod ui; 3 | -------------------------------------------------------------------------------- /src/cli/commands/tui/pipelines/mod.rs: -------------------------------------------------------------------------------- 1 | pub(crate) mod graph; 2 | pub(crate) mod state; 3 | pub(crate) mod ui; 4 | -------------------------------------------------------------------------------- /src/cli/config.rs: -------------------------------------------------------------------------------- 1 | use crate::api; 2 | 3 | pub struct Config { 4 | pub api: api::Client, 5 | pub diagnostic_path: Option, 6 | } 7 | -------------------------------------------------------------------------------- /src/cli/commands/traits.rs: -------------------------------------------------------------------------------- 1 | use crate::config::Config; 2 | use crate::errors::AnyError; 3 | use crate::output::Output; 4 | 5 | pub trait RunnableCommand { 6 | fn run(&self, out: &mut Output, args: &T, config: &Config) -> Result<(), AnyError>; 7 | } 8 | -------------------------------------------------------------------------------- /src/cli/output.rs: -------------------------------------------------------------------------------- 1 | use std::io::Write; 2 | 3 | pub struct Output<'a> { 4 | pub handle: &'a mut dyn Write, 5 | } 6 | 7 | impl Output<'_> { 8 | pub fn new(handle: &mut dyn Write) -> Output { 9 | Output { handle } 10 | } 11 | } 12 | -------------------------------------------------------------------------------- /src/cli/commands/tui/mod.rs: -------------------------------------------------------------------------------- 1 | use time::OffsetDateTime; 2 | 3 | mod app; 4 | mod backend; 5 | mod charts; 6 | pub mod command; 7 | mod data_fetcher; 8 | mod events; 9 | mod flow_charts; 10 | 11 | mod data_decorator; 12 | mod flows; 13 | mod node; 14 | mod pipelines; 15 | mod shared_state; 16 | mod threads; 17 | mod ui; 18 | mod widgets; 19 | 20 | fn now_local_unix_timestamp() -> i64 { 21 | OffsetDateTime::now_local().unwrap().unix_timestamp() 22 | } 23 | -------------------------------------------------------------------------------- /src/cli/errors.rs: -------------------------------------------------------------------------------- 1 | use std::fmt::{Display, Formatter}; 2 | use std::{error::Error, fmt}; 3 | 4 | pub type AnyError = Box; 5 | 6 | #[derive(Debug)] 7 | pub struct TuiError { 8 | message: String, 9 | } 10 | 11 | impl TuiError { 12 | pub fn from(message: &str) -> Self { 13 | TuiError { 14 | message: message.to_string(), 15 | } 16 | } 17 | } 18 | 19 | impl Error for TuiError {} 20 | 21 | impl Display for TuiError { 22 | fn fmt(&self, f: &mut Formatter<'_>) -> fmt::Result { 23 | write!(f, "{}", self.message) 24 | } 25 | } 26 | -------------------------------------------------------------------------------- /src/cli/commands/tui/events.rs: -------------------------------------------------------------------------------- 1 | use crate::commands::tui::app::AppData; 2 | use crossterm::event::KeyEvent; 3 | 4 | pub(crate) trait EventsListener { 5 | fn update(&mut self, app_data: &AppData); 6 | fn reset(&mut self); 7 | fn focus_gained(&mut self, _: &AppData) {} 8 | fn focus_lost(&mut self, _: &AppData) {} 9 | fn on_enter(&mut self, _: &AppData) {} 10 | fn on_left(&mut self, _: &AppData) {} 11 | fn on_right(&mut self, _: &AppData) {} 12 | fn on_up(&mut self, _: &AppData) {} 13 | fn on_down(&mut self, _: &AppData) {} 14 | fn on_other(&mut self, _: KeyEvent, _: &AppData) {} 15 | } 16 | -------------------------------------------------------------------------------- /src/cli/cli.rs: -------------------------------------------------------------------------------- 1 | use clap::Parser; 2 | use commands::Command; 3 | 4 | use crate::commands; 5 | 6 | #[derive(Parser)] 7 | #[command(author, version, about, long_about = None)] 8 | #[command(propagate_version = true)] 9 | pub struct Cli { 10 | #[command(subcommand)] 11 | pub command: Option, 12 | 13 | #[arg(long, default_value = "http://localhost:9600", global = true)] 14 | pub host: String, 15 | 16 | #[arg(long, global = true)] 17 | pub username: Option, 18 | 19 | #[arg(long, global = true)] 20 | pub password: Option, 21 | 22 | #[arg(long, default_value_t = false, global = true)] 23 | pub skip_tls_verification: bool, 24 | 25 | /// Read the data from a Logstash diagnostic path 26 | #[arg(long, short = 'p', global = false)] 27 | pub diagnostic_path: Option, 28 | } 29 | 30 | pub fn build_cli() -> Cli { 31 | Cli::parse() 32 | } 33 | -------------------------------------------------------------------------------- /.github/workflows/homebrew.yml: -------------------------------------------------------------------------------- 1 | name: Homebrew 2 | on: 3 | workflow_dispatch: 4 | inputs: 5 | tag-name: 6 | description: 'The git tag name to bump the formula to' 7 | required: true 8 | 9 | jobs: 10 | homebrew: 11 | name: Bump Homebrew formula 12 | runs-on: ubuntu-latest 13 | steps: 14 | - uses: mislav/bump-homebrew-formula-action@v2 15 | with: 16 | formula-name: tuistash 17 | formula-path: Formula/tuistash.rb 18 | homebrew-tap: edmocosta/homebrew-tap 19 | tag-name: ${{ github.event.inputs.tag-name }} 20 | download-url: https://github.com/edmocosta/tuistash/releases/download/${{ github.event.inputs.tag-name }}/tuistash-${{ github.event.inputs.tag-name }}-x86_64-apple-darwin.zip 21 | commit-message: | 22 | {{formulaName}} {{version}} 23 | env: 24 | COMMITTER_TOKEN: ${{ secrets.COMMITTER_TOKEN }} -------------------------------------------------------------------------------- /src/cli/commands/tui/command.rs: -------------------------------------------------------------------------------- 1 | use std::time::Duration; 2 | 3 | use clap::Args; 4 | 5 | use crate::commands::traits::RunnableCommand; 6 | use crate::commands::tui::backend::run; 7 | use crate::config::Config; 8 | use crate::errors::AnyError; 9 | use crate::output::Output; 10 | 11 | #[derive(Args)] 12 | pub struct TuiArgs { 13 | /// Refresh interval in seconds 14 | #[arg(default_value_t = 1, short = 'i', long)] 15 | pub interval: u64, 16 | } 17 | 18 | impl Default for TuiArgs { 19 | fn default() -> Self { 20 | TuiArgs { interval: 1 } 21 | } 22 | } 23 | 24 | pub struct TuiCommand; 25 | 26 | impl RunnableCommand for TuiCommand { 27 | fn run(&self, _: &mut Output, args: &TuiArgs, config: &Config) -> Result<(), AnyError> { 28 | let tick_rate = Duration::from_secs(args.interval); 29 | if let Err(e) = run(tick_rate, config) { 30 | println!("{}", e); 31 | } 32 | 33 | Ok(()) 34 | } 35 | } 36 | -------------------------------------------------------------------------------- /.github/workflows/ci.yml: -------------------------------------------------------------------------------- 1 | name: CI 2 | on: 3 | pull_request: 4 | push: 5 | branches: 6 | - main 7 | jobs: 8 | fmt: 9 | name: Rustfmt 10 | runs-on: ubuntu-latest 11 | steps: 12 | - uses: actions/checkout@v4 13 | - uses: dtolnay/rust-toolchain@nightly 14 | with: 15 | components: rustfmt 16 | - uses: Swatinem/rust-cache@v2 17 | - run: cargo fmt --all -- --check 18 | 19 | clippy: 20 | name: Clippy 21 | runs-on: ubuntu-latest 22 | steps: 23 | - uses: actions/checkout@v4 24 | - uses: dtolnay/rust-toolchain@stable 25 | with: 26 | components: clippy 27 | - uses: Swatinem/rust-cache@v2 28 | - run: cargo clippy --all-targets --all-features -- -D warnings 29 | 30 | test: 31 | name: Test 32 | runs-on: ${{ matrix.os }} 33 | strategy: 34 | fail-fast: false 35 | matrix: 36 | os: [ ubuntu-latest, windows-latest, macOS-latest ] 37 | rust: [ stable ] 38 | steps: 39 | - uses: actions/checkout@v4 40 | - uses: dtolnay/rust-toolchain@master 41 | with: 42 | toolchain: ${{ matrix.rust }} 43 | - uses: Swatinem/rust-cache@v2 44 | - run: cargo test --all-features -------------------------------------------------------------------------------- /src/cli/commands/mod.rs: -------------------------------------------------------------------------------- 1 | use clap::Subcommand; 2 | 3 | use crate::commands::node::command::{NodeArgs, NodeCommand}; 4 | use crate::commands::traits::RunnableCommand; 5 | use crate::commands::tui::command::{TuiArgs, TuiCommand}; 6 | use crate::config::Config; 7 | use crate::errors::AnyError; 8 | use crate::output::Output; 9 | 10 | mod formatter; 11 | mod node; 12 | pub mod traits; 13 | mod tui; 14 | 15 | #[derive(Subcommand)] 16 | pub enum Command { 17 | /// Query data from the Logstash API 18 | #[command(subcommand)] 19 | Get(GetCommands), 20 | /// Logstash TUI 21 | Tui(TuiArgs), 22 | } 23 | 24 | #[derive(Subcommand)] 25 | pub enum GetCommands { 26 | /// Prints the current Logstash node information 27 | Node(NodeArgs), 28 | } 29 | 30 | impl Command { 31 | pub fn execute_default_command(out: &mut Output, config: &Config) -> Result<(), AnyError> { 32 | TuiCommand.run(out, &TuiArgs::default(), config) 33 | } 34 | 35 | pub fn execute(&self, out: &mut Output, config: &Config) -> Result<(), AnyError> { 36 | match &self { 37 | Command::Get(subcommand) => match subcommand { 38 | GetCommands::Node(args) => NodeCommand.run(out, args, config), 39 | }, 40 | Command::Tui(args) => TuiCommand.run(out, args, config), 41 | } 42 | } 43 | } 44 | -------------------------------------------------------------------------------- /src/cli/commands/node/output.rs: -------------------------------------------------------------------------------- 1 | use serde_json::Value; 2 | 3 | use crate::api::node::{NodeInfo, NodeInfoType}; 4 | use crate::commands::node::json::JsonFormatter; 5 | use crate::errors::AnyError; 6 | 7 | pub trait ValueFormatter { 8 | fn format( 9 | &self, 10 | content: &NodeInfo, 11 | types: Option<&[NodeInfoType]>, 12 | ) -> Result; 13 | 14 | fn format_value( 15 | &self, 16 | content: Value, 17 | types: Option<&[NodeInfoType]>, 18 | ) -> Result { 19 | let node_info: NodeInfo = serde_json::from_value(content)?; 20 | Self::format(self, &node_info, types) 21 | } 22 | } 23 | 24 | #[derive(Debug, Clone, Eq, PartialEq)] 25 | pub enum OutputFormat { 26 | Raw, 27 | Json, 28 | } 29 | 30 | impl TryFrom<&str> for OutputFormat { 31 | type Error = String; 32 | 33 | fn try_from(value: &str) -> Result { 34 | match value { 35 | "json" => Ok(OutputFormat::Json), 36 | "raw" => Ok(OutputFormat::Raw), 37 | _ => Err(format!("Invalid output format: {}!", value)), 38 | } 39 | } 40 | } 41 | 42 | impl OutputFormat { 43 | pub fn new_formatter(&self) -> Box { 44 | match self { 45 | OutputFormat::Json => Box::new(JsonFormatter {}), 46 | _ => Box::new(JsonFormatter {}), 47 | } 48 | } 49 | } 50 | -------------------------------------------------------------------------------- /Cargo.toml: -------------------------------------------------------------------------------- 1 | [package] 2 | name = "tuistash" 3 | description = "A Terminal User Interface for Logstash" 4 | homepage = "https://github.com/edmocosta/tuistash" 5 | repository = "https://github.com/edmocosta/tuistash" 6 | documentation = "https://github.com/edmocosta/tuistash" 7 | keywords = ["logstash", "tui", "cli", "terminal"] 8 | categories = ["command-line-utilities"] 9 | authors = ["Edmo Vamerlatti Costa "] 10 | version = "0.7.1" 11 | edition = "2021" 12 | 13 | [dependencies] 14 | clap = { version = "4.5.20", features = ["std", "derive", "help", "usage", "suggestions"], default-features = false } 15 | ureq = { version = "2.9.7", features = ["json", "tls"], default-features = false } 16 | serde = { version = "1.0.217", features = ["derive"], default-features = false } 17 | serde_json = { version = "1.0.135", default-features = false } 18 | base64 = { version = "0.22.1", features = ["std"], default-features = false } 19 | rustls = { version = "0.23.5", default-features = false } 20 | colored_json = { version = "5.0", default-features = false } 21 | humansize = { version = "2.1", default-features = false } 22 | humantime = { version = "2.1" } 23 | ratatui = { version = "0.29.0", features = ["crossterm", "unstable-rendered-line-info"] } 24 | crossterm = { version = "0.28.1", default-features = false, features = ["event-stream"] } 25 | human_format = { version = "1.1" } 26 | uuid = { version = "1.10.0", features = ["v4"] } 27 | time = { version = "0.3.36", features = ["default", "formatting", "local-offset", "parsing"] } 28 | regex = { version = "1.11.0", features = [] } 29 | 30 | [[bin]] 31 | name = "tuistash" 32 | path = "src/cli/main.rs" -------------------------------------------------------------------------------- /src/cli/api/hot_threads.rs: -------------------------------------------------------------------------------- 1 | use std::collections::HashMap; 2 | 3 | use serde::{Deserialize, Serialize}; 4 | 5 | #[derive(Default, Debug, Clone, Serialize, Deserialize)] 6 | #[serde(default)] 7 | pub struct NodeHotThreads { 8 | pub hot_threads: HotThreads, 9 | } 10 | 11 | #[derive(Default, Debug, Clone, Serialize, Deserialize)] 12 | #[serde(default)] 13 | pub struct HotThreads { 14 | pub time: String, 15 | pub busiest_threads: u64, 16 | #[serde(with = "threads")] 17 | pub threads: HashMap, 18 | } 19 | 20 | mod threads { 21 | use std::collections::HashMap; 22 | 23 | use serde::de::{Deserialize, Deserializer}; 24 | use serde::ser::Serializer; 25 | 26 | use super::Thread; 27 | 28 | pub fn serialize(map: &HashMap, serializer: S) -> Result 29 | where 30 | S: Serializer, 31 | { 32 | serializer.collect_seq(map.values()) 33 | } 34 | 35 | pub fn deserialize<'de, D>(deserializer: D) -> Result, D::Error> 36 | where 37 | D: Deserializer<'de>, 38 | { 39 | let vertices = Vec::::deserialize(deserializer)?; 40 | let mut map = HashMap::with_capacity(vertices.len()); 41 | 42 | for item in vertices { 43 | map.insert(item.thread_id, item); 44 | } 45 | 46 | Ok(map) 47 | } 48 | } 49 | 50 | #[derive(Default, Debug, Clone, Serialize, Deserialize, PartialEq)] 51 | #[serde(default)] 52 | pub struct Thread { 53 | pub name: String, 54 | pub thread_id: i64, 55 | pub percent_of_cpu_time: f64, 56 | pub state: String, 57 | pub traces: Vec, 58 | } 59 | -------------------------------------------------------------------------------- /src/cli/commands/node/json.rs: -------------------------------------------------------------------------------- 1 | use std::collections::HashSet; 2 | 3 | use colored_json::ColorMode; 4 | use serde_json::Value; 5 | 6 | use crate::api::node::{NodeInfo, NodeInfoType}; 7 | use crate::commands::node::output::ValueFormatter; 8 | use crate::errors::AnyError; 9 | 10 | pub(crate) struct JsonFormatter; 11 | 12 | impl ValueFormatter for JsonFormatter { 13 | fn format( 14 | &self, 15 | content: &NodeInfo, 16 | types: Option<&[NodeInfoType]>, 17 | ) -> Result { 18 | let value = serde_json::to_value(content)?; 19 | Self::format_value(self, value, types) 20 | } 21 | 22 | fn format_value( 23 | &self, 24 | content: Value, 25 | types: Option<&[NodeInfoType]>, 26 | ) -> Result { 27 | let formatted_content = match types { 28 | None => content, 29 | Some(values) => remove_unlisted_fields(content, values)?, 30 | }; 31 | 32 | Ok(colored_json::to_colored_json( 33 | &formatted_content, 34 | ColorMode::On, 35 | )?) 36 | } 37 | } 38 | 39 | pub(crate) fn remove_unlisted_fields( 40 | content: Value, 41 | types: &[NodeInfoType], 42 | ) -> Result { 43 | let mut inner_map = content.as_object().unwrap().to_owned(); 44 | let mut types_set: HashSet = HashSet::with_capacity(types.len()); 45 | 46 | types 47 | .iter() 48 | .map(|v| v.as_api_value().to_string()) 49 | .for_each(|value| { 50 | types_set.insert(value); 51 | }); 52 | 53 | if types_set.contains(NodeInfoType::All.as_api_value()) { 54 | return Ok(Value::Object(inner_map)); 55 | } 56 | 57 | let json_fields: Vec = inner_map.keys().map(|k| k.to_string()).collect(); 58 | for key in json_fields { 59 | if !types_set.contains(&key) { 60 | inner_map.remove(&key); 61 | } 62 | } 63 | 64 | Ok(Value::Object(inner_map)) 65 | } 66 | -------------------------------------------------------------------------------- /src/cli/api/tls.rs: -------------------------------------------------------------------------------- 1 | use std::sync::Arc; 2 | 3 | use rustls::client::danger::{HandshakeSignatureValid, ServerCertVerified, ServerCertVerifier}; 4 | use rustls::pki_types::{CertificateDer, ServerName, UnixTime}; 5 | use rustls::{DigitallySignedStruct, Error, SignatureScheme}; 6 | 7 | #[derive(Debug)] 8 | pub struct SkipServerVerification; 9 | 10 | impl SkipServerVerification { 11 | pub fn new() -> Arc { 12 | Arc::new(Self) 13 | } 14 | } 15 | 16 | impl ServerCertVerifier for SkipServerVerification { 17 | fn verify_server_cert( 18 | &self, 19 | _end_entity: &CertificateDer<'_>, 20 | _intermediates: &[CertificateDer<'_>], 21 | _server_name: &ServerName<'_>, 22 | _ocsp_response: &[u8], 23 | _now: UnixTime, 24 | ) -> Result { 25 | Ok(ServerCertVerified::assertion()) 26 | } 27 | 28 | fn verify_tls12_signature( 29 | &self, 30 | _message: &[u8], 31 | _cert: &CertificateDer<'_>, 32 | _dss: &DigitallySignedStruct, 33 | ) -> Result { 34 | Ok(HandshakeSignatureValid::assertion()) 35 | } 36 | 37 | fn verify_tls13_signature( 38 | &self, 39 | _message: &[u8], 40 | _cert: &CertificateDer<'_>, 41 | _dss: &DigitallySignedStruct, 42 | ) -> Result { 43 | Ok(HandshakeSignatureValid::assertion()) 44 | } 45 | 46 | fn supported_verify_schemes(&self) -> Vec { 47 | vec![ 48 | SignatureScheme::RSA_PKCS1_SHA1, 49 | SignatureScheme::ECDSA_SHA1_Legacy, 50 | SignatureScheme::RSA_PKCS1_SHA256, 51 | SignatureScheme::ECDSA_NISTP256_SHA256, 52 | SignatureScheme::RSA_PKCS1_SHA384, 53 | SignatureScheme::ECDSA_NISTP384_SHA384, 54 | SignatureScheme::RSA_PKCS1_SHA512, 55 | SignatureScheme::ECDSA_NISTP521_SHA512, 56 | SignatureScheme::RSA_PSS_SHA256, 57 | SignatureScheme::RSA_PSS_SHA384, 58 | SignatureScheme::RSA_PSS_SHA512, 59 | SignatureScheme::ED25519, 60 | SignatureScheme::ED448, 61 | ] 62 | } 63 | } 64 | -------------------------------------------------------------------------------- /src/cli/main.rs: -------------------------------------------------------------------------------- 1 | use crate::commands::Command; 2 | use std::panic; 3 | use std::process::exit; 4 | 5 | use crate::config::Config; 6 | use crate::errors::AnyError; 7 | use crate::output::Output; 8 | 9 | mod api; 10 | mod cli; 11 | mod commands; 12 | mod config; 13 | mod errors; 14 | mod output; 15 | 16 | type ExitCode = i32; 17 | 18 | fn run() -> Result { 19 | let cli = cli::build_cli(); 20 | let username = cli.username; 21 | let password = cli.password; 22 | let api = api::Client::new(cli.host, username, password, cli.skip_tls_verification)?; 23 | let config = Config { 24 | api, 25 | diagnostic_path: cli.diagnostic_path, 26 | }; 27 | 28 | let stdout = std::io::stdout(); 29 | let mut stdout_lock = stdout.lock(); 30 | let mut out = Output::new(&mut stdout_lock); 31 | 32 | match cli.command { 33 | Some(cmd) => { 34 | cmd.execute(&mut out, &config)?; 35 | } 36 | None => { 37 | Command::execute_default_command(&mut out, &config)?; 38 | } 39 | } 40 | 41 | Ok(0) 42 | } 43 | 44 | fn main() { 45 | setup_panic_hook(); 46 | 47 | let result = run(); 48 | match result { 49 | Err(err) => { 50 | println!("{}", err); 51 | exit(1); 52 | } 53 | Ok(exit_code) => { 54 | exit(exit_code); 55 | } 56 | } 57 | } 58 | 59 | fn setup_panic_hook() { 60 | panic::set_hook(Box::new(move |panic_info| { 61 | // Attempt to clear the terminal 62 | print!("{}[2J", 27 as char); 63 | 64 | let loc = if let Some(location) = panic_info.location() { 65 | format!(" in file {} at line {}", location.file(), location.line()) 66 | } else { 67 | String::new() 68 | }; 69 | 70 | let message = if let Some(value) = panic_info.payload().downcast_ref::<&str>() { 71 | value 72 | } else if let Some(value) = panic_info.payload().downcast_ref::() { 73 | value 74 | } else { 75 | "Payload not captured as it is not a string." 76 | }; 77 | 78 | println!("Panic occurred{} with the message: {}", loc, message); 79 | 80 | exit(-1) 81 | })); 82 | } 83 | -------------------------------------------------------------------------------- /CHANGELOG.md: -------------------------------------------------------------------------------- 1 | ## 0.7.1 2 | - Fixed high CPU usage when polling data from a Logstash diagnostic path (`--diagnostic-path`). 3 | 4 | ## 0.7.0 5 | - Logstash APIs are now fetched concurrently, improving the overall performance, and reducing UI lags when pipelines are too complex or contains several components. [#12](https://github.com/edmocosta/tuistash/pull/12) 6 | 7 | ## 0.6.0 8 | - Changed the dropped events percentage color from yellow to dark gray for the `logstash-filter-drop` plugin. 9 | - The `logstash-output-elasticsearch` plugin details now shows the bulk requests failures. 10 | - Added the "Top upstream producers" list on the `pipeline` input plugin details. 11 | - Minor UI improvements. 12 | - Bumped dependencies versions. 13 | 14 | ## 0.5.0 15 | - Added support for vertices and edges files diagnostics. 16 | - The thread name is now displayed on the traces details block. 17 | - **BREAKING**: Removed the `default` and `table` output options from the `node` command. 18 | - Bumped dependencies versions. 19 | 20 | ## 0.4.0 21 | - Introduced a new view `Threads`, which relies on the Logstash's hot-threads API to display the busiest threads and their traces. 22 | - Added the `User-Agent` header to requests so the source can be identified. 23 | - Minor UI fixes. 24 | 25 | ## 0.3.0 26 | - Bumped a few dependencies. 27 | - Added a command option (`diagnostic-path`) to poll the data from a Logstash diagnostic path. 28 | - Improved compatibility with older Logstash versions (7.x), which graph API is not supported. 29 | - The pipeline components view now shows the plugin's pipeline usage and the dropped events percentages. 30 | - Added a few plugin's extra details on the pipeline view. 31 | 32 | ## 0.2.0 33 | - Added a new `flows` view built on top of the latest Logstash flow metrics. 34 | - **BREAKING**: Renamed the `view` command to `tui`. 35 | - Changed to execute the `tui` command by default when no specific command is supplied. 36 | - Migrated from `tui` to `ratatui` and bumped a few dependencies. 37 | - Changed `pipelines` charts to continuously aggregate data, even if the chart isn't being displayed. 38 | - Added `worker millis per event` chart on the pipeline's plugin details. 39 | - Reorganized TUI shortcuts and other design changes. 40 | - Added license file 41 | -------------------------------------------------------------------------------- /src/cli/commands/formatter.rs: -------------------------------------------------------------------------------- 1 | use std::time::Duration; 2 | 3 | pub trait DurationFormatter { 4 | fn format_duration(&self) -> String; 5 | fn format_duration_per_event(&self, events_count: u64) -> String; 6 | } 7 | 8 | impl DurationFormatter for u64 { 9 | fn format_duration(&self) -> String { 10 | if *self == 0 { 11 | return "-".to_string(); 12 | } 13 | 14 | let secs = *self / 1000; 15 | let duration = if secs > 0 { 16 | Duration::from_secs(secs) 17 | } else { 18 | Duration::from_millis(*self) 19 | }; 20 | 21 | humantime::format_duration(duration).to_string() 22 | } 23 | 24 | fn format_duration_per_event(&self, events_count: u64) -> String { 25 | if *self == 0 || events_count == 0 { 26 | return "-".to_string(); 27 | } 28 | 29 | let duration = *self as f64 / events_count as f64; 30 | human_format::Formatter::new() 31 | .format(duration) 32 | .trim() 33 | .to_string() 34 | } 35 | } 36 | 37 | pub trait NumberFormatter { 38 | fn format_number(&self) -> String { 39 | self.format_number_with_decimals(3) 40 | } 41 | 42 | fn format_number_with_decimals(&self, decimals: usize) -> String; 43 | 44 | fn strip_number_decimals(&self, decimals: usize) -> String; 45 | } 46 | 47 | impl NumberFormatter for i64 { 48 | fn format_number(&self) -> String { 49 | let decimals = if *self < 1000 { 0 } else { 3 }; 50 | self.format_number_with_decimals(decimals) 51 | } 52 | 53 | fn format_number_with_decimals(&self, decimals: usize) -> String { 54 | match self { 55 | 0 => "0".into(), 56 | _ => human_format::Formatter::new() 57 | .with_decimals(decimals) 58 | .format(*self as f64), 59 | } 60 | } 61 | 62 | fn strip_number_decimals(&self, _decimals: usize) -> String { 63 | self.to_string() 64 | } 65 | } 66 | 67 | impl NumberFormatter for f64 { 68 | fn format_number_with_decimals(&self, decimals: usize) -> String { 69 | match self { 70 | &f64::INFINITY => "Inf ".into(), 71 | &f64::NEG_INFINITY => "-Inf ".into(), 72 | 0.0 => "0".into(), 73 | _ => human_format::Formatter::new() 74 | .with_decimals(decimals) 75 | .format(*self), 76 | } 77 | } 78 | 79 | fn strip_number_decimals(&self, decimals: usize) -> String { 80 | format!("{:.1$}", self, decimals) 81 | } 82 | } 83 | -------------------------------------------------------------------------------- /src/cli/commands/node/command.rs: -------------------------------------------------------------------------------- 1 | use clap::Args; 2 | 3 | use crate::api::node::NodeInfoType; 4 | use crate::commands::node::output::OutputFormat; 5 | use crate::commands::traits::RunnableCommand; 6 | use crate::config::Config; 7 | use crate::errors::{AnyError, TuiError}; 8 | use crate::output::Output; 9 | 10 | #[derive(Args)] 11 | pub struct NodeArgs { 12 | /// Valid values are 'node', 'os', 'jvm', 'pipelines' separated by comma 13 | #[arg()] 14 | pub types: Option, 15 | 16 | /// Valid values are 'json', 'raw' 17 | #[arg(short)] 18 | pub output: Option, 19 | } 20 | 21 | pub struct NodeCommand; 22 | 23 | impl RunnableCommand for NodeCommand { 24 | fn run(&self, out: &mut Output, args: &NodeArgs, config: &Config) -> Result<(), AnyError> { 25 | let output_format = match &args.output { 26 | None => OutputFormat::Json, 27 | Some(value) => OutputFormat::try_from(value.as_ref())?, 28 | }; 29 | 30 | if config.diagnostic_path.is_some() { 31 | return Err(TuiError::from( 32 | "The --diagnostic-path argument is not supported by the get command", 33 | ) 34 | .into()); 35 | } 36 | 37 | let info_types = &NodeCommand::parse_info_types(&args.types)?; 38 | if output_format == OutputFormat::Raw { 39 | let raw = config.api.get_node_info_as_string(info_types, None)?; 40 | NodeCommand::write(out, raw.as_bytes())?; 41 | } else { 42 | let node_info = config.api.get_node_info_as_value(info_types, None)?; 43 | NodeCommand::write( 44 | out, 45 | output_format 46 | .new_formatter() 47 | .format_value(node_info, Some(info_types))? 48 | .as_bytes(), 49 | )?; 50 | } 51 | 52 | Ok(()) 53 | } 54 | } 55 | 56 | impl NodeCommand { 57 | fn write(out: &mut Output, buf: &[u8]) -> Result<(), AnyError> { 58 | out.handle.write_all(buf)?; 59 | out.handle.write_all(b"\n")?; 60 | Ok(()) 61 | } 62 | 63 | fn parse_info_types(types: &Option) -> Result, AnyError> { 64 | match types { 65 | None => Ok(vec![NodeInfoType::All]), 66 | Some(values) => { 67 | let parts = values.trim().split(','); 68 | let mut result: Vec = Vec::with_capacity(values.len()); 69 | for info_type in parts { 70 | result.push(NodeInfoType::try_from(info_type)?); 71 | } 72 | 73 | Ok(result) 74 | } 75 | } 76 | } 77 | } 78 | -------------------------------------------------------------------------------- /src/cli/api/node_api.rs: -------------------------------------------------------------------------------- 1 | use crate::api::hot_threads::NodeHotThreads; 2 | use crate::api::node::{NodeInfo, NodeInfoType}; 3 | use crate::api::stats::NodeStats; 4 | use crate::api::Client; 5 | use crate::errors::AnyError; 6 | use serde_json::Value; 7 | 8 | impl Client { 9 | pub const QUERY_NODE_INFO_GRAPH: &'static [(&'static str, &'static str)] = &[("graph", "true")]; 10 | pub const QUERY_NODE_STATS_VERTICES: &'static [(&'static str, &'static str)] = 11 | &[("vertices", "true")]; 12 | 13 | pub fn get_node_info_as_string( 14 | &self, 15 | types: &[NodeInfoType], 16 | query: Option<&[(&str, &str)]>, 17 | ) -> Result { 18 | let response = self.request("GET", &self.node_info_request_path(types), query)?; 19 | Ok(response.into_string()?) 20 | } 21 | 22 | pub fn get_node_info_as_value( 23 | &self, 24 | types: &[NodeInfoType], 25 | query: Option<&[(&str, &str)]>, 26 | ) -> Result { 27 | let response = self.request("GET", &self.node_info_request_path(types), query)?; 28 | let value: Value = response.into_json()?; 29 | Ok(value) 30 | } 31 | 32 | pub fn get_node_info( 33 | &self, 34 | types: &[NodeInfoType], 35 | query: Option<&[(&str, &str)]>, 36 | ) -> Result { 37 | let response = self.request("GET", &self.node_info_request_path(types), query)?; 38 | let node_info: NodeInfo = response.into_json()?; 39 | Ok(node_info) 40 | } 41 | 42 | pub fn get_node_stats(&self, query: Option<&[(&str, &str)]>) -> Result { 43 | let response = self.request("GET", &self.node_request_path("stats"), query)?; 44 | let node_stats: NodeStats = response.into_json()?; 45 | Ok(node_stats) 46 | } 47 | 48 | pub fn get_hot_threads( 49 | &self, 50 | query: Option<&[(&str, &str)]>, 51 | ) -> Result { 52 | let response = self.request("GET", &self.node_request_path("hot_threads"), query)?; 53 | let hot_threads: NodeHotThreads = response.into_json()?; 54 | Ok(hot_threads) 55 | } 56 | 57 | fn node_info_request_path(&self, types: &[NodeInfoType]) -> String { 58 | let filterable_types = types 59 | .iter() 60 | .filter(|info_type| **info_type != NodeInfoType::All) 61 | .map(|info_type| info_type.as_api_value()) 62 | .collect::>(); 63 | 64 | if filterable_types.len() != types.len() { 65 | return self.node_request_path(NodeInfoType::All.as_api_value()); 66 | } 67 | 68 | let filters = filterable_types.join(","); 69 | self.node_request_path(&filters) 70 | } 71 | 72 | fn node_request_path(&self, request_path: &str) -> String { 73 | if request_path.is_empty() { 74 | return "_node".to_string(); 75 | } 76 | 77 | format!("{}/{}", "_node", request_path) 78 | } 79 | } 80 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Tuistash 2 | 3 | A Terminal User Interface (TUI) for Logstash 🪵 4 | 5 | ![demo](docs/img/demo.gif) 6 | 7 | ## Installation 8 | 9 | ### Arch Linux 10 | [tuistash](https://aur.archlinux.org/packages/tuistash) is available as a package in the AUR. 11 | You can install it using an AUR helper (e.g. `paru`): 12 | ```shell 13 | paru -S tuistash 14 | ``` 15 | 16 | ### Homebrew 17 | ```shell 18 | brew tap edmocosta/homebrew-tap 19 | ``` 20 | 21 | ```shell 22 | brew install tuistash 23 | ``` 24 | 25 | ### Manual 26 | The compiled versions can be downloaded from the [GitHub releases page](https://github.com/edmocosta/tuistash/releases). 27 | If a version for your operating system isn't available, you can build it from the source by following these steps: 28 | 29 | 1 - Install Rust and Cargo (Linux and macOS): 30 | ```shell 31 | curl https://sh.rustup.rs -sSf | sh 32 | ``` 33 | 34 | 2 - Clone the repository: 35 | ```shell 36 | git clone https://github.com/edmocosta/tuistash.git 37 | ``` 38 | 39 | 3 - Build the binary (`target/release/tuistash`) 40 | ```shell 41 | cd tuistash 42 | ``` 43 | 44 | ```shell 45 | cargo build --release 46 | ``` 47 | 48 | ## Usage 49 | 50 | The Logstash's [monitoring API](https://www.elastic.co/guide/en/logstash/current/monitoring-logstash.html) must be enabled 51 | and accessible from the client machine, unless the data is being read from a Logstash diagnostic path. 52 | 53 | By default, the data is polled every 1 second. If you have very complex pipelines, increasing the refresh interval (`--interval`) might 54 | improve the UI performance. 55 | 56 | ```shell 57 | $ ./tuistash --help 58 | ``` 59 | 60 | ```shell 61 | Usage: tuistash [OPTIONS] [COMMAND] 62 | 63 | Commands: 64 | get Query data from the Logstash API 65 | tui Logstash TUI 66 | help Print this message or the help of the given subcommand(s) 67 | 68 | Options: 69 | --host [default: http://localhost:9600] 70 | --username 71 | --password 72 | --skip-tls-verification 73 | -p, --diagnostic-path Read the data from a Logstash diagnostic path 74 | -h, --help Print help 75 | -V, --version Print version 76 | 77 | ``` 78 | 79 | ### TUI 80 | 81 | ```shell 82 | ./tuistash 83 | ``` 84 | 85 | ```shell 86 | ./tuistash tui --help 87 | ``` 88 | 89 | ```shell 90 | Logstash TUI 91 | 92 | Usage: tuistash tui [OPTIONS] 93 | 94 | Options: 95 | -i, --interval Refresh interval in seconds [default: 1] 96 | ``` 97 | 98 | ### Other commands 99 | 100 | #### GET 101 | 102 | ```shell 103 | ./tuistash get node --help 104 | ``` 105 | 106 | ```shell 107 | Prints the current node information 108 | 109 | Usage: tuistash get node [OPTIONS] [TYPES] 110 | 111 | Arguments: 112 | [TYPES] Valid values are 'node', 'os', 'jvm', 'pipelines' separated by comma 113 | 114 | Options: 115 | -o Valid values are 'json', 'raw' 116 | ``` 117 | 118 | Examples: 119 | 120 | ```shell 121 | ./tuistash get node pipelines,os 122 | ``` 123 | 124 | ```shell 125 | ./tuistash get node jvm -o raw 126 | ``` 127 | -------------------------------------------------------------------------------- /src/cli/commands/tui/widgets.rs: -------------------------------------------------------------------------------- 1 | use ratatui::prelude::{Color, Modifier, Style}; 2 | use ratatui::widgets::TableState; 3 | 4 | pub(crate) const TABLE_HEADER_CELL_STYLE: Style = 5 | Style::new().fg(Color::Gray).add_modifier(Modifier::BOLD); 6 | pub(crate) const TABLE_HEADER_ROW_STYLE: Style = Style::new().bg(Color::DarkGray); 7 | pub(crate) const TABLE_SELECTED_ROW_STYLE: Style = Style::new().bg(Color::Gray); 8 | pub(crate) const TABLE_SELECTED_ROW_SYMBOL: &str = "▍"; 9 | 10 | // Tabs 11 | pub struct TabsState { 12 | pub index: usize, 13 | } 14 | 15 | impl TabsState { 16 | pub fn with_default_index(index: usize) -> Self { 17 | TabsState { index } 18 | } 19 | 20 | pub fn new() -> Self { 21 | TabsState { index: 0 } 22 | } 23 | 24 | pub fn select(&mut self, index: usize) { 25 | self.index = index; 26 | } 27 | } 28 | 29 | // Table 30 | pub struct StatefulTable { 31 | pub state: TableState, 32 | pub items: Vec, 33 | } 34 | 35 | impl StatefulTable { 36 | pub fn new() -> Self { 37 | StatefulTable { 38 | state: TableState::default(), 39 | items: Vec::new(), 40 | } 41 | } 42 | 43 | pub fn selected_item(&self) -> Option<&T> { 44 | match self.state.selected() { 45 | None => None, 46 | Some(index) => Some(&self.items[index]), 47 | } 48 | } 49 | 50 | pub fn select(&mut self, index: Option) { 51 | self.state.select(index); 52 | } 53 | 54 | pub fn unselect(&mut self) { 55 | self.state.select(None); 56 | } 57 | 58 | pub fn has_next(&mut self) -> bool { 59 | if self.items.is_empty() { 60 | return false; 61 | } 62 | 63 | match self.state.selected() { 64 | Some(i) => i < self.items.len() - 1, 65 | None => true, 66 | } 67 | } 68 | 69 | pub fn next(&mut self) -> Option<&T> { 70 | if self.items.is_empty() { 71 | return None; 72 | } 73 | 74 | let i = match self.state.selected() { 75 | Some(i) => { 76 | if i >= self.items.len() - 1 { 77 | 0 78 | } else { 79 | i + 1 80 | } 81 | } 82 | None => 0, 83 | }; 84 | 85 | self.state.select(Some(i)); 86 | self.selected_item() 87 | } 88 | 89 | pub fn has_previous(&mut self) -> bool { 90 | if self.items.is_empty() { 91 | return false; 92 | } 93 | 94 | match self.state.selected() { 95 | Some(i) => i > 0, 96 | None => false, 97 | } 98 | } 99 | 100 | pub fn previous(&mut self) -> Option<&T> { 101 | if self.items.is_empty() { 102 | return None; 103 | } 104 | 105 | let i = match self.state.selected() { 106 | Some(i) => { 107 | if i == 0 { 108 | self.items.len() - 1 109 | } else { 110 | i - 1 111 | } 112 | } 113 | None => self.items.len() - 1, 114 | }; 115 | 116 | self.state.select(Some(i)); 117 | self.selected_item() 118 | } 119 | } 120 | -------------------------------------------------------------------------------- /src/cli/api/mod.rs: -------------------------------------------------------------------------------- 1 | use std::io::Write; 2 | use std::sync::Arc; 3 | 4 | use base64::prelude::BASE64_STANDARD; 5 | use base64::write::EncoderWriter; 6 | use ureq::{Agent, AgentBuilder, Response}; 7 | 8 | use crate::api::tls::SkipServerVerification; 9 | use crate::errors::AnyError; 10 | 11 | pub mod hot_threads; 12 | pub mod node; 13 | pub mod node_api; 14 | pub mod stats; 15 | mod tls; 16 | 17 | #[derive(Debug, Clone)] 18 | pub struct Client { 19 | client: Agent, 20 | config: ClientConfig, 21 | } 22 | 23 | #[derive(Debug, Clone)] 24 | pub struct ClientConfig { 25 | base_url: String, 26 | username: Option, 27 | password: Option, 28 | } 29 | 30 | impl ClientConfig { 31 | pub fn basic_auth_header(&self) -> String { 32 | let mut buf = b"Basic ".to_vec(); 33 | { 34 | let mut encoder = EncoderWriter::new(&mut buf, &BASE64_STANDARD); 35 | let _ = write!( 36 | encoder, 37 | "{}:", 38 | &self.username.as_ref().unwrap_or(&"".to_string()) 39 | ); 40 | if let Some(password) = &self.password { 41 | let _ = write!(encoder, "{}", password); 42 | } 43 | } 44 | 45 | String::from_utf8(buf).unwrap() 46 | } 47 | } 48 | 49 | impl Client { 50 | pub fn new( 51 | host: String, 52 | username: Option, 53 | password: Option, 54 | skip_tls_verification: bool, 55 | ) -> Result { 56 | let user_agent = "tuistash"; 57 | 58 | let agent_builder: AgentBuilder = if skip_tls_verification { 59 | let tls_config = rustls::ClientConfig::builder() 60 | .dangerous() 61 | .with_custom_certificate_verifier(SkipServerVerification::new()) 62 | .with_no_client_auth(); 63 | AgentBuilder::new() 64 | .user_agent(user_agent) 65 | .tls_config(Arc::new(tls_config)) 66 | } else { 67 | AgentBuilder::new().user_agent(user_agent) 68 | } 69 | .user_agent(format!("tuistash/{}", env!("CARGO_PKG_VERSION")).as_str()); 70 | 71 | Ok(Self { 72 | client: agent_builder.build(), 73 | config: ClientConfig { 74 | base_url: host.to_string(), 75 | username, 76 | password, 77 | }, 78 | }) 79 | } 80 | 81 | pub fn request( 82 | &self, 83 | method: &str, 84 | request_path: &str, 85 | query: Option<&[(&str, &str)]>, 86 | ) -> Result { 87 | let path = format!("{}/{}", self.config.base_url, request_path); 88 | let mut request = self.client.request(method, &path); 89 | 90 | if let Some(pairs) = query { 91 | for pair in pairs { 92 | request = request.query(pair.0, pair.1); 93 | } 94 | } 95 | 96 | if self.config.username.is_some() { 97 | request = request.set("authorization", &self.config.basic_auth_header()) 98 | } 99 | 100 | let result = request.call()?; 101 | Ok(result) 102 | } 103 | 104 | pub fn base_url(&self) -> &str { 105 | &self.config.base_url 106 | } 107 | } 108 | -------------------------------------------------------------------------------- /src/cli/commands/tui/backend.rs: -------------------------------------------------------------------------------- 1 | use crate::commands::tui::app::App; 2 | use crate::commands::tui::data_fetcher::{ApiDataFetcher, PathDataFetcher}; 3 | use crate::commands::tui::ui; 4 | use crate::config::Config; 5 | use crate::errors::AnyError; 6 | use crossterm::{ 7 | event::{self, DisableMouseCapture, EnableMouseCapture, Event, KeyCode}, 8 | execute, 9 | terminal::{disable_raw_mode, enable_raw_mode, EnterAlternateScreen, LeaveAlternateScreen}, 10 | }; 11 | use ratatui::{ 12 | backend::{Backend, CrosstermBackend}, 13 | Terminal, 14 | }; 15 | use std::ops::Add; 16 | use std::{ 17 | io, 18 | time::{Duration, Instant}, 19 | }; 20 | 21 | const APP_TITLE: &str = "Logstash"; 22 | 23 | pub fn run(interval: Duration, config: &Config) -> Result<(), AnyError> { 24 | enable_raw_mode()?; 25 | 26 | let mut stdout = io::stdout(); 27 | execute!(stdout, EnterAlternateScreen, EnableMouseCapture)?; 28 | 29 | let backend = CrosstermBackend::new(stdout); 30 | let mut terminal = Terminal::new(backend)?; 31 | terminal.clear()?; 32 | 33 | if let Some(path) = &config.diagnostic_path { 34 | let path = path.to_string(); 35 | match PathDataFetcher::new(path.to_string()) { 36 | Ok(file_data_fetcher) => { 37 | let mut app = App::new(APP_TITLE.to_string(), path, None); 38 | app.set_data(&file_data_fetcher); 39 | run_app(&mut terminal, app)?; 40 | } 41 | Err(err) => { 42 | return Err(err); 43 | } 44 | }; 45 | } else { 46 | let app = App::new( 47 | APP_TITLE.to_string(), 48 | config.api.base_url().to_string(), 49 | Some(interval), 50 | ); 51 | 52 | let fetcher = ApiDataFetcher::new(config.api.clone()); 53 | fetcher.start_polling(interval); 54 | 55 | app.start_reading_data(Box::new(fetcher), interval); 56 | run_app(&mut terminal, app)?; 57 | }; 58 | 59 | disable_raw_mode()?; 60 | 61 | execute!( 62 | terminal.backend_mut(), 63 | LeaveAlternateScreen, 64 | DisableMouseCapture 65 | )?; 66 | 67 | terminal.show_cursor()?; 68 | Ok(()) 69 | } 70 | 71 | fn run_app(terminal: &mut Terminal, mut app: App) -> io::Result<()> { 72 | app.wait_node_data(); 73 | app.on_tick(); 74 | 75 | let mut last_tick = Instant::now(); 76 | loop { 77 | terminal.draw(|f| ui::draw(f, &mut app))?; 78 | 79 | let tick_interval = app 80 | .sampling_interval 81 | .unwrap_or(Duration::from_secs(1)) 82 | .add(Duration::from_millis(300)); 83 | 84 | let timeout = tick_interval 85 | .checked_sub(last_tick.elapsed()) 86 | .unwrap_or_else(|| Duration::from_secs(0)); 87 | 88 | if event::poll(timeout)? { 89 | if let Event::Key(key) = event::read()? { 90 | match key.code { 91 | KeyCode::Esc => app.on_esc(), 92 | _ => app.handle_key_event(key), 93 | } 94 | } 95 | } 96 | 97 | if app.sampling_interval.is_some() && last_tick.elapsed() >= tick_interval { 98 | app.on_tick(); 99 | } 100 | if app.should_quit { 101 | return Ok(()); 102 | } 103 | 104 | last_tick = Instant::now(); 105 | } 106 | } 107 | -------------------------------------------------------------------------------- /.github/workflows/cd.yml: -------------------------------------------------------------------------------- 1 | name: Release 2 | on: 3 | push: 4 | tags: 5 | - "v*.*.*" 6 | jobs: 7 | build-release: 8 | runs-on: ${{ matrix.os }} 9 | strategy: 10 | fail-fast: false 11 | matrix: 12 | include: 13 | # Linux 14 | - os: ubuntu-latest 15 | target: x86_64-unknown-linux-musl 16 | archive: tar.gz 17 | archive-cmd: tar czf 18 | sha-cmd: sha256sum 19 | - os: ubuntu-latest 20 | target: aarch64-unknown-linux-musl 21 | archive: tar.gz 22 | archive-cmd: tar czf 23 | sha-cmd: sha256sum 24 | - os: ubuntu-latest 25 | target: arm-unknown-linux-musleabihf 26 | archive: tar.gz 27 | archive-cmd: tar czf 28 | sha-cmd: sha256sum 29 | - os: ubuntu-latest 30 | target: loongarch64-unknown-linux-gnu 31 | archive: tar.gz 32 | archive-cmd: tar czf 33 | sha-cmd: sha256sum 34 | 35 | # MacOS 36 | - os: macos-latest 37 | target: x86_64-apple-darwin 38 | archive: zip 39 | archive-cmd: zip -r 40 | sha-cmd: shasum -a 256 41 | - os: macos-latest 42 | target: aarch64-apple-darwin 43 | archive: zip 44 | archive-cmd: zip -r 45 | sha-cmd: shasum -a 256 46 | 47 | # Windows 48 | - os: windows-latest 49 | target: x86_64-pc-windows-msvc 50 | archive: zip 51 | archive-cmd: 7z a 52 | sha-cmd: sha256sum 53 | 54 | steps: 55 | - name: Checkout repo 56 | uses: actions/checkout@v4 57 | 58 | - name: Installing Rust toolchain 59 | uses: dtolnay/rust-toolchain@master 60 | with: 61 | target: ${{ matrix.target }} 62 | toolchain: stable 63 | 64 | - uses: Swatinem/rust-cache@v2 65 | 66 | - name: Build for Linux 67 | if: matrix.os == 'ubuntu-latest' 68 | run: | 69 | cargo install cross --git https://github.com/cross-rs/cross 70 | cross build --release --target ${{ matrix.target }} 71 | 72 | - name: Build for Darwin and Windows 73 | if: matrix.os == 'macos-latest' || matrix.os == 'windows-latest' 74 | run: cargo build --release --target ${{ matrix.target }} 75 | 76 | - name: Packaging final binaries 77 | shell: bash 78 | run: | 79 | src=$(pwd) 80 | stage=$(mktemp -d) 81 | ver=${GITHUB_REF#refs/tags/} 82 | asset_name="tuistash-$ver-${{ matrix.target }}.${{ matrix.archive }}" 83 | ASSET_PATH="$src/$asset_name" 84 | CHECKSUM_PATH="$ASSET_PATH.sha256" 85 | cp target/${{ matrix.target }}/release/tuistash $stage/ 86 | cd $stage 87 | ${{ matrix.archive-cmd }} $ASSET_PATH * 88 | cd $src 89 | ${{ matrix.sha-cmd }} $asset_name > $CHECKSUM_PATH 90 | if [ "$RUNNER_OS" == "Windows" ]; then 91 | echo "ASSET_PATH=$(cygpath -m $ASSET_PATH)" >> $GITHUB_ENV 92 | echo "CHECKSUM_PATH=$(cygpath -m $CHECKSUM_PATH)" >> $GITHUB_ENV 93 | else 94 | echo "ASSET_PATH=$ASSET_PATH" >> $GITHUB_ENV 95 | echo "CHECKSUM_PATH=$CHECKSUM_PATH" >> $GITHUB_ENV 96 | fi 97 | 98 | - name: Releasing assets 99 | uses: softprops/action-gh-release@v1 100 | if: startsWith(github.ref, 'refs/tags/') 101 | with: 102 | fail_on_unmatched_files: true 103 | files: | 104 | ${{ env.ASSET_PATH }} 105 | ${{ env.CHECKSUM_PATH }} 106 | env: 107 | GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} 108 | -------------------------------------------------------------------------------- /src/cli/api/node.rs: -------------------------------------------------------------------------------- 1 | use serde::{Deserialize, Serialize}; 2 | use std::collections::HashMap; 3 | use std::fmt; 4 | 5 | #[derive(Default, Debug, Clone, Serialize, Deserialize)] 6 | #[serde(default)] 7 | pub struct Node { 8 | pub host: String, 9 | pub version: String, 10 | pub http_address: String, 11 | pub id: String, 12 | pub name: String, 13 | pub ephemeral_id: String, 14 | pub status: String, 15 | pub pipeline: PipelineSettings, 16 | } 17 | 18 | #[derive(Default, Debug, Clone, Serialize, Deserialize)] 19 | #[serde(default)] 20 | pub struct NodeInfo { 21 | #[serde(flatten)] 22 | pub node: Node, 23 | pub pipelines: Option>, 24 | pub os: Option, 25 | pub jvm: Option, 26 | } 27 | 28 | #[derive(Default, Debug, Clone, PartialEq, Serialize, Deserialize)] 29 | #[serde(default)] 30 | pub struct Graph { 31 | pub graph: GraphDefinition, 32 | } 33 | 34 | #[derive(Default, Debug, Clone, PartialEq, Serialize, Deserialize)] 35 | #[serde(default)] 36 | pub struct GraphDefinition { 37 | pub vertices: Vec, 38 | pub edges: Vec, 39 | } 40 | 41 | #[derive(Default, Debug, Clone, PartialEq, Serialize, Deserialize)] 42 | #[serde(default)] 43 | pub struct Edge { 44 | pub id: String, 45 | pub from: String, 46 | pub to: String, 47 | pub r#type: String, 48 | pub when: Option, 49 | } 50 | 51 | #[derive(Default, Debug, Clone, Serialize, Deserialize)] 52 | #[serde(default)] 53 | pub struct Vertex { 54 | pub id: String, 55 | pub explicit_id: bool, 56 | pub config_name: String, 57 | pub plugin_type: String, 58 | pub condition: String, 59 | pub r#type: String, 60 | pub meta: Option, 61 | } 62 | 63 | impl PartialEq for Vertex { 64 | fn eq(&self, other: &Self) -> bool { 65 | self.id.eq(&other.id) 66 | } 67 | } 68 | 69 | #[derive(Default, Debug, Clone, Serialize, Deserialize)] 70 | #[serde(default)] 71 | pub struct VertexMeta { 72 | pub source: VertexMetaSource, 73 | } 74 | 75 | #[derive(Default, Debug, Clone, Serialize, Deserialize)] 76 | #[serde(default)] 77 | pub struct VertexMetaSource { 78 | pub protocol: String, 79 | pub id: String, 80 | pub line: u32, 81 | pub column: u32, 82 | } 83 | 84 | #[derive(Default, Debug, Clone, PartialEq, Serialize, Deserialize)] 85 | #[serde(default)] 86 | pub struct PipelineInfo { 87 | pub ephemeral_id: String, 88 | pub workers: i64, 89 | pub batch_size: i64, 90 | pub batch_delay: i64, 91 | pub config_reload_automatic: bool, 92 | pub config_reload_interval: i64, 93 | pub dead_letter_queue_enabled: bool, 94 | pub graph: Graph, 95 | } 96 | 97 | #[derive(Default, Debug, Clone, PartialEq, Serialize, Deserialize)] 98 | #[serde(default)] 99 | pub struct Os { 100 | pub name: String, 101 | pub arch: String, 102 | pub version: String, 103 | pub available_processors: i64, 104 | } 105 | 106 | #[derive(Default, Debug, Clone, PartialEq, Serialize, Deserialize)] 107 | #[serde(default)] 108 | pub struct Jvm { 109 | pub pid: i64, 110 | pub version: String, 111 | pub vm_version: String, 112 | pub vm_vendor: String, 113 | pub vm_name: String, 114 | pub start_time_in_millis: i64, 115 | pub mem: JvmMem, 116 | pub gc_collectors: Vec, 117 | } 118 | 119 | #[derive(Default, Debug, Clone, PartialEq, Serialize, Deserialize)] 120 | #[serde(default)] 121 | pub struct JvmMem { 122 | pub heap_init_in_bytes: i64, 123 | pub heap_max_in_bytes: i64, 124 | pub non_heap_init_in_bytes: i64, 125 | pub non_heap_max_in_bytes: i64, 126 | } 127 | 128 | #[derive(Default, Debug, Clone, PartialEq, Serialize, Deserialize)] 129 | #[serde(default)] 130 | pub struct PipelineSettings { 131 | pub workers: i64, 132 | pub batch_size: i64, 133 | pub batch_delay: i64, 134 | } 135 | 136 | #[derive(PartialEq, Eq, Debug, Clone)] 137 | pub enum NodeInfoType { 138 | All, 139 | Pipelines, 140 | Os, 141 | Jvm, 142 | } 143 | 144 | impl fmt::Display for NodeInfoType { 145 | fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result { 146 | write!(f, "{:?}", self) 147 | } 148 | } 149 | 150 | impl NodeInfoType { 151 | pub(crate) fn as_api_value(&self) -> &'static str { 152 | match self { 153 | NodeInfoType::Pipelines => "pipelines", 154 | NodeInfoType::Os => "os", 155 | NodeInfoType::Jvm => "jvm", 156 | NodeInfoType::All => "", 157 | } 158 | } 159 | } 160 | 161 | impl TryFrom<&str> for NodeInfoType { 162 | type Error = String; 163 | 164 | fn try_from(value: &str) -> Result { 165 | let clean_value = value.to_lowercase().trim().to_string(); 166 | 167 | match clean_value.as_str() { 168 | "pipelines" => Ok(NodeInfoType::Pipelines), 169 | "os" => Ok(NodeInfoType::Os), 170 | "jvm" => Ok(NodeInfoType::Jvm), 171 | "" => Ok(NodeInfoType::All), 172 | _ => Err(format!("Invalid info type: {}!", clean_value)), 173 | } 174 | } 175 | } 176 | -------------------------------------------------------------------------------- /src/cli/commands/tui/node/state.rs: -------------------------------------------------------------------------------- 1 | use crate::api::stats::NodeStats; 2 | use crate::commands::tui::app::AppData; 3 | use crate::commands::tui::charts::{ChartDataPoint, TimestampChartState, DEFAULT_MAX_DATA_POINTS}; 4 | use crate::commands::tui::events::EventsListener; 5 | use crate::commands::tui::flow_charts::{FlowMetricDataPoint, PluginFlowMetricDataPoint}; 6 | use crate::commands::tui::now_local_unix_timestamp; 7 | use crossterm::event::KeyEvent; 8 | 9 | pub struct ProcessCpuDataPoint { 10 | pub timestamp: i64, 11 | pub percent: i64, 12 | } 13 | 14 | impl ProcessCpuDataPoint { 15 | pub fn new(percent: i64) -> Self { 16 | ProcessCpuDataPoint { 17 | timestamp: now_local_unix_timestamp(), 18 | percent, 19 | } 20 | } 21 | } 22 | 23 | impl ChartDataPoint for ProcessCpuDataPoint { 24 | fn y_axis_bounds(&self) -> [f64; 2] { 25 | [self.percent as f64, self.percent as f64] 26 | } 27 | 28 | fn x_axis_bounds(&self) -> [f64; 2] { 29 | [self.timestamp as f64, self.timestamp as f64] 30 | } 31 | } 32 | pub struct JvmMemNonHeapDataPoint { 33 | pub timestamp: i64, 34 | pub non_heap_committed_in_bytes: i64, 35 | pub non_heap_used_in_bytes: i64, 36 | } 37 | 38 | impl ChartDataPoint for JvmMemNonHeapDataPoint { 39 | fn y_axis_bounds(&self) -> [f64; 2] { 40 | [ 41 | f64::min( 42 | self.non_heap_used_in_bytes as f64, 43 | self.non_heap_committed_in_bytes as f64, 44 | ), 45 | f64::max( 46 | self.non_heap_used_in_bytes as f64, 47 | self.non_heap_committed_in_bytes as f64, 48 | ), 49 | ] 50 | } 51 | 52 | fn x_axis_bounds(&self) -> [f64; 2] { 53 | [self.timestamp as f64, self.timestamp as f64] 54 | } 55 | } 56 | 57 | pub struct JvmMemHeapDataPoint { 58 | pub timestamp: i64, 59 | pub heap_max_in_bytes: i64, 60 | pub heap_used_in_bytes: i64, 61 | } 62 | 63 | impl ChartDataPoint for JvmMemHeapDataPoint { 64 | fn y_axis_bounds(&self) -> [f64; 2] { 65 | [ 66 | f64::min( 67 | self.heap_max_in_bytes as f64, 68 | self.heap_used_in_bytes as f64, 69 | ), 70 | f64::max( 71 | self.heap_max_in_bytes as f64, 72 | self.heap_used_in_bytes as f64, 73 | ), 74 | ] 75 | } 76 | 77 | fn x_axis_bounds(&self) -> [f64; 2] { 78 | [self.timestamp as f64, self.timestamp as f64] 79 | } 80 | } 81 | 82 | pub struct NodeState { 83 | pub chart_jvm_heap_state: TimestampChartState, 84 | pub chart_jvm_non_heap_state: TimestampChartState, 85 | pub chart_process_cpu: TimestampChartState, 86 | pub chart_flow_plugins_throughput: TimestampChartState, 87 | pub chart_flow_queue_backpressure: TimestampChartState, 88 | } 89 | 90 | impl NodeState { 91 | pub(crate) fn new() -> Self { 92 | NodeState { 93 | chart_jvm_heap_state: TimestampChartState::new(DEFAULT_MAX_DATA_POINTS), 94 | chart_jvm_non_heap_state: TimestampChartState::new(DEFAULT_MAX_DATA_POINTS), 95 | chart_process_cpu: TimestampChartState::new(DEFAULT_MAX_DATA_POINTS), 96 | chart_flow_plugins_throughput: Default::default(), 97 | chart_flow_queue_backpressure: Default::default(), 98 | } 99 | } 100 | 101 | fn update_chart_states(&mut self, node_stats: &NodeStats) { 102 | self.chart_process_cpu 103 | .push(ProcessCpuDataPoint::new(node_stats.process.cpu.percent)); 104 | 105 | self.update_jvm_charts_states(node_stats); 106 | 107 | self.chart_flow_plugins_throughput 108 | .push(PluginFlowMetricDataPoint::new( 109 | node_stats.flow.input_throughput.current, 110 | node_stats.flow.filter_throughput.current, 111 | node_stats.flow.filter_throughput.current, 112 | )); 113 | 114 | self.chart_flow_queue_backpressure 115 | .push(FlowMetricDataPoint::new( 116 | node_stats.flow.queue_backpressure.current, 117 | )); 118 | } 119 | 120 | fn update_jvm_charts_states(&mut self, node_stats: &NodeStats) { 121 | self.chart_jvm_heap_state.push(JvmMemHeapDataPoint { 122 | timestamp: now_local_unix_timestamp(), 123 | heap_max_in_bytes: node_stats.jvm.mem.heap_max_in_bytes, 124 | heap_used_in_bytes: node_stats.jvm.mem.heap_used_in_bytes, 125 | }); 126 | 127 | self.chart_jvm_non_heap_state.push(JvmMemNonHeapDataPoint { 128 | timestamp: now_local_unix_timestamp(), 129 | non_heap_committed_in_bytes: node_stats.jvm.mem.non_heap_committed_in_bytes, 130 | non_heap_used_in_bytes: node_stats.jvm.mem.non_heap_used_in_bytes, 131 | }); 132 | } 133 | } 134 | 135 | impl EventsListener for NodeState { 136 | fn update(&mut self, app_data: &AppData) { 137 | if app_data.node_stats().is_none() { 138 | self.reset(); 139 | return; 140 | } 141 | 142 | self.update_chart_states(app_data.node_stats().unwrap()); 143 | } 144 | 145 | fn reset(&mut self) { 146 | self.chart_jvm_heap_state.reset(); 147 | self.chart_jvm_non_heap_state.reset(); 148 | self.chart_process_cpu.reset(); 149 | self.chart_flow_plugins_throughput.reset(); 150 | self.chart_flow_queue_backpressure.reset(); 151 | } 152 | 153 | fn focus_gained(&mut self, _app_data: &AppData) {} 154 | 155 | fn focus_lost(&mut self, _app_data: &AppData) {} 156 | 157 | fn on_enter(&mut self, _app_data: &AppData) {} 158 | 159 | fn on_left(&mut self, _app_data: &AppData) {} 160 | 161 | fn on_right(&mut self, _app_data: &AppData) {} 162 | 163 | fn on_up(&mut self, _app_data: &AppData) {} 164 | 165 | fn on_down(&mut self, _app_data: &AppData) {} 166 | 167 | fn on_other(&mut self, _event: KeyEvent, _app_data: &AppData) {} 168 | } 169 | -------------------------------------------------------------------------------- /src/cli/commands/tui/data_decorator.rs: -------------------------------------------------------------------------------- 1 | use std::cmp::Ordering; 2 | use std::collections::HashMap; 3 | 4 | use uuid::Uuid; 5 | 6 | use crate::api::node::{Edge, NodeInfo, Vertex}; 7 | use crate::api::stats::{NodeStats, NodeStatsVertex}; 8 | 9 | pub(crate) fn decorate(node_info: &mut NodeInfo, node_stats: &mut NodeStats) { 10 | if !is_graph_api_available(&node_info.node.version) || has_empty_vertices(node_info) { 11 | decorate_node_stats(node_stats); 12 | decorate_node_info(node_info, node_stats); 13 | } 14 | } 15 | 16 | fn has_empty_vertices(node_info: &NodeInfo) -> bool { 17 | node_info 18 | .pipelines 19 | .as_ref() 20 | .is_some_and(|p| p.values().any(|m| m.graph.graph.vertices.is_empty())) 21 | } 22 | 23 | // Pipeline graph/vertices API is available on version > 7.3.0 24 | fn is_graph_api_available(version: &str) -> bool { 25 | let version: Vec<&str> = version.split('.').collect(); 26 | if let Some(major) = version.first() { 27 | if let Ok(major) = major.parse::() { 28 | if major < 7 { 29 | return false; 30 | } else if major > 7 { 31 | return true; 32 | } else if let Some(minor) = version.get(1) { 33 | if let Ok(minor) = minor.parse::() { 34 | return minor >= 3; 35 | } 36 | } 37 | } 38 | } 39 | 40 | false 41 | } 42 | 43 | fn decorate_node_stats(node_stats: &mut NodeStats) { 44 | for stats in &mut node_stats.pipelines.values_mut() { 45 | if stats.vertices.is_empty() { 46 | let mut new_vertices: HashMap = HashMap::new(); 47 | for (id, plugin) in stats.plugins.all() { 48 | new_vertices.insert( 49 | id.to_string(), 50 | NodeStatsVertex { 51 | id: id.to_string(), 52 | pipeline_ephemeral_id: stats 53 | .ephemeral_id 54 | .as_deref() 55 | .map(|p| p.to_string()) 56 | .unwrap_or(id), 57 | events_out: plugin.events.out, 58 | events_in: plugin.events.r#in, 59 | duration_in_millis: plugin.events.duration_in_millis, 60 | queue_push_duration_in_millis: plugin.events.queue_push_duration_in_millis, 61 | }, 62 | ); 63 | } 64 | stats.vertices = new_vertices; 65 | } 66 | } 67 | } 68 | 69 | fn decorate_node_info(node_info: &mut NodeInfo, node_stats: &NodeStats) { 70 | if let Some(pipelines) = &mut node_info.pipelines { 71 | for (pipeline, info) in pipelines { 72 | if info.graph.graph.vertices.is_empty() { 73 | let pipeline_stats = node_stats.pipelines.get(pipeline).unwrap(); 74 | let mut new_vertices = vec![]; 75 | let mut new_edges = vec![]; 76 | 77 | for (id, (plugin_type, plugin)) in pipeline_stats.plugins.all_with_type() { 78 | if plugin_type == "codec" { 79 | continue; 80 | } 81 | 82 | new_vertices.push(Vertex { 83 | id: id.to_string(), 84 | explicit_id: id.len() != 64, // Guessed based on the generated ID size 85 | config_name: plugin 86 | .name 87 | .as_ref() 88 | .unwrap_or(&plugin.id.to_string()) 89 | .to_string(), 90 | plugin_type: plugin_type.to_string(), 91 | condition: "".to_string(), 92 | r#type: "plugin".to_string(), 93 | meta: None, 94 | }); 95 | } 96 | 97 | new_vertices.push(Vertex { 98 | id: "__QUEUE__".to_string(), 99 | explicit_id: false, 100 | config_name: "".to_string(), 101 | plugin_type: "".to_string(), 102 | condition: "".to_string(), 103 | r#type: "queue".to_string(), 104 | meta: None, 105 | }); 106 | 107 | new_vertices.sort_by(|v1, v2| { 108 | if v1.plugin_type == v2.plugin_type { 109 | return v1.id.cmp(&v2.id); 110 | } 111 | 112 | if v1.plugin_type == "input" { 113 | return Ordering::Less; 114 | } 115 | 116 | if v2.plugin_type == "input" { 117 | return Ordering::Greater; 118 | } 119 | 120 | v1.plugin_type.cmp(&v2.plugin_type) 121 | }); 122 | 123 | for i in 0..new_vertices.len() - 1 { 124 | let (plugin_type, from) = &new_vertices 125 | .get(i) 126 | .map(|p| (p.plugin_type.to_string(), p.id.to_string())) 127 | .unwrap_or(("".to_string(), "".to_string())); 128 | 129 | let to = if plugin_type == "input" { 130 | "__QUEUE__".to_string() 131 | } else { 132 | new_vertices 133 | .get(i + 1) 134 | .map(|p| p.id.to_string()) 135 | .unwrap_or("".to_string()) 136 | }; 137 | 138 | new_edges.push(Edge { 139 | id: Uuid::new_v4().to_string(), 140 | from: from.to_string(), 141 | to, 142 | r#type: "".to_string(), 143 | when: None, 144 | }); 145 | } 146 | 147 | info.graph.graph.vertices = new_vertices; 148 | info.graph.graph.edges = new_edges; 149 | } 150 | } 151 | } 152 | } 153 | -------------------------------------------------------------------------------- /src/cli/commands/tui/flow_charts.rs: -------------------------------------------------------------------------------- 1 | use crate::commands::formatter::NumberFormatter; 2 | use crate::commands::tui::charts::{ 3 | create_chart_float_label_spans, create_chart_timestamp_label_spans, ChartDataPoint, 4 | TimestampChartState, DEFAULT_LABELS_COUNT, 5 | }; 6 | use crate::commands::tui::now_local_unix_timestamp; 7 | use ratatui::layout::{Constraint, Rect}; 8 | use ratatui::prelude::{Color, Span, Style}; 9 | use ratatui::widgets::{Axis, Block, Borders, Chart, Dataset, GraphType}; 10 | use ratatui::{symbols, Frame}; 11 | 12 | pub struct PluginFlowMetricDataPoint { 13 | pub timestamp: i64, 14 | pub input: f64, 15 | pub filter: f64, 16 | pub output: f64, 17 | } 18 | 19 | impl PluginFlowMetricDataPoint { 20 | pub fn new(input: f64, filter: f64, output: f64) -> Self { 21 | PluginFlowMetricDataPoint { 22 | timestamp: now_local_unix_timestamp(), 23 | input, 24 | filter, 25 | output, 26 | } 27 | } 28 | } 29 | 30 | impl ChartDataPoint for PluginFlowMetricDataPoint { 31 | fn y_axis_bounds(&self) -> [f64; 2] { 32 | [ 33 | f64::min(f64::min(self.input, self.filter), self.output), 34 | f64::max(f64::max(self.input, self.filter), self.output), 35 | ] 36 | } 37 | 38 | fn x_axis_bounds(&self) -> [f64; 2] { 39 | [self.timestamp as f64, self.timestamp as f64] 40 | } 41 | } 42 | 43 | pub struct FlowMetricDataPoint { 44 | pub timestamp: i64, 45 | pub value: f64, 46 | } 47 | 48 | impl FlowMetricDataPoint { 49 | pub fn new(value: f64) -> Self { 50 | FlowMetricDataPoint { 51 | timestamp: now_local_unix_timestamp(), 52 | value, 53 | } 54 | } 55 | } 56 | 57 | impl ChartDataPoint for FlowMetricDataPoint { 58 | fn y_axis_bounds(&self) -> [f64; 2] { 59 | [self.value, self.value] 60 | } 61 | 62 | fn x_axis_bounds(&self) -> [f64; 2] { 63 | [self.timestamp as f64, self.timestamp as f64] 64 | } 65 | } 66 | 67 | pub(crate) fn draw_plugin_throughput_flow_chart( 68 | f: &mut Frame, 69 | title: &str, 70 | state: &TimestampChartState, 71 | area: Rect, 72 | ) { 73 | let mut input_throughput_data: Vec<(f64, f64)> = vec![]; 74 | let mut filter_throughput_data: Vec<(f64, f64)> = vec![]; 75 | let mut output_throughput_data: Vec<(f64, f64)> = vec![]; 76 | 77 | for data_point in &state.data_points { 78 | input_throughput_data.push((data_point.timestamp as f64, data_point.input)); 79 | filter_throughput_data.push((data_point.timestamp as f64, data_point.filter)); 80 | output_throughput_data.push((data_point.timestamp as f64, data_point.output)); 81 | } 82 | 83 | let (input_throughput, filter_throughput, output_throughput) = state 84 | .data_points 85 | .front() 86 | .map(|p| (p.input, p.filter, p.output)) 87 | .unwrap_or((0.0, 0.0, 0.0)); 88 | 89 | let datasets = vec![ 90 | Dataset::default() 91 | .name(format!("Input: {} e/s", input_throughput.format_number())) 92 | .marker(symbols::Marker::Braille) 93 | .graph_type(GraphType::Line) 94 | .style(Style::default().fg(Color::Blue)) 95 | .data(&input_throughput_data), 96 | Dataset::default() 97 | .name(format!("Filter: {} e/s", filter_throughput.format_number())) 98 | .marker(symbols::Marker::Braille) 99 | .graph_type(GraphType::Line) 100 | .style(Style::default().fg(Color::Yellow)) 101 | .data(&filter_throughput_data), 102 | Dataset::default() 103 | .name(format!("Output: {} e/s", output_throughput.format_number())) 104 | .marker(symbols::Marker::Braille) 105 | .graph_type(GraphType::Line) 106 | .style(Style::default().fg(Color::Magenta)) 107 | .data(&output_throughput_data), 108 | ]; 109 | 110 | f.render_widget(create_flow_metric_chart(title, datasets, state), area); 111 | } 112 | 113 | pub(crate) fn draw_flow_metric_chart( 114 | f: &mut Frame, 115 | title: &str, 116 | label_suffix: Option<&str>, 117 | state: &TimestampChartState, 118 | area: Rect, 119 | human_format: bool, 120 | ) { 121 | let metric_data: Vec<(f64, f64)> = state 122 | .data_points 123 | .iter() 124 | .map(|p| (p.timestamp as f64, p.value)) 125 | .collect(); 126 | 127 | let current_value = state.data_points.front().map(|p| p.value).unwrap_or(0.0); 128 | let formatted_current_value = if human_format { 129 | current_value.format_number() 130 | } else { 131 | current_value.strip_number_decimals(3) 132 | }; 133 | 134 | let datasets = vec![Dataset::default() 135 | .name(format!( 136 | "Current: {} {}", 137 | formatted_current_value, 138 | label_suffix.unwrap_or("") 139 | )) 140 | .marker(symbols::Marker::Braille) 141 | .graph_type(GraphType::Line) 142 | .style(Style::default().fg(Color::Blue)) 143 | .data(&metric_data)]; 144 | 145 | f.render_widget(create_flow_metric_chart(title, datasets, state), area); 146 | } 147 | 148 | fn create_flow_metric_chart<'a>( 149 | title: &'a str, 150 | datasets: Vec>, 151 | state: &TimestampChartState, 152 | ) -> Chart<'a> { 153 | Chart::new(datasets) 154 | .hidden_legend_constraints((Constraint::Percentage(90), Constraint::Percentage(90))) 155 | .block( 156 | Block::default() 157 | .title(Span::raw(title)) 158 | .borders(Borders::ALL), 159 | ) 160 | .x_axis( 161 | Axis::default() 162 | .style(Style::default().fg(Color::Gray)) 163 | .bounds(*state.x_axis_bounds()) 164 | .labels(create_chart_timestamp_label_spans( 165 | state.x_axis_labels_values(DEFAULT_LABELS_COUNT), 166 | )), 167 | ) 168 | .y_axis( 169 | Axis::default() 170 | .style(Style::default().fg(Color::Gray)) 171 | .bounds(*state.y_axis_bounds()) 172 | .labels(create_chart_float_label_spans( 173 | state.y_axis_labels_values(DEFAULT_LABELS_COUNT), 174 | )), 175 | ) 176 | } 177 | -------------------------------------------------------------------------------- /src/cli/commands/tui/charts.rs: -------------------------------------------------------------------------------- 1 | use humansize::{format_size_i, DECIMAL}; 2 | use ratatui::text::Span; 3 | use std::collections::VecDeque; 4 | use time::{format_description, OffsetDateTime, UtcOffset}; 5 | 6 | pub(crate) const DEFAULT_LABELS_COUNT: usize = 4; 7 | 8 | pub(crate) const DEFAULT_MAX_DATA_POINTS: Option = Some(120); 9 | 10 | pub(crate) fn create_chart_float_label_spans<'a>(label_values: Vec) -> Vec> { 11 | label_values 12 | .iter() 13 | .map(|value| Span::raw(format!("{:.2}", *value))) 14 | .collect() 15 | } 16 | 17 | pub(crate) fn create_chart_timestamp_label_spans<'a>(label_values: Vec) -> Vec> { 18 | let format = format_description::parse("[hour]:[minute]:[second]").unwrap(); 19 | label_values 20 | .iter() 21 | .map(|value| { 22 | Span::raw( 23 | OffsetDateTime::from_unix_timestamp(*value as i64) 24 | .unwrap() 25 | .to_offset(UtcOffset::current_local_offset().unwrap()) 26 | .format(&format) 27 | .unwrap(), 28 | ) 29 | }) 30 | .collect() 31 | } 32 | 33 | pub(crate) fn create_chart_binary_size_label_spans<'a>(label_values: Vec) -> Vec> { 34 | label_values 35 | .iter() 36 | .map(|value| Span::raw(format_size_i(*value, DECIMAL))) 37 | .collect() 38 | } 39 | 40 | pub(crate) fn create_chart_percentage_label_spans<'a>(label_values: Vec) -> Vec> { 41 | label_values 42 | .iter() 43 | .map(|value| Span::raw(format!("{:.2}%", *value))) 44 | .collect() 45 | } 46 | 47 | pub trait ChartDataPoint { 48 | fn y_axis_bounds(&self) -> [f64; 2]; 49 | fn x_axis_bounds(&self) -> [f64; 2]; 50 | } 51 | 52 | pub struct TimestampChartState 53 | where 54 | Y: ChartDataPoint, 55 | { 56 | pub data_points: VecDeque, 57 | pub max_data_points: Option, 58 | x_axis_bounds: [f64; 2], 59 | y_axis_bounds: [f64; 2], 60 | min_x_value: Option, 61 | min_y_value: Option, 62 | } 63 | 64 | impl Default for TimestampChartState 65 | where 66 | Y: ChartDataPoint, 67 | { 68 | fn default() -> Self { 69 | TimestampChartState::new(DEFAULT_MAX_DATA_POINTS) 70 | } 71 | } 72 | 73 | impl TimestampChartState 74 | where 75 | Y: ChartDataPoint, 76 | { 77 | pub fn with_min_bounds( 78 | max_data_points: Option, 79 | min_x_value: Option, 80 | min_y_value: Option, 81 | ) -> Self { 82 | TimestampChartState { 83 | data_points: VecDeque::new(), 84 | max_data_points, 85 | x_axis_bounds: [0.0, 0.0], 86 | y_axis_bounds: [0.0, 0.0], 87 | min_x_value, 88 | min_y_value, 89 | } 90 | } 91 | 92 | pub fn with_negative_bounds(max_data_points: Option) -> Self { 93 | Self::with_min_bounds(max_data_points, Some(f64::MIN), Some(f64::MIN)) 94 | } 95 | 96 | pub fn new(max_data_points: Option) -> Self { 97 | Self::with_min_bounds(max_data_points, Some(0.0), Some(0.0)) 98 | } 99 | 100 | fn update_bounds(bounds: &mut [f64; 2], value_bounds: [f64; 2]) { 101 | if value_bounds[0] < bounds[0] { 102 | bounds[0] = value_bounds[0]; 103 | } 104 | 105 | if value_bounds[1] > bounds[1] { 106 | bounds[1] = value_bounds[1]; 107 | } 108 | } 109 | 110 | pub fn push(&mut self, value: Y) { 111 | let value_y_bounds = value.y_axis_bounds(); 112 | let value_x_bounds = value.x_axis_bounds(); 113 | 114 | let inclusive_y_bounds = [ 115 | f64::max(self.min_y_value.unwrap_or(0.0), value_y_bounds[0] - 1.0), 116 | value_y_bounds[1] + 1.0, 117 | ]; 118 | let inclusive_x_bounds = [ 119 | f64::max(self.min_x_value.unwrap_or(0.0), value_x_bounds[0] - 1.0), 120 | value_x_bounds[1] + 1.0, 121 | ]; 122 | 123 | if self.data_points.is_empty() { 124 | self.x_axis_bounds = inclusive_x_bounds; 125 | self.y_axis_bounds = inclusive_y_bounds; 126 | } else { 127 | Self::update_bounds(&mut self.x_axis_bounds, inclusive_x_bounds); 128 | Self::update_bounds(&mut self.y_axis_bounds, inclusive_y_bounds); 129 | } 130 | 131 | self.data_points.push_front(value); 132 | 133 | if let Some(max_data_points) = self.max_data_points { 134 | if self.data_points.len() >= max_data_points { 135 | self.data_points.pop_back(); 136 | if let Some(newest) = self.data_points.back() { 137 | self.x_axis_bounds[0] = newest.x_axis_bounds()[0]; 138 | } 139 | } 140 | } 141 | } 142 | 143 | pub fn x_axis_bounds(&self) -> &[f64; 2] { 144 | &self.x_axis_bounds 145 | } 146 | 147 | pub fn y_axis_bounds(&self) -> &[f64; 2] { 148 | &self.y_axis_bounds 149 | } 150 | 151 | pub fn x_axis_labels_values(&self, count: usize) -> Vec { 152 | let mut values: Vec = Vec::with_capacity(count + 1); 153 | 154 | let pieces = f64::max( 155 | self.min_x_value.map(|p| p + 1.0).unwrap_or(1.0), 156 | (self.x_axis_bounds[1] - self.x_axis_bounds[0]) / (count as f64), 157 | ); 158 | 159 | let mut previous: Option = None; 160 | for i in 0..(count - 1) { 161 | let next = self.x_axis_bounds[0] + (pieces * (i as f64)); 162 | if previous.is_some_and(|p| p.signum() != next.signum()) { 163 | values.push(0.0); 164 | } 165 | 166 | previous = Some(next); 167 | values.push(next); 168 | } 169 | 170 | values.push(self.x_axis_bounds[1]); 171 | values 172 | } 173 | 174 | pub fn y_axis_labels_values(&self, count: usize) -> Vec { 175 | let mut values: Vec = Vec::with_capacity(count + 1); 176 | 177 | let pieces = f64::max( 178 | self.min_y_value.map(|p| p + 1.0).unwrap_or(1.0), 179 | (self.y_axis_bounds[1] - self.y_axis_bounds[0]) / (count as f64), 180 | ); 181 | 182 | let mut previous: Option = None; 183 | for i in 0..(count - 1) { 184 | let next = self.y_axis_bounds[0] + (pieces * (i as f64)); 185 | if previous.is_some_and(|p| p.signum() != next.signum()) { 186 | values.push(0.0); 187 | } 188 | 189 | previous = Some(next); 190 | values.push(next); 191 | } 192 | 193 | values.push(self.y_axis_bounds[1]); 194 | values 195 | } 196 | 197 | pub fn reset(&mut self) { 198 | self.data_points.clear(); 199 | self.x_axis_bounds = [0.0, 0.00]; 200 | self.y_axis_bounds = [0.0, 0.00]; 201 | } 202 | 203 | pub fn is_empty(&self) -> bool { 204 | self.data_points.is_empty() 205 | } 206 | } 207 | -------------------------------------------------------------------------------- /src/cli/commands/tui/threads/state.rs: -------------------------------------------------------------------------------- 1 | use std::collections::{HashMap, VecDeque}; 2 | 3 | use time::format_description::well_known::Iso8601; 4 | use time::OffsetDateTime; 5 | 6 | use crate::commands::tui::app::AppData; 7 | use crate::commands::tui::events::EventsListener; 8 | use crate::commands::tui::widgets::StatefulTable; 9 | 10 | pub const THREAD_LIST: usize = 0; 11 | pub const THREAD_TRACES_VIEW: usize = 1; 12 | 13 | pub struct ThreadsState { 14 | pub current_focus: usize, 15 | pub show_selected_thread: bool, 16 | pub threads_table: StatefulTable, 17 | pub threads_table_states: HashMap>, 18 | pub threads_table_states_times: VecDeque, 19 | pub selected_thread_traces: StatefulTable, 20 | pub selected_thread_trace_value_offset: usize, 21 | } 22 | 23 | pub struct ThreadTableItem { 24 | pub time: String, 25 | pub name: String, 26 | pub id: i64, 27 | pub percent_of_cpu_time: f64, 28 | pub state: String, 29 | pub traces: Vec, 30 | } 31 | 32 | impl ThreadsState { 33 | pub(crate) fn new() -> Self { 34 | ThreadsState { 35 | current_focus: THREAD_LIST, 36 | show_selected_thread: false, 37 | threads_table: StatefulTable::new(), 38 | threads_table_states: Default::default(), 39 | threads_table_states_times: Default::default(), 40 | selected_thread_traces: StatefulTable::new(), 41 | selected_thread_trace_value_offset: 0, 42 | } 43 | } 44 | 45 | fn update_selected_thread_traces(&mut self, _app_data: &AppData) { 46 | if let Some(selected_thread) = self.threads_table.selected_item() { 47 | self.selected_thread_traces 48 | .items 49 | .clone_from(&selected_thread.traces); 50 | } 51 | } 52 | } 53 | 54 | impl StatefulTable { 55 | fn update(&mut self, data: &AppData) { 56 | if let Some(threads) = data.hot_threads() { 57 | let mut new_items = Vec::with_capacity(threads.hot_threads.threads.len()); 58 | for thread in threads.hot_threads.threads.values() { 59 | let new_item = ThreadTableItem { 60 | id: thread.thread_id, 61 | name: thread.name.to_string(), 62 | percent_of_cpu_time: thread.percent_of_cpu_time, 63 | state: thread.state.to_string(), 64 | traces: thread.traces.clone(), 65 | time: threads.hot_threads.time.to_string(), 66 | }; 67 | new_items.push(new_item); 68 | } 69 | 70 | new_items.sort_by(|a, b| { 71 | if a.percent_of_cpu_time == b.percent_of_cpu_time { 72 | a.name.cmp(&b.name) 73 | } else { 74 | a.percent_of_cpu_time 75 | .total_cmp(&b.percent_of_cpu_time) 76 | .reverse() 77 | } 78 | }); 79 | 80 | let new_selected_index = if let Some(selected) = self.selected_item() { 81 | // Not optimal, but should work for now to keep the selected line 82 | // selected after an update with order changes. 83 | new_items.iter().position(|p| p.id == selected.id) 84 | } else { 85 | None 86 | }; 87 | 88 | self.items = new_items; 89 | self.select(new_selected_index); 90 | } 91 | } 92 | } 93 | 94 | pub(crate) const MAX_THREAD_STATES: usize = 500; 95 | 96 | impl EventsListener for ThreadsState { 97 | fn update(&mut self, app_data: &AppData) { 98 | self.threads_table.update(app_data); 99 | 100 | for thread_item in &self.threads_table.items { 101 | self.threads_table_states.entry(thread_item.id).or_default(); 102 | 103 | let states = self.threads_table_states.get_mut(&thread_item.id).unwrap(); 104 | 105 | if let Ok(time) = OffsetDateTime::parse(&thread_item.time, &Iso8601::DEFAULT) { 106 | self.threads_table_states_times.push_back(time); 107 | } else { 108 | self.threads_table_states_times 109 | .push_back(OffsetDateTime::now_utc()); 110 | } 111 | 112 | states.push_back(thread_item.state.to_string()); 113 | 114 | if states.len() > MAX_THREAD_STATES { 115 | states.pop_front(); 116 | self.threads_table_states_times.pop_front(); 117 | } 118 | } 119 | } 120 | 121 | fn reset(&mut self) { 122 | self.current_focus = THREAD_LIST; 123 | self.show_selected_thread = false; 124 | self.selected_thread_trace_value_offset = 0; 125 | 126 | self.threads_table = StatefulTable::new(); 127 | self.selected_thread_traces = StatefulTable::new(); 128 | self.threads_table_states.clear(); 129 | self.threads_table_states_times.clear(); 130 | } 131 | 132 | fn on_enter(&mut self, _app_data: &AppData) { 133 | if self.current_focus == THREAD_LIST { 134 | if self.threads_table.selected_item().is_some() { 135 | self.show_selected_thread = !self.show_selected_thread; 136 | } 137 | } else { 138 | self.show_selected_thread = false; 139 | self.selected_thread_trace_value_offset = 0; 140 | self.selected_thread_traces.unselect(); 141 | self.current_focus = THREAD_LIST; 142 | } 143 | } 144 | 145 | fn on_left(&mut self, _app_data: &AppData) { 146 | if self.current_focus != THREAD_LIST { 147 | self.current_focus = THREAD_LIST; 148 | self.selected_thread_traces.unselect(); 149 | } 150 | } 151 | 152 | fn on_right(&mut self, _app_data: &AppData) { 153 | if self.current_focus == THREAD_LIST && self.show_selected_thread { 154 | self.current_focus = THREAD_TRACES_VIEW; 155 | self.selected_thread_traces.next(); 156 | } else if self.selected_thread_traces.selected_item().is_some() { 157 | self.selected_thread_trace_value_offset += 3; 158 | } 159 | } 160 | 161 | fn on_up(&mut self, app_data: &AppData) { 162 | if self.current_focus == THREAD_LIST { 163 | self.threads_table.previous(); 164 | self.update_selected_thread_traces(app_data); 165 | } else { 166 | self.selected_thread_trace_value_offset = 0; 167 | self.selected_thread_traces.previous(); 168 | } 169 | } 170 | 171 | fn on_down(&mut self, app_data: &AppData) { 172 | if self.current_focus == THREAD_LIST { 173 | self.threads_table.next(); 174 | self.update_selected_thread_traces(app_data); 175 | } else { 176 | self.selected_thread_trace_value_offset = 0; 177 | self.selected_thread_traces.next(); 178 | } 179 | } 180 | } 181 | -------------------------------------------------------------------------------- /src/cli/commands/tui/pipelines/state.rs: -------------------------------------------------------------------------------- 1 | use crossterm::event::{KeyCode, KeyEvent}; 2 | use std::marker::PhantomData; 3 | 4 | use crate::api::node::{GraphDefinition, NodeInfo}; 5 | use crate::commands::tui::app::AppData; 6 | use crate::commands::tui::events::EventsListener; 7 | use crate::commands::tui::pipelines::graph::PipelineGraph; 8 | use crate::commands::tui::widgets::StatefulTable; 9 | 10 | pub const PIPELINE_VERTEX_LIST: usize = 0; 11 | pub const PIPELINE_VERTEX_VIEW: usize = 1; 12 | 13 | pub struct PipelineTableItem { 14 | pub name: String, 15 | pub graph: GraphDefinition, 16 | } 17 | 18 | impl StatefulTable { 19 | fn update(&mut self, data: &AppData) { 20 | if let Some(node_info) = &data.node_info() { 21 | if let Some(pipelines) = &node_info.pipelines { 22 | let mut new_items = Vec::with_capacity(pipelines.len()); 23 | for (name, pipeline_info) in pipelines { 24 | let new_item = PipelineTableItem { 25 | name: name.to_string(), 26 | graph: pipeline_info.graph.graph.clone(), 27 | }; 28 | 29 | new_items.push(new_item); 30 | } 31 | 32 | new_items.sort_by_key(|k| k.name.to_string()); 33 | if let Some(selected_pipeline_name) = self.selected_item().map(|p| p.name.as_str()) 34 | { 35 | if let Some(new_index) = new_items 36 | .iter() 37 | .position(|p| p.name == selected_pipeline_name) 38 | { 39 | self.state.select(Some(new_index)); 40 | } 41 | } 42 | 43 | self.items = new_items; 44 | } 45 | } 46 | } 47 | } 48 | 49 | type SelectedPipelineVertexTableItem = String; 50 | 51 | impl StatefulTable { 52 | fn update( 53 | &mut self, 54 | node_info: &Option<&NodeInfo>, 55 | selected_pipeline: &Option<&PipelineTableItem>, 56 | ) { 57 | if selected_pipeline.is_none() || node_info.is_none() { 58 | self.items = vec![]; 59 | self.unselect(); 60 | return; 61 | } 62 | 63 | self.items = PipelineGraph::from(&selected_pipeline.unwrap().graph) 64 | .create_pipeline_vertex_ids(selected_pipeline.unwrap()); 65 | } 66 | } 67 | 68 | pub struct PipelinesState<'a> { 69 | pub current_focus: usize, 70 | pub pipelines_table: StatefulTable, 71 | pub selected_pipeline_vertex: StatefulTable, 72 | pub show_selected_pipeline_charts: bool, 73 | pub show_selected_vertex_details: bool, 74 | _marker: PhantomData<&'a ()>, 75 | } 76 | 77 | impl PipelinesState<'_> { 78 | pub fn new() -> Self { 79 | PipelinesState { 80 | current_focus: 0, 81 | pipelines_table: StatefulTable::new(), 82 | selected_pipeline_vertex: StatefulTable::new(), 83 | show_selected_pipeline_charts: false, 84 | show_selected_vertex_details: false, 85 | _marker: PhantomData, 86 | } 87 | } 88 | 89 | pub fn selected_pipeline_name(&self) -> Option<&String> { 90 | self.pipelines_table.selected_item().map(|p| &p.name) 91 | } 92 | 93 | pub fn selected_pipeline_vertex(&self) -> Option<&String> { 94 | self.selected_pipeline_vertex.selected_item() 95 | } 96 | } 97 | 98 | impl EventsListener for PipelinesState<'_> { 99 | fn update(&mut self, app_data: &AppData) { 100 | self.pipelines_table.update(app_data); 101 | 102 | let selected_pipeline_item = if self.pipelines_table.selected_item().is_none() 103 | && !self.pipelines_table.items.is_empty() 104 | { 105 | self.pipelines_table.next() 106 | } else { 107 | self.pipelines_table.selected_item() 108 | }; 109 | 110 | self.selected_pipeline_vertex 111 | .update(&app_data.node_info(), &selected_pipeline_item); 112 | } 113 | 114 | fn reset(&mut self) { 115 | // UI 116 | self.current_focus = PIPELINE_VERTEX_LIST; 117 | self.show_selected_pipeline_charts = false; 118 | self.show_selected_vertex_details = false; 119 | 120 | self.pipelines_table = StatefulTable::new(); 121 | self.selected_pipeline_vertex = StatefulTable::new(); 122 | } 123 | 124 | fn on_enter(&mut self, _app_data: &AppData) { 125 | if self.current_focus == PIPELINE_VERTEX_LIST { 126 | if self.pipelines_table.selected_item().is_some() { 127 | self.show_selected_vertex_details = false; 128 | self.show_selected_pipeline_charts = !self.show_selected_pipeline_charts; 129 | } 130 | } else if self.current_focus == PIPELINE_VERTEX_VIEW { 131 | self.show_selected_pipeline_charts = false; 132 | self.show_selected_vertex_details = !self.show_selected_vertex_details; 133 | } 134 | } 135 | 136 | fn on_left(&mut self, _: &AppData) { 137 | if self.current_focus == PIPELINE_VERTEX_VIEW { 138 | self.current_focus = PIPELINE_VERTEX_LIST; 139 | self.selected_pipeline_vertex.unselect(); 140 | self.show_selected_vertex_details = false; 141 | } 142 | } 143 | 144 | fn on_right(&mut self, _: &AppData) { 145 | if self.current_focus == PIPELINE_VERTEX_LIST 146 | && !self.selected_pipeline_vertex.items.is_empty() 147 | { 148 | self.current_focus = PIPELINE_VERTEX_VIEW; 149 | self.selected_pipeline_vertex.next(); 150 | } 151 | } 152 | 153 | fn on_up(&mut self, app_data: &AppData) { 154 | if self.current_focus == PIPELINE_VERTEX_LIST { 155 | self.selected_pipeline_vertex 156 | .update(&app_data.node_info(), &self.pipelines_table.previous()); 157 | } else { 158 | self.selected_pipeline_vertex.previous(); 159 | } 160 | } 161 | 162 | fn on_down(&mut self, app_data: &AppData) { 163 | if self.current_focus == PIPELINE_VERTEX_LIST { 164 | self.selected_pipeline_vertex 165 | .update(&app_data.node_info(), &self.pipelines_table.next()); 166 | } else { 167 | self.selected_pipeline_vertex.next(); 168 | } 169 | } 170 | 171 | fn on_other(&mut self, key_event: KeyEvent, app_data: &AppData) { 172 | // Tab navigation 173 | if key_event.code == KeyCode::Tab { 174 | if self.current_focus == PIPELINE_VERTEX_LIST { 175 | self.on_right(app_data); 176 | } else { 177 | self.on_left(app_data); 178 | } 179 | 180 | return; 181 | } 182 | 183 | if let KeyCode::Char(c) = key_event.code { 184 | if c.eq_ignore_ascii_case(&'c') && self.pipelines_table.selected_item().is_some() { 185 | self.show_selected_vertex_details = false; 186 | self.show_selected_pipeline_charts = !self.show_selected_pipeline_charts; 187 | } 188 | }; 189 | } 190 | } 191 | -------------------------------------------------------------------------------- /src/cli/commands/tui/ui.rs: -------------------------------------------------------------------------------- 1 | use std::collections::HashMap; 2 | use std::vec; 3 | 4 | use ratatui::layout::{Alignment, Flex}; 5 | use ratatui::widgets::{Paragraph, Wrap}; 6 | use ratatui::{ 7 | layout::{Constraint, Direction, Layout, Rect}, 8 | style::{Color, Modifier, Style}, 9 | text::{Line, Span}, 10 | widgets::{Block, Borders, Tabs}, 11 | Frame, 12 | }; 13 | 14 | use crate::commands::tui::app::App; 15 | use crate::commands::tui::flows::ui::{draw_flows_tab, flows_tab_shortcuts_help}; 16 | use crate::commands::tui::node::ui::draw_node_tab; 17 | use crate::commands::tui::pipelines::ui::{draw_pipelines_tab, pipelines_tab_shortcuts_help}; 18 | use crate::commands::tui::threads::ui::{draw_threads_tab, threads_tab_shortcuts_help}; 19 | 20 | pub(crate) fn draw(f: &mut Frame, app: &mut App) { 21 | let last_error_message = app.data.read().unwrap().last_error_message().clone(); 22 | let constraints = if app.show_help || last_error_message.is_some() { 23 | vec![ 24 | Constraint::Length(3), 25 | Constraint::Min(0), 26 | Constraint::Length(3), 27 | ] 28 | } else { 29 | vec![Constraint::Length(3), Constraint::Min(0)] 30 | }; 31 | 32 | let chunks = Layout::default() 33 | .constraints(constraints) 34 | .direction(Direction::Vertical) 35 | .split(f.area()); 36 | 37 | let header_block = Block::default() 38 | .borders(Borders::ALL) 39 | .title(app.title.as_str()); 40 | 41 | f.render_widget(header_block, chunks[0]); 42 | 43 | let title_chunks = Layout::default() 44 | .flex(Flex::Legacy) 45 | .constraints( 46 | [ 47 | Constraint::Length(37), 48 | Constraint::Percentage(20), 49 | Constraint::Percentage(50), 50 | ] 51 | .as_ref(), 52 | ) 53 | .direction(Direction::Horizontal) 54 | .margin(1) 55 | .split(chunks[0]); 56 | 57 | let tab_titles = vec![ 58 | Line::from(vec![ 59 | Span::styled( 60 | "P", 61 | Style::default().add_modifier(Modifier::UNDERLINED | Modifier::BOLD), 62 | ), 63 | Span::styled("ipelines", Style::default().add_modifier(Modifier::BOLD)), 64 | ]), 65 | Line::from(vec![ 66 | Span::styled( 67 | "F", 68 | Style::default().add_modifier(Modifier::UNDERLINED | Modifier::BOLD), 69 | ), 70 | Span::styled("lows", Style::default().add_modifier(Modifier::BOLD)), 71 | ]), 72 | Line::from(vec![ 73 | Span::styled( 74 | "T", 75 | Style::default().add_modifier(Modifier::UNDERLINED | Modifier::BOLD), 76 | ), 77 | Span::styled("hreads", Style::default().add_modifier(Modifier::BOLD)), 78 | ]), 79 | Line::from(vec![ 80 | Span::styled( 81 | "N", 82 | Style::default().add_modifier(Modifier::UNDERLINED | Modifier::BOLD), 83 | ), 84 | Span::styled("ode", Style::default().add_modifier(Modifier::BOLD)), 85 | ]), 86 | ]; 87 | 88 | let tabs = Tabs::new(tab_titles) 89 | .block(Block::default().borders(Borders::NONE)) 90 | .highlight_style( 91 | Style::default() 92 | .fg(Color::Cyan) 93 | .add_modifier(Modifier::BOLD), 94 | ) 95 | .select(app.tabs.index); 96 | 97 | f.render_widget(tabs, title_chunks[0]); 98 | 99 | // Help text 100 | let help_text = Line::from(vec![ 101 | Span::styled(" [H] ", Style::default().fg(Color::Yellow)), 102 | Span::styled("for help", Style::default().fg(Color::DarkGray)), 103 | ]); 104 | 105 | let w = Paragraph::new(help_text) 106 | .alignment(Alignment::Left) 107 | .wrap(Wrap { trim: true }); 108 | 109 | f.render_widget(w, title_chunks[1]); 110 | 111 | // Connection status 112 | let errored = app.data.read().unwrap().errored(); 113 | let conn_status_span: Span = if errored { 114 | Span::styled("Disconnected", Style::default().fg(Color::Red)) 115 | } else { 116 | Span::styled("Connected", Style::default().fg(Color::Green)) 117 | }; 118 | 119 | let mut status_text_spans = vec![ 120 | conn_status_span, 121 | Span::styled(" @ ", Style::default().fg(Color::Gray)), 122 | Span::from(app.host.as_str()), 123 | ]; 124 | 125 | if let Some(interval) = app.sampling_interval { 126 | status_text_spans.push(Span::styled( 127 | format!(" | Sampling every {}s", interval.as_secs()), 128 | Style::default().fg(Color::Gray), 129 | )); 130 | } 131 | 132 | let w = Paragraph::new(Line::from(status_text_spans)).alignment(Alignment::Right); 133 | 134 | f.render_widget(w, title_chunks[2]); 135 | 136 | if !errored { 137 | match app.tabs.index { 138 | App::TAB_PIPELINES => draw_pipelines_tab(f, app, chunks[1]), 139 | App::TAB_NODE => draw_node_tab(f, app, chunks[1]), 140 | App::TAB_FLOWS => draw_flows_tab(f, app, chunks[1]), 141 | App::TAB_THREADS => draw_threads_tab(f, app, chunks[1]), 142 | _ => {} 143 | }; 144 | } 145 | 146 | if last_error_message.is_some() { 147 | draw_error_panel(f, app, chunks[2]); 148 | } else if app.show_help { 149 | let (defaults, shortcuts) = match app.tabs.index { 150 | App::TAB_PIPELINES => (true, pipelines_tab_shortcuts_help(app)), 151 | App::TAB_FLOWS => (true, flows_tab_shortcuts_help(app)), 152 | App::TAB_THREADS => (true, threads_tab_shortcuts_help(app)), 153 | _ => (true, Default::default()), 154 | }; 155 | 156 | draw_help_panel(f, defaults, shortcuts, chunks[2]); 157 | } 158 | } 159 | 160 | fn draw_error_panel(f: &mut Frame, app: &App, area: Rect) { 161 | let last_error_message = app.data.read().unwrap().last_error_message().clone(); 162 | if let Some(error) = &last_error_message { 163 | f.render_widget(Block::default().borders(Borders::ALL), area); 164 | 165 | let footer_chunks = Layout::default() 166 | .constraints([Constraint::Percentage(100)]) 167 | .direction(Direction::Horizontal) 168 | .margin(1) 169 | .split(area); 170 | 171 | let w = Paragraph::new(vec![Line::from(vec![ 172 | Span::styled("Error: ", Style::default().fg(Color::Red)), 173 | Span::styled(error, Style::default().fg(Color::DarkGray)), 174 | ])]) 175 | .alignment(Alignment::Left) 176 | .wrap(Wrap { trim: true }); 177 | 178 | f.render_widget(w, footer_chunks[0]); 179 | } 180 | } 181 | 182 | fn draw_help_panel(f: &mut Frame, defaults: bool, shortcuts: HashMap, area: Rect) { 183 | let footer_block = Block::default().borders(Borders::ALL).title("Help"); 184 | f.render_widget(footer_block, area); 185 | 186 | let footer_chunks = Layout::default() 187 | .constraints([Constraint::Percentage(100)]) 188 | .direction(Direction::Horizontal) 189 | .margin(1) 190 | .split(area); 191 | 192 | f.render_widget( 193 | default_help_paragraph(defaults, shortcuts), 194 | footer_chunks[0], 195 | ); 196 | } 197 | 198 | fn default_help_paragraph<'a>(defaults: bool, shortcuts: HashMap) -> Paragraph<'a> { 199 | let separator_span = Span::styled(" |", Style::default()); 200 | 201 | let mut content = vec![Span::styled("Shortcuts:", Style::default().fg(Color::Gray))]; 202 | let mut shortcuts_vec: Vec<(&String, &String)> = shortcuts.iter().collect(); 203 | shortcuts_vec.sort_by(|a, b| b.1.cmp(a.1)); 204 | 205 | for (key, desc) in shortcuts_vec { 206 | content.push(separator_span.clone()); 207 | content.push(Span::styled( 208 | format!("{} ", key), 209 | Style::default().fg(Color::Yellow), 210 | )); 211 | content.push(Span::styled( 212 | desc.to_string(), 213 | Style::default().fg(Color::Gray), 214 | )); 215 | } 216 | 217 | if defaults { 218 | content.extend(vec![ 219 | separator_span.clone(), 220 | Span::styled("[P][F][N] ", Style::default().fg(Color::Yellow)), 221 | Span::styled("switch tabs", Style::default().fg(Color::Gray)), 222 | separator_span.clone(), 223 | Span::styled("[▲][▼][◀][▶][Tab] ", Style::default().fg(Color::Yellow)), 224 | Span::styled("navigate", Style::default().fg(Color::Gray)), 225 | separator_span.clone(), 226 | Span::styled("[Esc][Q] ", Style::default().fg(Color::Yellow)), 227 | Span::styled("exit", Style::default().fg(Color::Gray)), 228 | ]); 229 | } 230 | 231 | Paragraph::new(Line::from(content)) 232 | .alignment(Alignment::Left) 233 | .wrap(Wrap { trim: true }) 234 | } 235 | -------------------------------------------------------------------------------- /src/cli/commands/tui/pipelines/graph.rs: -------------------------------------------------------------------------------- 1 | use std::cell::RefCell; 2 | use std::cmp::Ordering; 3 | use std::collections::{HashMap, HashSet}; 4 | use uuid::Uuid; 5 | 6 | use crate::api::node::{GraphDefinition, Vertex}; 7 | use crate::commands::tui::pipelines::state::PipelineTableItem; 8 | 9 | pub struct Data<'a, T> { 10 | pub value: &'a T, 11 | } 12 | 13 | pub struct VertexEdge<'a> { 14 | pub vertex_id: &'a str, 15 | pub when: Option, 16 | } 17 | 18 | pub struct PipelineGraph<'a> { 19 | pub vertices: HashMap<&'a str, Vec>>, 20 | pub data: HashMap<&'a str, Data<'a, Vertex>>, 21 | pub heads: Vec<&'a str>, 22 | } 23 | 24 | type GraphVisitorStack<'b> = RefCell, i32, bool)>>; 25 | 26 | impl<'a> PipelineGraph<'a> { 27 | pub fn from(graph_value: &'a GraphDefinition) -> Self { 28 | let mut vertices: HashMap<&'a str, Vec>> = HashMap::new(); 29 | let mut inputs_incoming_edge: HashMap<&'a str, &'a str> = HashMap::new(); 30 | let mut data: HashMap<&'a str, Data<'a, Vertex>> = HashMap::new(); 31 | let mut inputs: HashSet<&'a str> = HashSet::new(); 32 | let mut heads: Vec<&'a str> = Vec::new(); 33 | 34 | for vertex in &graph_value.vertices { 35 | let id = vertex.id.as_str(); 36 | if !vertices.contains_key(id) { 37 | vertices.insert(id, Vec::new()); 38 | } 39 | 40 | if vertex.r#type == "plugin" && vertex.plugin_type == "input" { 41 | inputs.insert(id); 42 | } 43 | 44 | data.insert(id, Data { value: vertex }); 45 | } 46 | 47 | for edge in &graph_value.edges { 48 | vertices 49 | .get_mut(edge.from.as_str()) 50 | .unwrap() 51 | .push(VertexEdge { 52 | vertex_id: edge.to.as_str(), 53 | when: edge.when, 54 | }); 55 | 56 | let edge_to_vertex = data.get(edge.to.as_str()).unwrap(); 57 | if edge_to_vertex.value.plugin_type == "input" || edge_to_vertex.value.r#type == "if" { 58 | inputs_incoming_edge.insert(edge.to.as_str(), edge.from.as_str()); 59 | } 60 | } 61 | 62 | let sort_by_source_fn = Self::sort_vertices_by_source(); 63 | for vertex in &graph_value.vertices { 64 | let neighbours = vertices.get_mut(vertex.id.as_str()).unwrap(); 65 | neighbours.sort_by(|a, b| { 66 | sort_by_source_fn( 67 | data.get(a.vertex_id).unwrap().value, 68 | data.get(b.vertex_id).unwrap().value, 69 | ) 70 | }); 71 | } 72 | 73 | for vertex_id in &inputs { 74 | if !inputs_incoming_edge.contains_key(vertex_id) { 75 | heads.push(vertex_id); 76 | } else { 77 | let mut current: &str = vertex_id; 78 | while inputs_incoming_edge.contains_key(current) { 79 | current = inputs_incoming_edge.get(current).unwrap(); 80 | } 81 | 82 | heads.push(current); 83 | } 84 | } 85 | 86 | heads.sort_by(|a, b| { 87 | sort_by_source_fn(data.get(a).unwrap().value, data.get(b).unwrap().value) 88 | }); 89 | 90 | PipelineGraph { 91 | heads, 92 | vertices, 93 | data, 94 | } 95 | } 96 | 97 | fn sort_vertices_by_source() -> impl Fn(&'a Vertex, &'a Vertex) -> Ordering { 98 | |a_vertex: &'a Vertex, b_vertex: &'a Vertex| { 99 | if let Some(a_vertex_source) = a_vertex.meta.as_ref().map(|m| &m.source) { 100 | if let Some(b_vertex_source) = b_vertex.meta.as_ref().map(|m| &m.source) { 101 | let line_order = a_vertex_source.line.cmp(&b_vertex_source.line); 102 | if line_order == Ordering::Equal { 103 | return a_vertex_source.column.cmp(&b_vertex_source.column); 104 | } 105 | 106 | return line_order; 107 | } 108 | } 109 | 110 | a_vertex.id.cmp(&b_vertex.id) 111 | } 112 | } 113 | 114 | pub(crate) fn process_vertices_edges<'b>( 115 | graph: &'b PipelineGraph, 116 | vertex_type: &'b str, 117 | vertex_id: &'b str, 118 | next_ident_level: i32, 119 | next_row_index: Option, 120 | visited: &mut RefCell>, 121 | stack: &mut GraphVisitorStack<'b>, 122 | ) { 123 | if let Some(vertices) = graph.vertices.get(vertex_id) { 124 | if vertex_type == "if" { 125 | let mut when_vertices = [vec![], vec![]]; 126 | let mut other_vertices = vec![]; 127 | 128 | for edge in vertices { 129 | match edge.when { 130 | None => { 131 | other_vertices.push(edge); 132 | } 133 | Some(when) => { 134 | if when { 135 | when_vertices[1].push(edge); 136 | } else { 137 | when_vertices[0].push(edge); 138 | } 139 | } 140 | } 141 | } 142 | 143 | for edge in other_vertices.iter().rev() { 144 | if !visited.borrow().contains(edge.vertex_id) { 145 | stack.get_mut().push(( 146 | edge.vertex_id, 147 | next_row_index, 148 | next_ident_level, 149 | false, 150 | )); 151 | } 152 | } 153 | 154 | for (i, edge) in when_vertices[0].iter().rev().enumerate() { 155 | if !visited.borrow().contains(edge.vertex_id) { 156 | if (i + 1) == when_vertices[0].len() { 157 | stack.get_mut().push(( 158 | edge.vertex_id, 159 | next_row_index, 160 | next_ident_level, 161 | true, 162 | )); 163 | } else { 164 | stack.get_mut().push(( 165 | edge.vertex_id, 166 | next_row_index, 167 | next_ident_level, 168 | false, 169 | )); 170 | } 171 | } 172 | } 173 | 174 | for edge in when_vertices[1].iter().rev() { 175 | if !visited.borrow().contains(edge.vertex_id) { 176 | stack.get_mut().push(( 177 | edge.vertex_id, 178 | next_row_index, 179 | next_ident_level, 180 | false, 181 | )); 182 | } 183 | } 184 | } else { 185 | for edge in vertices { 186 | if !visited.borrow().contains(edge.vertex_id) { 187 | stack.get_mut().push(( 188 | edge.vertex_id, 189 | next_row_index, 190 | next_ident_level, 191 | false, 192 | )); 193 | } 194 | } 195 | } 196 | } 197 | } 198 | 199 | pub fn create_pipeline_vertex_ids(&self, selected_pipeline: &PipelineTableItem) -> Vec { 200 | let mut visited: RefCell> = RefCell::new(HashSet::with_capacity( 201 | selected_pipeline.graph.vertices.len(), 202 | )); 203 | let mut stack: GraphVisitorStack = RefCell::new(Vec::new()); 204 | let mut head_stack = self.heads.to_vec(); 205 | if head_stack.is_empty() { 206 | return vec![]; 207 | } 208 | 209 | // Add first head 210 | let first_head = head_stack.pop().unwrap(); 211 | visited.get_mut().insert(first_head); 212 | stack.get_mut().push((first_head, None, 0, false)); 213 | 214 | let mut table_rows: Vec = Vec::with_capacity(selected_pipeline.graph.edges.len()); 215 | while let Some((vertex_id, mut next_row_index, _, add_else_row)) = stack.get_mut().pop() { 216 | visited.get_mut().insert(vertex_id); 217 | 218 | let vertex = &self.data.get(vertex_id).unwrap().value; 219 | 220 | let mut push_row = |row| { 221 | if let Some(i) = next_row_index { 222 | table_rows.insert(i as usize, row); 223 | next_row_index = Some(i + 1); 224 | } else { 225 | table_rows.push(row); 226 | } 227 | }; 228 | 229 | if add_else_row { 230 | push_row(Uuid::new_v4().to_string()); 231 | } 232 | 233 | push_row(vertex.id.to_string()); 234 | 235 | Self::process_vertices_edges( 236 | self, 237 | &vertex.r#type, 238 | vertex_id, 239 | 0, 240 | next_row_index, 241 | &mut visited, 242 | &mut stack, 243 | ); 244 | 245 | if stack.borrow().is_empty() && !head_stack.is_empty() { 246 | while let Some(vertex_id) = head_stack.pop() { 247 | stack.get_mut().push((vertex_id, Some(0), 0, false)); 248 | } 249 | } 250 | } 251 | 252 | table_rows 253 | } 254 | } 255 | -------------------------------------------------------------------------------- /src/cli/commands/tui/shared_state.rs: -------------------------------------------------------------------------------- 1 | use crate::api::stats::PipelineStats; 2 | use crate::commands::tui::app::AppData; 3 | use crate::commands::tui::charts::{TimestampChartState, DEFAULT_MAX_DATA_POINTS}; 4 | use crate::commands::tui::events::EventsListener; 5 | use crate::commands::tui::flow_charts::{FlowMetricDataPoint, PluginFlowMetricDataPoint}; 6 | use std::collections::HashMap; 7 | 8 | pub struct PluginFlowChartState { 9 | pub throughput: TimestampChartState, 10 | pub worker_utilization: TimestampChartState, 11 | pub worker_millis_per_event: TimestampChartState, 12 | } 13 | 14 | impl PluginFlowChartState { 15 | pub fn new() -> Self { 16 | PluginFlowChartState { 17 | throughput: Default::default(), 18 | worker_utilization: Default::default(), 19 | worker_millis_per_event: Default::default(), 20 | } 21 | } 22 | } 23 | 24 | pub struct PipelineFlowChartState { 25 | pub plugins_throughput: TimestampChartState, 26 | pub input_throughput: TimestampChartState, 27 | pub filter_throughput: TimestampChartState, 28 | pub output_throughput: TimestampChartState, 29 | pub queue_backpressure: TimestampChartState, 30 | pub worker_concurrency: TimestampChartState, 31 | pub queue_persisted_growth_bytes: TimestampChartState, 32 | pub queue_persisted_growth_events: TimestampChartState, 33 | } 34 | 35 | impl PipelineFlowChartState { 36 | pub fn new(pipeline_stats: &PipelineStats) -> Self { 37 | let mut state = PipelineFlowChartState { 38 | plugins_throughput: Default::default(), 39 | input_throughput: Default::default(), 40 | filter_throughput: Default::default(), 41 | output_throughput: Default::default(), 42 | queue_backpressure: Default::default(), 43 | worker_concurrency: Default::default(), 44 | queue_persisted_growth_bytes: TimestampChartState::with_negative_bounds( 45 | DEFAULT_MAX_DATA_POINTS, 46 | ), 47 | queue_persisted_growth_events: TimestampChartState::with_negative_bounds( 48 | DEFAULT_MAX_DATA_POINTS, 49 | ), 50 | }; 51 | 52 | state.input_throughput.push(FlowMetricDataPoint::new( 53 | pipeline_stats.flow.input_throughput.current, 54 | )); 55 | 56 | state.filter_throughput.push(FlowMetricDataPoint::new( 57 | pipeline_stats.flow.filter_throughput.current, 58 | )); 59 | 60 | state.output_throughput.push(FlowMetricDataPoint::new( 61 | pipeline_stats.flow.output_throughput.current, 62 | )); 63 | 64 | state 65 | .plugins_throughput 66 | .push(PluginFlowMetricDataPoint::new( 67 | pipeline_stats.flow.input_throughput.current, 68 | pipeline_stats.flow.filter_throughput.current, 69 | pipeline_stats.flow.output_throughput.current, 70 | )); 71 | 72 | state.queue_backpressure.push(FlowMetricDataPoint::new( 73 | pipeline_stats.flow.queue_backpressure.current, 74 | )); 75 | 76 | state.worker_concurrency.push(FlowMetricDataPoint::new( 77 | pipeline_stats.flow.worker_concurrency.current, 78 | )); 79 | 80 | state 81 | .queue_persisted_growth_bytes 82 | .push(FlowMetricDataPoint::new( 83 | pipeline_stats.flow.queue_persisted_growth_bytes.current, 84 | )); 85 | 86 | state 87 | .queue_persisted_growth_events 88 | .push(FlowMetricDataPoint::new( 89 | pipeline_stats.flow.queue_persisted_growth_events.current, 90 | )); 91 | 92 | state 93 | } 94 | } 95 | 96 | pub struct PipelineChartState { 97 | pub pipeline: PipelineFlowChartState, 98 | pub plugins: HashMap, 99 | } 100 | 101 | impl PipelineChartState { 102 | pub fn new(pipeline_stats: &PipelineStats) -> Self { 103 | let mut plugins_states: HashMap = 104 | HashMap::with_capacity(pipeline_stats.vertices.len()); 105 | 106 | for (name, plugin) in pipeline_stats.plugins.all() { 107 | if !plugins_states.contains_key(&name) { 108 | plugins_states.insert(name.to_string(), PluginFlowChartState::new()); 109 | } 110 | 111 | if let Some(plugin_flow) = &plugin.flow { 112 | let state = plugins_states.get_mut(&name).unwrap(); 113 | if let Some(metric) = &plugin_flow.throughput { 114 | state 115 | .throughput 116 | .push(FlowMetricDataPoint::new(metric.current)); 117 | } 118 | 119 | if let Some(metric) = &plugin_flow.worker_utilization { 120 | state 121 | .worker_utilization 122 | .push(FlowMetricDataPoint::new(metric.current)); 123 | } 124 | 125 | if let Some(metric) = &plugin_flow.worker_millis_per_event { 126 | state 127 | .worker_millis_per_event 128 | .push(FlowMetricDataPoint::new(metric.current)); 129 | } 130 | } 131 | } 132 | 133 | PipelineChartState { 134 | pipeline: PipelineFlowChartState::new(pipeline_stats), 135 | plugins: plugins_states, 136 | } 137 | } 138 | } 139 | 140 | pub struct SharedState { 141 | pipelines_flows_chart_state: HashMap, 142 | } 143 | 144 | impl SharedState { 145 | pub fn new() -> Self { 146 | SharedState { 147 | pipelines_flows_chart_state: Default::default(), 148 | } 149 | } 150 | 151 | pub(crate) fn pipeline_flows_chart_state( 152 | &self, 153 | pipeline: &String, 154 | ) -> Option<&PipelineChartState> { 155 | self.pipelines_flows_chart_state.get(pipeline) 156 | } 157 | 158 | pub(crate) fn pipeline_plugin_flows_chart_state( 159 | &self, 160 | pipeline: &String, 161 | plugin: &String, 162 | ) -> Option<&PluginFlowChartState> { 163 | if let Some(state) = self.pipeline_flows_chart_state(pipeline) { 164 | return state.plugins.get(plugin); 165 | } 166 | 167 | None 168 | } 169 | 170 | fn update_chart_flows_states(&mut self, app_data: &AppData) { 171 | if app_data.node_stats().is_none() { 172 | self.pipelines_flows_chart_state.clear(); 173 | return; 174 | } 175 | 176 | let node_stats = app_data.node_stats().unwrap(); 177 | for (pipeline_name, pipeline_stats) in &node_stats.pipelines { 178 | if !self.pipelines_flows_chart_state.contains_key(pipeline_name) { 179 | self.pipelines_flows_chart_state.insert( 180 | pipeline_name.to_string(), 181 | PipelineChartState::new(pipeline_stats), 182 | ); 183 | } 184 | 185 | let pipeline_chart_state = self 186 | .pipelines_flows_chart_state 187 | .get_mut(pipeline_name) 188 | .unwrap(); 189 | pipeline_chart_state 190 | .pipeline 191 | .input_throughput 192 | .push(FlowMetricDataPoint::new( 193 | pipeline_stats.flow.input_throughput.current, 194 | )); 195 | 196 | pipeline_chart_state 197 | .pipeline 198 | .filter_throughput 199 | .push(FlowMetricDataPoint::new( 200 | pipeline_stats.flow.filter_throughput.current, 201 | )); 202 | 203 | pipeline_chart_state 204 | .pipeline 205 | .output_throughput 206 | .push(FlowMetricDataPoint::new( 207 | pipeline_stats.flow.output_throughput.current, 208 | )); 209 | 210 | pipeline_chart_state 211 | .pipeline 212 | .plugins_throughput 213 | .push(PluginFlowMetricDataPoint::new( 214 | pipeline_stats.flow.input_throughput.current, 215 | pipeline_stats.flow.filter_throughput.current, 216 | pipeline_stats.flow.output_throughput.current, 217 | )); 218 | 219 | pipeline_chart_state 220 | .pipeline 221 | .worker_concurrency 222 | .push(FlowMetricDataPoint::new( 223 | pipeline_stats.flow.worker_concurrency.current, 224 | )); 225 | 226 | pipeline_chart_state 227 | .pipeline 228 | .queue_backpressure 229 | .push(FlowMetricDataPoint::new( 230 | pipeline_stats.flow.queue_backpressure.current, 231 | )); 232 | 233 | pipeline_chart_state 234 | .pipeline 235 | .queue_persisted_growth_bytes 236 | .push(FlowMetricDataPoint::new( 237 | pipeline_stats.flow.queue_persisted_growth_bytes.current, 238 | )); 239 | 240 | pipeline_chart_state 241 | .pipeline 242 | .queue_persisted_growth_events 243 | .push(FlowMetricDataPoint::new( 244 | pipeline_stats.flow.queue_persisted_growth_events.current, 245 | )); 246 | 247 | for (plugin_name, plugin) in pipeline_stats.plugins.all() { 248 | if !pipeline_chart_state.plugins.contains_key(&plugin_name) { 249 | pipeline_chart_state 250 | .plugins 251 | .insert(plugin_name.to_string(), PluginFlowChartState::new()); 252 | } 253 | 254 | if let Some(plugin_flow) = &plugin.flow { 255 | let plugin_state = pipeline_chart_state.plugins.get_mut(&plugin_name).unwrap(); 256 | if let Some(metric) = &plugin_flow.throughput { 257 | plugin_state 258 | .throughput 259 | .push(FlowMetricDataPoint::new(metric.current)); 260 | } 261 | if let Some(metric) = &plugin_flow.worker_utilization { 262 | plugin_state 263 | .worker_utilization 264 | .push(FlowMetricDataPoint::new(metric.current)); 265 | } 266 | if let Some(metric) = &plugin_flow.worker_millis_per_event { 267 | plugin_state 268 | .worker_millis_per_event 269 | .push(FlowMetricDataPoint::new(metric.current)); 270 | } 271 | } 272 | } 273 | } 274 | } 275 | } 276 | 277 | impl EventsListener for SharedState { 278 | fn update(&mut self, app_data: &AppData) { 279 | self.update_chart_flows_states(app_data); 280 | } 281 | 282 | fn reset(&mut self) { 283 | self.pipelines_flows_chart_state.clear(); 284 | } 285 | } 286 | -------------------------------------------------------------------------------- /src/cli/commands/tui/threads/ui.rs: -------------------------------------------------------------------------------- 1 | use std::cmp::max; 2 | use std::collections::HashMap; 3 | 4 | use ratatui::layout::{Constraint, Direction, Layout, Rect}; 5 | use ratatui::prelude::{Line, Span, Style, Stylize, Text}; 6 | 7 | use ratatui::style::Color; 8 | use ratatui::widgets::{Block, Borders, Cell, Paragraph, Row, Table}; 9 | use ratatui::Frame; 10 | use time::format_description; 11 | 12 | use crate::commands::tui::app::App; 13 | use crate::commands::tui::threads::state::{ThreadTableItem, ThreadsState}; 14 | use crate::commands::tui::widgets::{ 15 | TABLE_HEADER_CELL_STYLE, TABLE_HEADER_ROW_STYLE, TABLE_SELECTED_ROW_STYLE, 16 | TABLE_SELECTED_ROW_SYMBOL, 17 | }; 18 | 19 | pub(crate) fn draw_threads_tab(f: &mut Frame, app: &mut App, area: Rect) { 20 | let chunks = Layout::default() 21 | .constraints([Constraint::Percentage(100)].as_ref()) 22 | .split(area); 23 | 24 | draw_threads_widgets(f, app, chunks[0]); 25 | } 26 | 27 | pub(crate) fn threads_tab_shortcuts_help(_: &App) -> HashMap { 28 | let mut keys = HashMap::with_capacity(1); 29 | keys.insert("[↵]".to_string(), "view thread traces".to_string()); 30 | keys 31 | } 32 | 33 | fn draw_threads_widgets(f: &mut Frame, app: &mut App, area: Rect) { 34 | let chunks = Layout::default() 35 | .constraints(vec![Constraint::Min(0), Constraint::Length(2)]) 36 | .direction(Direction::Vertical) 37 | .split(area); 38 | 39 | let tables_constraints = if app.threads_state.show_selected_thread { 40 | vec![Constraint::Percentage(40), Constraint::Percentage(60)] 41 | } else { 42 | vec![Constraint::Percentage(100)] 43 | }; 44 | 45 | let tables_chunks = Layout::default() 46 | .constraints(tables_constraints) 47 | .direction(Direction::Horizontal) 48 | .split(chunks[0]); 49 | 50 | draw_threads_table(f, app, tables_chunks[0]); 51 | 52 | if app.threads_state.show_selected_thread { 53 | draw_selected_thread_traces(f, app, tables_chunks[1]); 54 | } 55 | 56 | draw_labels_panel(f, chunks[1]); 57 | } 58 | 59 | fn draw_labels_panel(f: &mut Frame, area: Rect) { 60 | let states_labels = ["NEW", "RUNNABLE", "BLOCKED", "WAITING", "TIMED_WAITING"]; 61 | 62 | let mut spans = Vec::with_capacity(states_labels.len() * 2); 63 | for state in states_labels { 64 | spans.push(Span::styled( 65 | " ■", 66 | Style::default().fg(thread_state_color(state)), 67 | )); 68 | spans.push(Span::styled( 69 | format!(" {}", state), 70 | Style::default().fg(Color::DarkGray), 71 | )); 72 | } 73 | 74 | f.render_widget(Paragraph::new(Line::from(spans)), area); 75 | } 76 | 77 | fn draw_selected_thread_traces(f: &mut Frame, app: &mut App, area: Rect) { 78 | let rows: Vec = app 79 | .threads_state 80 | .selected_thread_traces 81 | .items 82 | .iter() 83 | .enumerate() 84 | .map(|(i, value)| { 85 | let mut row_value = value.to_string(); 86 | if let Some(selected) = app.threads_state.selected_thread_traces.state.selected() { 87 | if selected == i 88 | && app.threads_state.selected_thread_trace_value_offset < value.len() 89 | { 90 | let full_value = value.to_string(); 91 | let strip_value = 92 | &full_value[app.threads_state.selected_thread_trace_value_offset..]; 93 | row_value = strip_value.to_string(); 94 | } 95 | } 96 | 97 | Row::new(vec![Cell::from(Text::from(row_value.to_string()))]) 98 | }) 99 | .collect(); 100 | 101 | let traces_block_title = if let Some(thread) = app.threads_state.threads_table.selected_item() { 102 | thread.name.to_string() 103 | } else { 104 | "Traces".to_string() 105 | }; 106 | 107 | let traces = Table::new(rows, vec![Constraint::Percentage(100)]) 108 | .block( 109 | Block::default() 110 | .borders(Borders::ALL) 111 | .title(Span::from(traces_block_title).bold()), 112 | ) 113 | .column_spacing(2) 114 | .row_highlight_style(TABLE_SELECTED_ROW_STYLE) 115 | .highlight_symbol(TABLE_SELECTED_ROW_SYMBOL); 116 | 117 | f.render_stateful_widget( 118 | traces, 119 | area, 120 | &mut app.threads_state.selected_thread_traces.state, 121 | ); 122 | } 123 | 124 | fn draw_threads_table(f: &mut Frame, app: &mut App, area: Rect) { 125 | let states_cell_size_percentage: u16 = 44; 126 | let mut thread_skipped_states: usize = 0; 127 | 128 | let rows: Vec = app 129 | .threads_state 130 | .threads_table 131 | .items 132 | .iter() 133 | .map(|i| { 134 | let (skipped_states, state_line) = create_thread_states_line( 135 | &app.threads_state, 136 | i, 137 | &states_cell_size_percentage, 138 | &area.width, 139 | ); 140 | if skipped_states != 0 { 141 | thread_skipped_states = skipped_states; 142 | } 143 | 144 | let cells = vec![ 145 | Cell::from(Text::from(i.id.to_string())), 146 | Cell::from(Line::from(vec![ 147 | Span::styled("■ ", Style::default().fg(thread_state_color(&i.state))), 148 | Span::raw(i.name.to_string()), 149 | ])), 150 | Cell::from(Text::from(format!("{} %", i.percent_of_cpu_time))), 151 | Cell::from(state_line), 152 | ]; 153 | 154 | Row::new(cells) 155 | }) 156 | .collect(); 157 | 158 | let headers = ["ID", "Name", "CPU Usage"]; 159 | 160 | let mut header_cells: Vec = headers 161 | .iter() 162 | .map(|h| Cell::from(*h).style(TABLE_HEADER_CELL_STYLE)) 163 | .collect(); 164 | 165 | header_cells.push(Cell::from(create_thread_states_title( 166 | &app.threads_state, 167 | &states_cell_size_percentage, 168 | &area.width, 169 | thread_skipped_states, 170 | ))); 171 | 172 | let header = Row::new(header_cells) 173 | .style(TABLE_HEADER_ROW_STYLE) 174 | .height(1); 175 | 176 | let widths: Vec = vec![ 177 | Constraint::Percentage(4), // Thread ID 178 | Constraint::Percentage(45), // Name 179 | Constraint::Percentage(7), // CPU Usage 180 | Constraint::Percentage(states_cell_size_percentage), // States 181 | ]; 182 | 183 | let busiest_threads = if let Some(hot_threads) = app.data.read().unwrap().hot_threads() { 184 | hot_threads.hot_threads.busiest_threads 185 | } else { 186 | app.threads_state.threads_table.items.len() as u64 187 | }; 188 | 189 | let threads = Table::new(rows, widths) 190 | .header(header) 191 | .block( 192 | Block::default() 193 | .borders(Borders::ALL) 194 | .title(format!("Busiest {} threads", busiest_threads)), 195 | ) 196 | .column_spacing(2) 197 | .row_highlight_style(TABLE_SELECTED_ROW_STYLE) 198 | .highlight_symbol(TABLE_SELECTED_ROW_SYMBOL); 199 | 200 | f.render_stateful_widget(threads, area, &mut app.threads_state.threads_table.state); 201 | } 202 | 203 | fn create_thread_states_title<'a>( 204 | threads_state: &ThreadsState, 205 | line_constraint_percentage: &u16, 206 | area_width: &u16, 207 | thread_skipped_states: usize, 208 | ) -> Line<'a> { 209 | let format = format_description::parse("[hour]:[minute]:[second]").unwrap(); 210 | let oldest = threads_state 211 | .threads_table_states_times 212 | .front() 213 | .map(|p| p.format(&format).unwrap_or("-".to_string())) 214 | .unwrap_or("-".to_string()); 215 | 216 | let oldest_span = Span::styled(oldest, TABLE_HEADER_CELL_STYLE); 217 | if thread_skipped_states == 0 { 218 | return Line::from(vec![oldest_span]); 219 | } 220 | 221 | let max_line_width = max( 222 | ((*line_constraint_percentage as f64 / 100.0) * (*area_width as f64)) as i64, 223 | 1, 224 | ); 225 | 226 | let cells_size = max(max_line_width / 3, 1); 227 | let middle = threads_state 228 | .threads_table_states_times 229 | .get(threads_state.threads_table_states_times.len() / 2) 230 | .map(|p| p.format(&format).unwrap_or("-".to_string())) 231 | .unwrap_or("-".to_string()); 232 | 233 | let newest = threads_state 234 | .threads_table_states_times 235 | .back() 236 | .map(|p| p.format(&format).unwrap_or("-".to_string())) 237 | .unwrap_or("-".to_string()); 238 | 239 | let middle_span = Span::styled(middle, TABLE_HEADER_CELL_STYLE); 240 | let newest_span = Span::styled(newest, TABLE_HEADER_CELL_STYLE); 241 | 242 | let oldest_span_width = oldest_span.width() as i64; 243 | let middle_span_width = middle_span.width() as i64; 244 | let newest_span_width = newest_span.width() as i64; 245 | 246 | let spans: Vec = vec![ 247 | oldest_span, 248 | Span::raw(" ".repeat(max(cells_size - oldest_span_width, 1) as usize)), 249 | Span::raw(" ".repeat(max((cells_size - middle_span_width) / 2, 0) as usize)), 250 | middle_span, 251 | Span::raw(" ".repeat(max(cells_size - middle_span_width, 1) as usize)), 252 | Span::raw(" ".repeat(max(((cells_size - 1) - newest_span_width) / 2, 0) as usize)), 253 | newest_span, 254 | ]; 255 | 256 | Line::from(spans) 257 | } 258 | 259 | fn create_thread_states_line<'a>( 260 | threads_state: &ThreadsState, 261 | thread_table_item: &ThreadTableItem, 262 | line_constraint_percentage: &u16, 263 | area_width: &u16, 264 | ) -> (usize, Line<'a>) { 265 | let bar = "▆"; 266 | let bar_width = Span::raw(bar).width(); 267 | 268 | if let Some(states) = threads_state 269 | .threads_table_states 270 | .get(&thread_table_item.id) 271 | { 272 | let max_line_width = (((*line_constraint_percentage as f64 / 100.0) * (*area_width as f64)) 273 | as usize) 274 | - bar_width; 275 | let skip_states = if states.len() * bar_width >= max_line_width { 276 | let max_bars = max_line_width / bar_width; 277 | states.len() - max_bars 278 | } else { 279 | 0 280 | }; 281 | 282 | let mut spans: Vec = Vec::with_capacity(states.len() - skip_states); 283 | for state in states.iter().skip(skip_states) { 284 | spans.push(Span::styled( 285 | bar, 286 | Style::default().fg(thread_state_color(state)), 287 | )); 288 | } 289 | 290 | (skip_states, Line::from(spans)) 291 | } else { 292 | ( 293 | 0, 294 | Line::from(Span::styled( 295 | bar, 296 | Style::default().fg(thread_state_color(&thread_table_item.state)), 297 | )), 298 | ) 299 | } 300 | } 301 | 302 | fn thread_state_color(state: &str) -> Color { 303 | match state.to_uppercase().as_str() { 304 | "NEW" => Color::Blue, 305 | "RUNNABLE" => Color::Green, 306 | "BLOCKED" => Color::Red, 307 | "WAITING" => Color::LightYellow, 308 | "TIMED_WAITING" => Color::Indexed(208), 309 | "TERMINATED" => Color::Gray, 310 | _ => Color::Reset, 311 | } 312 | } 313 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | Apache License 2 | Version 2.0, January 2004 3 | http://www.apache.org/licenses/ 4 | 5 | TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION 6 | 7 | 1. Definitions. 8 | 9 | "License" shall mean the terms and conditions for use, reproduction, 10 | and distribution as defined by Sections 1 through 9 of this document. 11 | 12 | "Licensor" shall mean the copyright owner or entity authorized by 13 | the copyright owner that is granting the License. 14 | 15 | "Legal Entity" shall mean the union of the acting entity and all 16 | other entities that control, are controlled by, or are under common 17 | control with that entity. For the purposes of this definition, 18 | "control" means (i) the power, direct or indirect, to cause the 19 | direction or management of such entity, whether by contract or 20 | otherwise, or (ii) ownership of fifty percent (50%) or more of the 21 | outstanding shares, or (iii) beneficial ownership of such entity. 22 | 23 | "You" (or "Your") shall mean an individual or Legal Entity 24 | exercising permissions granted by this License. 25 | 26 | "Source" form shall mean the preferred form for making modifications, 27 | including but not limited to software source code, documentation 28 | source, and configuration files. 29 | 30 | "Object" form shall mean any form resulting from mechanical 31 | transformation or translation of a Source form, including but 32 | not limited to compiled object code, generated documentation, 33 | and conversions to other media types. 34 | 35 | "Work" shall mean the work of authorship, whether in Source or 36 | Object form, made available under the License, as indicated by a 37 | copyright notice that is included in or attached to the work 38 | (an example is provided in the Appendix below). 39 | 40 | "Derivative Works" shall mean any work, whether in Source or Object 41 | form, that is based on (or derived from) the Work and for which the 42 | editorial revisions, annotations, elaborations, or other modifications 43 | represent, as a whole, an original work of authorship. For the purposes 44 | of this License, Derivative Works shall not include works that remain 45 | separable from, or merely link (or bind by name) to the interfaces of, 46 | the Work and Derivative Works thereof. 47 | 48 | "Contribution" shall mean any work of authorship, including 49 | the original version of the Work and any modifications or additions 50 | to that Work or Derivative Works thereof, that is intentionally 51 | submitted to Licensor for inclusion in the Work by the copyright owner 52 | or by an individual or Legal Entity authorized to submit on behalf of 53 | the copyright owner. For the purposes of this definition, "submitted" 54 | means any form of electronic, verbal, or written communication sent 55 | to the Licensor or its representatives, including but not limited to 56 | communication on electronic mailing lists, source code control systems, 57 | and issue tracking systems that are managed by, or on behalf of, the 58 | Licensor for the purpose of discussing and improving the Work, but 59 | excluding communication that is conspicuously marked or otherwise 60 | designated in writing by the copyright owner as "Not a Contribution." 61 | 62 | "Contributor" shall mean Licensor and any individual or Legal Entity 63 | on behalf of whom a Contribution has been received by Licensor and 64 | subsequently incorporated within the Work. 65 | 66 | 2. Grant of Copyright License. Subject to the terms and conditions of 67 | this License, each Contributor hereby grants to You a perpetual, 68 | worldwide, non-exclusive, no-charge, royalty-free, irrevocable 69 | copyright license to reproduce, prepare Derivative Works of, 70 | publicly display, publicly perform, sublicense, and distribute the 71 | Work and such Derivative Works in Source or Object form. 72 | 73 | 3. Grant of Patent License. Subject to the terms and conditions of 74 | this License, each Contributor hereby grants to You a perpetual, 75 | worldwide, non-exclusive, no-charge, royalty-free, irrevocable 76 | (except as stated in this section) patent license to make, have made, 77 | use, offer to sell, sell, import, and otherwise transfer the Work, 78 | where such license applies only to those patent claims licensable 79 | by such Contributor that are necessarily infringed by their 80 | Contribution(s) alone or by combination of their Contribution(s) 81 | with the Work to which such Contribution(s) was submitted. If You 82 | institute patent litigation against any entity (including a 83 | cross-claim or counterclaim in a lawsuit) alleging that the Work 84 | or a Contribution incorporated within the Work constitutes direct 85 | or contributory patent infringement, then any patent licenses 86 | granted to You under this License for that Work shall terminate 87 | as of the date such litigation is filed. 88 | 89 | 4. Redistribution. You may reproduce and distribute copies of the 90 | Work or Derivative Works thereof in any medium, with or without 91 | modifications, and in Source or Object form, provided that You 92 | meet the following conditions: 93 | 94 | (a) You must give any other recipients of the Work or 95 | Derivative Works a copy of this License; and 96 | 97 | (b) You must cause any modified files to carry prominent notices 98 | stating that You changed the files; and 99 | 100 | (c) You must retain, in the Source form of any Derivative Works 101 | that You distribute, all copyright, patent, trademark, and 102 | attribution notices from the Source form of the Work, 103 | excluding those notices that do not pertain to any part of 104 | the Derivative Works; and 105 | 106 | (d) If the Work includes a "NOTICE" text file as part of its 107 | distribution, then any Derivative Works that You distribute must 108 | include a readable copy of the attribution notices contained 109 | within such NOTICE file, excluding those notices that do not 110 | pertain to any part of the Derivative Works, in at least one 111 | of the following places: within a NOTICE text file distributed 112 | as part of the Derivative Works; within the Source form or 113 | documentation, if provided along with the Derivative Works; or, 114 | within a display generated by the Derivative Works, if and 115 | wherever such third-party notices normally appear. The contents 116 | of the NOTICE file are for informational purposes only and 117 | do not modify the License. You may add Your own attribution 118 | notices within Derivative Works that You distribute, alongside 119 | or as an addendum to the NOTICE text from the Work, provided 120 | that such additional attribution notices cannot be construed 121 | as modifying the License. 122 | 123 | You may add Your own copyright statement to Your modifications and 124 | may provide additional or different license terms and conditions 125 | for use, reproduction, or distribution of Your modifications, or 126 | for any such Derivative Works as a whole, provided Your use, 127 | reproduction, and distribution of the Work otherwise complies with 128 | the conditions stated in this License. 129 | 130 | 5. Submission of Contributions. Unless You explicitly state otherwise, 131 | any Contribution intentionally submitted for inclusion in the Work 132 | by You to the Licensor shall be under the terms and conditions of 133 | this License, without any additional terms or conditions. 134 | Notwithstanding the above, nothing herein shall supersede or modify 135 | the terms of any separate license agreement you may have executed 136 | with Licensor regarding such Contributions. 137 | 138 | 6. Trademarks. This License does not grant permission to use the trade 139 | names, trademarks, service marks, or product names of the Licensor, 140 | except as required for reasonable and customary use in describing the 141 | origin of the Work and reproducing the content of the NOTICE file. 142 | 143 | 7. Disclaimer of Warranty. Unless required by applicable law or 144 | agreed to in writing, Licensor provides the Work (and each 145 | Contributor provides its Contributions) on an "AS IS" BASIS, 146 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or 147 | implied, including, without limitation, any warranties or conditions 148 | of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A 149 | PARTICULAR PURPOSE. You are solely responsible for determining the 150 | appropriateness of using or redistributing the Work and assume any 151 | risks associated with Your exercise of permissions under this License. 152 | 153 | 8. Limitation of Liability. In no event and under no legal theory, 154 | whether in tort (including negligence), contract, or otherwise, 155 | unless required by applicable law (such as deliberate and grossly 156 | negligent acts) or agreed to in writing, shall any Contributor be 157 | liable to You for damages, including any direct, indirect, special, 158 | incidental, or consequential damages of any character arising as a 159 | result of this License or out of the use or inability to use the 160 | Work (including but not limited to damages for loss of goodwill, 161 | work stoppage, computer failure or malfunction, or any and all 162 | other commercial damages or losses), even if such Contributor 163 | has been advised of the possibility of such damages. 164 | 165 | 9. Accepting Warranty or Additional Liability. While redistributing 166 | the Work or Derivative Works thereof, You may choose to offer, 167 | and charge a fee for, acceptance of support, warranty, indemnity, 168 | or other liability obligations and/or rights consistent with this 169 | License. However, in accepting such obligations, You may act only 170 | on Your own behalf and on Your sole responsibility, not on behalf 171 | of any other Contributor, and only if You agree to indemnify, 172 | defend, and hold each Contributor harmless for any liability 173 | incurred by, or claims asserted against, such Contributor by reason 174 | of your accepting any such warranty or additional liability. 175 | 176 | END OF TERMS AND CONDITIONS 177 | 178 | APPENDIX: How to apply the Apache License to your work. 179 | 180 | To apply the Apache License to your work, attach the following 181 | boilerplate notice, with the fields enclosed by brackets "[]" 182 | replaced with your own identifying information. (Don't include 183 | the brackets!) The text should be enclosed in the appropriate 184 | comment syntax for the file format. We also recommend that a 185 | file or class name and description of purpose be included on the 186 | same "printed page" as the copyright notice for easier 187 | identification within third-party archives. 188 | 189 | Copyright [yyyy] [name of copyright owner] 190 | 191 | Licensed under the Apache License, Version 2.0 (the "License"); 192 | you may not use this file except in compliance with the License. 193 | You may obtain a copy of the License at 194 | 195 | http://www.apache.org/licenses/LICENSE-2.0 196 | 197 | Unless required by applicable law or agreed to in writing, software 198 | distributed under the License is distributed on an "AS IS" BASIS, 199 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 200 | See the License for the specific language governing permissions and 201 | limitations under the License. 202 | -------------------------------------------------------------------------------- /src/cli/commands/tui/app.rs: -------------------------------------------------------------------------------- 1 | use std::sync::mpsc::RecvTimeoutError; 2 | use std::sync::mpsc::{channel, Sender}; 3 | use std::sync::{Arc, RwLock}; 4 | use std::thread; 5 | use std::thread::sleep; 6 | use std::time::Duration; 7 | 8 | use crate::api::hot_threads::NodeHotThreads; 9 | use crate::api::node::NodeInfo; 10 | use crate::api::stats::NodeStats; 11 | use crate::commands::tui::data_decorator; 12 | use crate::commands::tui::data_fetcher::{DataFetcher, NodeData}; 13 | use crate::commands::tui::events::EventsListener; 14 | use crate::commands::tui::flows::state::FlowsState; 15 | use crate::commands::tui::node::state::NodeState; 16 | use crate::commands::tui::pipelines::state::PipelinesState; 17 | use crate::commands::tui::shared_state::SharedState; 18 | use crate::commands::tui::threads::state::ThreadsState; 19 | use crate::commands::tui::widgets::TabsState; 20 | use crate::errors::AnyError; 21 | use crossterm::event::{KeyCode, KeyEvent}; 22 | 23 | pub(crate) struct AppData { 24 | errored: bool, 25 | last_error_message: Option, 26 | node_info: Option, 27 | node_stats: Option, 28 | hot_threads: Option, 29 | } 30 | 31 | impl AppData { 32 | fn new() -> Self { 33 | AppData { 34 | errored: false, 35 | last_error_message: None, 36 | node_stats: None, 37 | node_info: None, 38 | hot_threads: None, 39 | } 40 | } 41 | 42 | fn reset(&mut self) { 43 | self.node_info = None; 44 | self.node_stats = None; 45 | } 46 | 47 | fn handle_error(&mut self, error: &AnyError) { 48 | self.errored = true; 49 | self.last_error_message = Some(error.to_string()); 50 | self.reset(); 51 | } 52 | 53 | fn fetch_and_set(&mut self, data_fetcher: &dyn DataFetcher) { 54 | if let Ok(mut node_data) = data_fetcher.fetch_node_data(None) { 55 | data_decorator::decorate(&mut node_data.info, &mut node_data.stats); 56 | self.node_info = Some(node_data.info); 57 | self.node_stats = Some(node_data.stats); 58 | } 59 | 60 | if let Ok(hot_threads) = data_fetcher.fetch_hot_threads(None) { 61 | self.hot_threads = Some(hot_threads); 62 | } 63 | } 64 | 65 | fn get_fetched_data( 66 | data_fetcher: &dyn DataFetcher, 67 | data_tx: Sender<(NodeInfo, NodeStats, Option)>, 68 | error_tx: Sender, 69 | ) { 70 | let mut node_data: NodeData = match data_fetcher.fetch_node_data(None) { 71 | Ok(value) => value, 72 | Err(e) => { 73 | if e.downcast_ref::().is_none() { 74 | _ = error_tx.send(e); 75 | } 76 | return; 77 | } 78 | }; 79 | 80 | let hot_threads = match data_fetcher.fetch_hot_threads(Some(Duration::from_millis(100))) { 81 | Ok(data) => Some(data), 82 | Err(e) => { 83 | if e.downcast_ref::().is_none() { 84 | _ = error_tx.send(e); 85 | } 86 | None 87 | } 88 | }; 89 | 90 | data_decorator::decorate(&mut node_data.info, &mut node_data.stats); 91 | let result = (node_data.info, node_data.stats, hot_threads); 92 | _ = data_tx.send(result); 93 | } 94 | 95 | pub(crate) fn node_info(&self) -> Option<&NodeInfo> { 96 | self.node_info.as_ref() 97 | } 98 | 99 | pub(crate) fn node_stats(&self) -> Option<&NodeStats> { 100 | self.node_stats.as_ref() 101 | } 102 | 103 | pub(crate) fn hot_threads(&self) -> Option<&NodeHotThreads> { 104 | self.hot_threads.as_ref() 105 | } 106 | 107 | pub(crate) fn errored(&self) -> bool { 108 | self.errored 109 | } 110 | 111 | pub(crate) fn last_error_message(&self) -> &Option { 112 | &self.last_error_message 113 | } 114 | } 115 | 116 | pub(crate) struct App<'a> { 117 | pub title: String, 118 | pub should_quit: bool, 119 | pub show_help: bool, 120 | pub tabs: TabsState, 121 | pub shared_state: SharedState, 122 | pub node_state: NodeState, 123 | pub pipelines_state: PipelinesState<'a>, 124 | pub flows_state: FlowsState, 125 | pub threads_state: ThreadsState, 126 | pub data: Arc>, 127 | pub host: String, 128 | pub sampling_interval: Option, 129 | } 130 | 131 | impl<'a> App<'a> { 132 | pub const TAB_PIPELINES: usize = 0; 133 | pub const TAB_FLOWS: usize = 1; 134 | pub const TAB_THREADS: usize = 2; 135 | pub const TAB_NODE: usize = 3; 136 | 137 | pub fn new(title: String, host: String, sampling_interval: Option) -> App<'a> { 138 | App { 139 | title, 140 | sampling_interval, 141 | show_help: false, 142 | should_quit: false, 143 | tabs: TabsState::new(), 144 | pipelines_state: PipelinesState::new(), 145 | node_state: NodeState::new(), 146 | data: Arc::new(RwLock::new(AppData::new())), 147 | host, 148 | shared_state: SharedState::new(), 149 | flows_state: FlowsState::new(), 150 | threads_state: ThreadsState::new(), 151 | } 152 | } 153 | 154 | fn reset(&mut self) { 155 | { 156 | self.data.write().unwrap().reset(); 157 | } 158 | self.trigger_states_event(|listener, _| { 159 | listener.reset(); 160 | }); 161 | } 162 | 163 | pub fn handle_key_event(&mut self, key: KeyEvent) { 164 | let selected_tab = &self.tabs.index.clone(); 165 | match key.code { 166 | KeyCode::Left => { 167 | self.trigger_tab_event(selected_tab, |app_data, listener| { 168 | listener.on_left(app_data); 169 | }); 170 | } 171 | KeyCode::Right => { 172 | self.trigger_tab_event(selected_tab, |app_data, listener| { 173 | listener.on_right(app_data); 174 | }); 175 | } 176 | KeyCode::Up => { 177 | self.trigger_tab_event(selected_tab, |app_data, listener| { 178 | listener.on_up(app_data); 179 | }); 180 | } 181 | KeyCode::Down => { 182 | self.trigger_tab_event(selected_tab, |app_data, listener| { 183 | listener.on_down(app_data); 184 | }); 185 | } 186 | KeyCode::Char(c) => { 187 | self.on_key(c); 188 | self.trigger_tab_event(selected_tab, |app_data, listener| { 189 | listener.on_other(key, app_data); 190 | }); 191 | } 192 | KeyCode::Enter => { 193 | self.trigger_tab_event(selected_tab, |app_data, listener| { 194 | listener.on_enter(app_data); 195 | }); 196 | } 197 | _ => { 198 | self.trigger_tab_event(selected_tab, |app_data, listener| { 199 | listener.on_other(key, app_data); 200 | }); 201 | } 202 | } 203 | } 204 | 205 | pub fn on_key(&mut self, c: char) { 206 | match c.to_lowercase().to_string().as_str() { 207 | "q" => { 208 | self.on_esc(); 209 | } 210 | "h" => { 211 | let visible = !self.show_help; 212 | self.show_help = visible; 213 | } 214 | "p" => { 215 | self.select_tab(Self::TAB_PIPELINES); 216 | } 217 | "f" => { 218 | self.select_tab(Self::TAB_FLOWS); 219 | } 220 | "n" => { 221 | self.select_tab(Self::TAB_NODE); 222 | } 223 | "t" => { 224 | self.select_tab(Self::TAB_THREADS); 225 | } 226 | _ => {} 227 | } 228 | } 229 | 230 | pub fn on_esc(&mut self) { 231 | self.should_quit = true; 232 | } 233 | 234 | pub fn set_data(&mut self, data_fetcher: &dyn DataFetcher) { 235 | self.data.write().unwrap().fetch_and_set(data_fetcher); 236 | } 237 | 238 | pub fn start_reading_data(&self, data_fetcher: Box, interval: Duration) { 239 | let (data_tx, data_rx) = channel::<(NodeInfo, NodeStats, Option)>(); 240 | let (error_tx, error_rx) = channel::(); 241 | 242 | thread::Builder::new() 243 | .name("app-data-get-fetched-data".to_string()) 244 | .spawn(move || loop { 245 | AppData::get_fetched_data(data_fetcher.as_ref(), data_tx.clone(), error_tx.clone()); 246 | sleep(interval); 247 | }) 248 | .unwrap(); 249 | 250 | let data = self.data.clone(); 251 | thread::Builder::new() 252 | .name("app-data-fetched-data-receiver".to_string()) 253 | .spawn(move || loop { 254 | if let Ok(values) = data_rx.recv() { 255 | let mut data = data.write().unwrap(); 256 | data.node_info = Some(values.0); 257 | data.node_stats = Some(values.1); 258 | data.hot_threads = values.2; 259 | data.errored = false; 260 | data.last_error_message = None; 261 | } 262 | sleep(interval); 263 | }) 264 | .unwrap(); 265 | 266 | let data = self.data.clone(); 267 | thread::Builder::new() 268 | .name("app-data-fetch-api-errors".to_string()) 269 | .spawn(move || loop { 270 | if let Ok(values) = error_rx.recv() { 271 | data.write().unwrap().handle_error(&values); 272 | } 273 | sleep(interval); 274 | }) 275 | .unwrap(); 276 | } 277 | 278 | pub fn wait_node_data(&self) { 279 | loop { 280 | { 281 | let data = self.data.read().unwrap(); 282 | if data.errored { 283 | break; 284 | } 285 | if data.node_info.is_some() && data.node_stats.is_some() { 286 | return; 287 | } 288 | } 289 | sleep(Duration::from_millis(100)); 290 | } 291 | } 292 | 293 | pub fn on_tick(&mut self) { 294 | { 295 | if self.data.read().unwrap().errored { 296 | self.reset(); 297 | return; 298 | } 299 | } 300 | 301 | self.trigger_states_event(|listener, app_data| { 302 | listener.update(app_data); 303 | }); 304 | } 305 | 306 | fn trigger_states_event(&mut self, func: impl Fn(&mut dyn EventsListener, &AppData)) { 307 | let listeners: Vec<&mut dyn EventsListener> = vec![ 308 | &mut self.shared_state, 309 | &mut self.pipelines_state, 310 | &mut self.flows_state, 311 | &mut self.node_state, 312 | &mut self.threads_state, 313 | ]; 314 | 315 | for listener in listeners { 316 | func(listener, &self.data.read().unwrap()); 317 | } 318 | } 319 | 320 | fn select_tab(&mut self, new_tab: usize) { 321 | self.tabs.select(new_tab); 322 | 323 | self.trigger_tab_event(&self.tabs.index.clone(), |app_data, listener| { 324 | listener.focus_lost(app_data); 325 | }); 326 | 327 | self.trigger_tab_event(&new_tab, |app_data, listener| { 328 | listener.focus_gained(app_data); 329 | }); 330 | } 331 | 332 | fn trigger_tab_event(&mut self, tab: &usize, func: impl Fn(&AppData, &mut dyn EventsListener)) { 333 | let listener: Option<&mut dyn EventsListener> = match *tab { 334 | Self::TAB_PIPELINES => Some(&mut self.pipelines_state), 335 | Self::TAB_NODE => Some(&mut self.node_state), 336 | Self::TAB_FLOWS => Some(&mut self.flows_state), 337 | Self::TAB_THREADS => Some(&mut self.threads_state), 338 | _ => None, 339 | }; 340 | 341 | if let Some(value) = listener { 342 | func(&self.data.read().unwrap(), value); 343 | } 344 | } 345 | } 346 | -------------------------------------------------------------------------------- /src/cli/commands/tui/data_fetcher.rs: -------------------------------------------------------------------------------- 1 | use crate::api::hot_threads::{HotThreads, NodeHotThreads, Thread}; 2 | use crate::api::node::{NodeInfo, NodeInfoType}; 3 | use crate::api::stats::NodeStats; 4 | use crate::api::Client; 5 | use crate::errors::{AnyError, TuiError}; 6 | use regex::{Captures, Regex, RegexBuilder}; 7 | use std::fs::File; 8 | use std::io::{BufRead, BufReader, Read}; 9 | use std::path::Path; 10 | use std::sync::mpsc::TrySendError; 11 | use std::sync::mpsc::{sync_channel, Receiver, SyncSender}; 12 | use std::sync::{Arc, Mutex}; 13 | use std::time::Duration; 14 | use std::{fs, thread}; 15 | 16 | pub(crate) struct NodeData { 17 | pub info: NodeInfo, 18 | pub stats: NodeStats, 19 | } 20 | 21 | pub(crate) trait DataFetcher: Sync + Send { 22 | fn fetch_node_data(&self, timeout: Option) -> Result; 23 | fn fetch_hot_threads(&self, timeout: Option) -> Result; 24 | } 25 | 26 | pub struct ApiDataFetcher { 27 | client: Arc, 28 | node_data_tx: SyncSender>, 29 | node_data_rx: Arc>>>, 30 | hot_threads_tx: SyncSender>, 31 | hot_threads_rx: Arc>>>, 32 | } 33 | 34 | impl ApiDataFetcher { 35 | pub fn new(client: Client) -> ApiDataFetcher { 36 | let (node_data_tx, node_data_rx) = sync_channel::>(10); 37 | let (hot_threads_tx, hot_threads_rx) = sync_channel::>(10); 38 | 39 | ApiDataFetcher { 40 | client: Arc::new(client), 41 | node_data_tx, 42 | node_data_rx: Arc::new(Mutex::new(node_data_rx)), 43 | hot_threads_tx, 44 | hot_threads_rx: Arc::new(Mutex::new(hot_threads_rx)), 45 | } 46 | } 47 | 48 | pub fn start_polling(&self, interval: Duration) { 49 | let node_data_tx = self.node_data_tx.clone(); 50 | let client = Arc::clone(&self.client); 51 | thread::Builder::new() 52 | .name("api-data-fetcher-node-data".to_string()) 53 | .spawn(move || loop { 54 | let node_info = match client.get_node_info( 55 | &[NodeInfoType::Pipelines], 56 | Some(Client::QUERY_NODE_INFO_GRAPH), 57 | ) { 58 | Ok(value) => value, 59 | Err(e) => { 60 | _ = node_data_tx.send(Err(e)); 61 | continue; 62 | } 63 | }; 64 | 65 | let node_stats = 66 | match client.get_node_stats(Some(Client::QUERY_NODE_STATS_VERTICES)) { 67 | Ok(value) => value, 68 | Err(e) => { 69 | _ = node_data_tx.send(Err(e)); 70 | continue; 71 | } 72 | }; 73 | 74 | let data = NodeData { 75 | info: node_info, 76 | stats: node_stats, 77 | }; 78 | 79 | if let Err(TrySendError::Disconnected(_)) = node_data_tx.try_send(Ok(data)) { 80 | break; 81 | } 82 | 83 | thread::sleep(interval); 84 | }) 85 | .unwrap(); 86 | 87 | let hot_threads_tx = self.hot_threads_tx.clone(); 88 | let client = Arc::clone(&self.client); 89 | thread::Builder::new() 90 | .name("api-data-fetcher-hot-threads".to_string()) 91 | .spawn(move || loop { 92 | let res = client.get_hot_threads(Some(&[ 93 | ("threads", "500"), 94 | ("ignore_idle_threads", "false"), 95 | ])); 96 | if hot_threads_tx.send(res).is_err() { 97 | break; 98 | } 99 | thread::sleep(interval); 100 | }) 101 | .unwrap(); 102 | } 103 | } 104 | 105 | impl DataFetcher for ApiDataFetcher { 106 | fn fetch_node_data(&self, timeout: Option) -> Result { 107 | self.node_data_rx 108 | .lock() 109 | .unwrap() 110 | .recv_timeout(timeout.unwrap_or(Duration::MAX))? 111 | } 112 | 113 | fn fetch_hot_threads(&self, timeout: Option) -> Result { 114 | let res = self 115 | .hot_threads_rx 116 | .lock() 117 | .unwrap() 118 | .recv_timeout(timeout.unwrap_or(Duration::MAX)); 119 | 120 | res? 121 | } 122 | } 123 | 124 | pub(crate) struct PathDataFetcher { 125 | path: String, 126 | } 127 | 128 | const LOGSTASH_NODE_FILE: &str = "logstash_node.json"; 129 | const LOGSTASH_NODE_GRAPH_FILE: &str = "logstash_node_graph.json"; 130 | const LOGSTASH_NODE_STATS_FILE: &str = "logstash_node_stats.json"; 131 | const LOGSTASH_NODE_STATS_VERTICES_FILE: &str = "logstash_node_stats_vertices.json"; 132 | const LOGSTASH_NODE_HOT_THREADS_FILE: &str = "logstash_nodes_hot_threads.json"; 133 | const LOGSTASH_DIAGNOSTIC_FILES: &[&str; 3] = &[ 134 | LOGSTASH_NODE_FILE, 135 | LOGSTASH_NODE_STATS_FILE, 136 | LOGSTASH_NODE_HOT_THREADS_FILE, 137 | ]; 138 | 139 | impl PathDataFetcher { 140 | pub fn new(path: String) -> Result { 141 | if let Err(err) = PathDataFetcher::validate_path(&path) { 142 | Err(From::from(err)) 143 | } else { 144 | Ok(PathDataFetcher { path }) 145 | } 146 | } 147 | 148 | fn fetch_info(path: &str, _timeout: Option) -> Result { 149 | let node_with_graphs = Path::new(path).join(LOGSTASH_NODE_GRAPH_FILE); 150 | let path = if node_with_graphs.exists() { 151 | node_with_graphs 152 | } else { 153 | Path::new(path).join(LOGSTASH_NODE_FILE) 154 | }; 155 | 156 | match fs::read_to_string(path) { 157 | Ok(data) => { 158 | let node_info: NodeInfo = serde_json::from_str(data.as_str())?; 159 | Ok(node_info) 160 | } 161 | Err(err) => Err(err.into()), 162 | } 163 | } 164 | 165 | fn fetch_stats(path: &str, _timeout: Option) -> Result { 166 | let stats_with_vertices = Path::new(path).join(LOGSTASH_NODE_STATS_VERTICES_FILE); 167 | let path = if stats_with_vertices.exists() { 168 | stats_with_vertices 169 | } else { 170 | Path::new(path).join(LOGSTASH_NODE_STATS_FILE) 171 | }; 172 | 173 | match fs::read_to_string(path) { 174 | Ok(data) => { 175 | let node_stats: NodeStats = serde_json::from_str(data.as_str())?; 176 | Ok(node_stats) 177 | } 178 | Err(err) => Err(err.into()), 179 | } 180 | } 181 | 182 | fn validate_path(path: &str) -> Result<(), TuiError> { 183 | let mut missing_files = vec![]; 184 | 185 | for file in LOGSTASH_DIAGNOSTIC_FILES { 186 | let file_path = Path::new(path).join(file); 187 | if !file_path.exists() || file_path.is_dir() { 188 | missing_files.push(file.to_string()); 189 | } 190 | } 191 | 192 | if missing_files.is_empty() { 193 | Ok(()) 194 | } else { 195 | let message = format!( 196 | "File(s) {} not found on the provided diagnostic path", 197 | missing_files.join(", ").as_str() 198 | ); 199 | 200 | Err(TuiError::from(message.as_str())) 201 | } 202 | } 203 | 204 | fn parse_hot_threads_human_file(&self) -> Result { 205 | let file = File::open(Path::new(self.path.as_str()).join(LOGSTASH_NODE_HOT_THREADS_FILE))?; 206 | let file_buffer = BufReader::new(file); 207 | 208 | let mut hot_threads: HotThreads = HotThreads::default(); 209 | let header_regex = 210 | Regex::new(r"Hot threads at (?