diff --git a/Cargo.lock b/Cargo.lock index 9564fdb..28e9b2b 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -941,6 +941,7 @@ dependencies = [ "env_logger", "harmony", "harmony_macros", + "harmony_tui", "harmony_types", "log", "tokio", @@ -1339,6 +1340,8 @@ dependencies = [ "log", "log-panics", "ratatui", + "serde-value", + "serde_json", "tokio", "tokio-stream", "tui-logger", diff --git a/examples/lamp/Cargo.toml b/examples/lamp/Cargo.toml index 1bdcf68..902548e 100644 --- a/examples/lamp/Cargo.toml +++ b/examples/lamp/Cargo.toml @@ -8,7 +8,7 @@ publish = false [dependencies] harmony = { path = "../../harmony" } -#harmony_tui = { path = "../../harmony_tui" } +harmony_tui = { path = "../../harmony_tui" } harmony_types = { path = "../../harmony_types" } cidr = { workspace = true } tokio = { workspace = true } diff --git a/examples/lamp/src/main.rs b/examples/lamp/src/main.rs index 7277aa5..feb8b4f 100644 --- a/examples/lamp/src/main.rs +++ b/examples/lamp/src/main.rs @@ -1,5 +1,6 @@ use harmony::{ data::Version, + inventory::Inventory, maestro::Maestro, modules::lamp::{LAMPConfig, LAMPScore}, topology::{HAClusterTopology, Url}, @@ -17,8 +18,9 @@ async fn main() { }, }; - Maestro::::load_from_env() - .interpret(Box::new(lamp_stack)) - .await - .unwrap(); + let inventory = Inventory::autoload(); + let topology = HAClusterTopology::autoload(); + let mut maestro = Maestro::new(inventory, topology); + maestro.register_all(vec![Box::new(lamp_stack)]); + harmony_tui::init(maestro).await.unwrap(); } diff --git a/examples/tui/src/main.rs b/examples/tui/src/main.rs index 05a768b..5ed607a 100644 --- a/examples/tui/src/main.rs +++ b/examples/tui/src/main.rs @@ -1,9 +1,20 @@ +use std::net::{SocketAddr, SocketAddrV4}; + use harmony::{ inventory::Inventory, maestro::Maestro, - modules::dummy::{ErrorScore, PanicScore, SuccessScore}, - topology::HAClusterTopology, + modules::{ + dns::DnsScore, + dummy::{ErrorScore, PanicScore, SuccessScore}, + load_balancer::LoadBalancerScore, + okd::load_balancer::OKDLoadBalancerScore, + }, + topology::{ + BackendServer, HAClusterTopology, HealthCheck, HttpMethod, HttpStatusCode, + LoadBalancerService, + }, }; +use harmony_macros::ipv4; #[tokio::main] async fn main() { @@ -11,10 +22,52 @@ async fn main() { let topology = HAClusterTopology::autoload(); let mut maestro = Maestro::new(inventory, topology); + maestro.register_all(vec![ Box::new(SuccessScore {}), Box::new(ErrorScore {}), Box::new(PanicScore {}), + Box::new(DnsScore::new(vec![], None)), + Box::new(build_large_score()), ]); harmony_tui::init(maestro).await.unwrap(); } + +fn build_large_score() -> LoadBalancerScore { + let backend_server = BackendServer { + address: "192.168.0.0".to_string(), + port: 342, + }; + let lb_service = LoadBalancerService { + backend_servers: vec![ + backend_server.clone(), + backend_server.clone(), + backend_server.clone(), + ], + listening_port: SocketAddr::V4(SocketAddrV4::new(ipv4!("192.168.0.0"), 49387)), + health_check: Some(HealthCheck::HTTP( + "/some_long_ass_path_to_see_how_it_is_displayed_but_it_has_to_be_even_longer" + .to_string(), + HttpMethod::GET, + HttpStatusCode::Success2xx, + )), + }; + LoadBalancerScore { + public_services: vec![ + lb_service.clone(), + lb_service.clone(), + lb_service.clone(), + lb_service.clone(), + lb_service.clone(), + lb_service.clone(), + ], + private_services: vec![ + lb_service.clone(), + lb_service.clone(), + lb_service.clone(), + lb_service.clone(), + lb_service.clone(), + lb_service.clone(), + ], + } +} diff --git a/harmony/src/domain/score.rs b/harmony/src/domain/score.rs index dbe7aa5..a48548c 100644 --- a/harmony/src/domain/score.rs +++ b/harmony/src/domain/score.rs @@ -1,10 +1,12 @@ +use std::collections::BTreeMap; + use serde::Serialize; use serde_value::Value; use super::{interpret::Interpret, topology::Topology}; pub trait Score: - std::fmt::Debug + Send + Sync + CloneBoxScore + SerializeScore + std::fmt::Debug + ScoreToString + Send + Sync + CloneBoxScore + SerializeScore { fn create_interpret(&self) -> Box>; fn name(&self) -> String; @@ -39,3 +41,191 @@ where Box::new(self.clone()) } } + +pub trait ScoreToString { + fn print_score_details(&self) -> String; + fn format_value_as_string(&self, val: &Value, indent: usize) -> String; + fn format_map(&self, map: &BTreeMap, indent: usize) -> String; + fn wrap_or_truncate(&self, s: &str, width: usize) -> Vec; +} + +impl ScoreToString for S +where + T: Topology, + S: Score + 'static, +{ + fn print_score_details(&self) -> String { + let mut output = String::new(); + output += "\n"; + output += &self.format_value_as_string(&self.serialize(), 0); + output += "\n"; + output + } + fn format_map(&self, map: &BTreeMap, indent: usize) -> String { + let pad = " ".repeat(indent * 2); + let mut output = String::new(); + + output += &format!( + "{}+--------------------------+--------------------------------------------------+\n", + pad + ); + output += &format!("{}| {:<24} | {:<48} |\n", pad, "score_name", self.name()); + output += &format!( + "{}+--------------------------+--------------------------------------------------+\n", + pad + ); + + for (k, v) in map { + let key_str = match k { + Value::String(s) => s.clone(), + other => format!("{:?}", other), + }; + + let formatted_val = self.format_value_as_string(v, indent + 1); + let mut lines = formatted_val.lines().map(|line| line.trim_start()); + + let wrapped_lines: Vec<_> = lines + .flat_map(|line| self.wrap_or_truncate(line.trim_start(), 48)) + .collect(); + + if let Some(first) = wrapped_lines.first() { + output += &format!("{}| {:<24} | {:<48} |\n", pad, key_str, first); + for line in &wrapped_lines[1..] { + output += &format!("{}| {:<24} | {:<48} |\n", pad, "", line); + } + } + + // let first_line = lines.next().unwrap_or(""); + // output += &format!("{}| {:<24} | {:<48} |\n", pad, key_str, first_line); + // + // for line in lines { + // output += &format!("{}| {:<24} | {:<48} |\n", pad, "", line); + // } + } + + output += &format!( + "{}+--------------------------+--------------------------------------------------+\n\n", + pad + ); + + output + } + + fn wrap_or_truncate(&self, s: &str, width: usize) -> Vec { + let mut lines = Vec::new(); + let mut current = s; + + while !current.is_empty() { + if current.len() <= width { + lines.push(current.to_string()); + break; + } + + // Try to wrap at whitespace if possible + let mut split_index = current[..width].rfind(' ').unwrap_or(width); + if split_index == 0 { + split_index = width; + } + + lines.push(current[..split_index].trim_end().to_string()); + current = current[split_index..].trim_start(); + } + + lines + } + + fn format_value_as_string(&self, val: &Value, indent: usize) -> String { + let pad = " ".repeat(indent * 2); + let mut output = String::new(); + + match val { + Value::Bool(b) => output += &format!("{}{}\n", pad, b), + Value::U8(u) => output += &format!("{}{}\n", pad, u), + Value::U16(u) => output += &format!("{}{}\n", pad, u), + Value::U32(u) => output += &format!("{}{}\n", pad, u), + Value::U64(u) => output += &format!("{}{}\n", pad, u), + Value::I8(i) => output += &format!("{}{}\n", pad, i), + Value::I16(i) => output += &format!("{}{}\n", pad, i), + Value::I32(i) => output += &format!("{}{}\n", pad, i), + Value::I64(i) => output += &format!("{}{}\n", pad, i), + Value::F32(f) => output += &format!("{}{}\n", pad, f), + Value::F64(f) => output += &format!("{}{}\n", pad, f), + Value::Char(c) => output += &format!("{}{}\n", pad, c), + Value::String(s) => output += &format!("{}{:<48}\n", pad, s), + Value::Unit => output += &format!("{}\n", pad), + Value::Bytes(bytes) => output += &format!("{}{:?}\n", pad, bytes), + + Value::Option(opt) => match opt { + Some(inner) => { + output += &format!("{}Option:\n", pad); + output += &self.format_value_as_string(inner, indent + 1); + } + None => output += &format!("{}None\n", pad), + }, + + Value::Newtype(inner) => { + output += &format!("{}Newtype:\n", pad); + output += &self.format_value_as_string(inner, indent + 1); + } + + Value::Seq(seq) => { + if seq.is_empty() { + output += &format!("{}[]\n", pad); + } else { + output += &format!("{}[\n", pad); + for item in seq { + output += &self.format_value_as_string(item, indent + 1); + } + output += &format!("{}]\n", pad); + } + } + + Value::Map(map) => { + if map.is_empty() { + output += &format!("{}\n", pad); + } else if indent == 0 { + output += &self.format_map(map, indent); + } else { + for (k, v) in map { + let key_str = match k { + Value::String(s) => s.clone(), + other => format!("{:?}", other), + }; + + let val_str = self + .format_value_as_string(v, indent + 1) + .trim() + .to_string(); + let val_lines: Vec<_> = val_str.lines().collect(); + + output += + &format!("{}{}: {}\n", pad, key_str, val_lines.first().unwrap_or(&"")); + for line in val_lines.iter().skip(1) { + output += &format!("{} {}\n", pad, line); + } + } + } + } + } + + output + } +} + +//TODO write test to check that the output is what it should be +// +#[cfg(test)] +mod tests { + use super::*; + use crate::modules::dns::DnsScore; + use crate::topology::{self, HAClusterTopology}; + + #[test] + fn test_format_values_as_string() { + let dns_score = Box::new(DnsScore::new(vec![], None)); + let print_score_output = + >::print_score_details(&dns_score); + let expected_empty_dns_score_table = "\n+--------------------------+--------------------------------------------------+\n| score_name | DnsScore |\n+--------------------------+--------------------------------------------------+\n| dns_entries | [] |\n| register_dhcp_leases | None |\n+--------------------------+--------------------------------------------------+\n\n\n"; + assert_eq!(print_score_output, expected_empty_dns_score_table); + } +} diff --git a/harmony/src/domain/topology/load_balancer.rs b/harmony/src/domain/topology/load_balancer.rs index afb9092..6127019 100644 --- a/harmony/src/domain/topology/load_balancer.rs +++ b/harmony/src/domain/topology/load_balancer.rs @@ -46,6 +46,7 @@ pub struct LoadBalancerService { #[derive(Debug, PartialEq, Clone, Serialize)] pub struct BackendServer { + // TODO should not be a string, probably IPAddress pub address: String, pub port: u16, } diff --git a/harmony_tui/Cargo.toml b/harmony_tui/Cargo.toml index a12e534..6aacedd 100644 --- a/harmony_tui/Cargo.toml +++ b/harmony_tui/Cargo.toml @@ -16,3 +16,5 @@ color-eyre = "0.6.3" tokio-stream = "0.1.17" tui-logger = "0.14.1" log-panics = "2.1.0" +serde-value.workspace = true +serde_json = "1.0.140" diff --git a/harmony_tui/src/lib.rs b/harmony_tui/src/lib.rs index 58b4ab7..c3a8a1a 100644 --- a/harmony_tui/src/lib.rs +++ b/harmony_tui/src/lib.rs @@ -159,12 +159,13 @@ impl HarmonyTUI { frame.render_widget(&help_block, help_area); frame.render_widget(HelpWidget::new(), help_block.inner(help_area)); - let [list_area, output_area] = + let [list_area, logger_area] = Layout::horizontal([Constraint::Min(30), Constraint::Percentage(100)]).areas(app_area); let block = Block::default().borders(Borders::RIGHT); frame.render_widget(&block, list_area); self.score.render(list_area, frame); + let tui_logger = tui_logger::TuiLoggerWidget::default() .style_error(Style::default().fg(Color::Red)) .style_warn(Style::default().fg(Color::LightRed)) @@ -172,9 +173,9 @@ impl HarmonyTUI { .style_debug(Style::default().fg(Color::Gray)) .style_trace(Style::default().fg(Color::Gray)) .state(&self.tui_state); - frame.render_widget(tui_logger, output_area) - } + frame.render_widget(tui_logger, logger_area); + } fn scores_list(maestro: &Maestro) -> Vec>> { let scores = maestro.scores(); let scores_read = scores.read().expect("Should be able to read scores"); diff --git a/harmony_tui/src/widget/score.rs b/harmony_tui/src/widget/score.rs index b0d2c27..3acb5c2 100644 --- a/harmony_tui/src/widget/score.rs +++ b/harmony_tui/src/widget/score.rs @@ -1,5 +1,6 @@ use std::sync::{Arc, RwLock}; +use crate::HarmonyTuiEvent; use crossterm::event::{Event, KeyCode, KeyEventKind}; use harmony::{score::Score, topology::Topology}; use log::{info, warn}; @@ -11,8 +12,6 @@ use ratatui::{ }; use tokio::sync::mpsc; -use crate::HarmonyTuiEvent; - #[derive(Debug)] enum ExecutionState { INITIATED, @@ -53,23 +52,27 @@ impl ScoreListWidget { } pub(crate) fn launch_execution(&mut self) { - let list_read = self.list_state.read().unwrap(); - if let Some(index) = list_read.selected() { - let score = self - .scores - .get(index) - .expect("List state should always match with internal Vec"); - + if let Some(score) = self.get_selected_score() { self.execution = Some(Execution { state: ExecutionState::INITIATED, score: score.clone_box(), }); - info!("{:#?}\n\nConfirm Execution (Press y/n)", score); + info!("{}\n\nConfirm Execution (Press y/n)", score.name()); + info!("{}", score.print_score_details()); } else { warn!("No Score selected, nothing to launch"); } } + pub(crate) fn get_selected_score(&self) -> Option>> { + let list_read = self.list_state.read().unwrap(); + if let Some(index) = list_read.selected() { + self.scores.get(index).map(|s| s.clone_box()) + } else { + None + } + } + pub(crate) fn scroll_down(&self) { self.list_state.write().unwrap().scroll_down_by(1); } diff --git a/opnsense-config/src/lib.rs b/opnsense-config/src/lib.rs index d11ec41..f83b3e0 100644 --- a/opnsense-config/src/lib.rs +++ b/opnsense-config/src/lib.rs @@ -12,6 +12,7 @@ mod test { use crate::Config; use pretty_assertions::assert_eq; + #[cfg(opnsenseendtoend)] #[tokio::test] async fn test_public_sdk() { let mac = "11:22:33:44:55:66";