feat: Improve output of tui. From p-r tui-score-info (#11)

WIP: formatted score debug print into a table with a name header and the score information below
Co-authored-by: Jean-Gabriel Gill-Couture <jg@nationtech.io>
Reviewed-on: https://git.nationtech.io/NationTech/harmony/pulls/11
Reviewed-by: johnride <jg@nationtech.io>
Co-authored-by: Willem <wrolleman@nationtech.io>
Co-committed-by: Willem <wrolleman@nationtech.io>
This commit is contained in:
Willem 2025-04-23 14:54:32 +00:00 committed by johnride
parent abd20b96a2
commit eeafa086f3
10 changed files with 277 additions and 21 deletions

3
Cargo.lock generated
View File

@ -941,6 +941,7 @@ dependencies = [
"env_logger", "env_logger",
"harmony", "harmony",
"harmony_macros", "harmony_macros",
"harmony_tui",
"harmony_types", "harmony_types",
"log", "log",
"tokio", "tokio",
@ -1339,6 +1340,8 @@ dependencies = [
"log", "log",
"log-panics", "log-panics",
"ratatui", "ratatui",
"serde-value",
"serde_json",
"tokio", "tokio",
"tokio-stream", "tokio-stream",
"tui-logger", "tui-logger",

View File

@ -8,7 +8,7 @@ publish = false
[dependencies] [dependencies]
harmony = { path = "../../harmony" } harmony = { path = "../../harmony" }
#harmony_tui = { path = "../../harmony_tui" } harmony_tui = { path = "../../harmony_tui" }
harmony_types = { path = "../../harmony_types" } harmony_types = { path = "../../harmony_types" }
cidr = { workspace = true } cidr = { workspace = true }
tokio = { workspace = true } tokio = { workspace = true }

View File

@ -1,5 +1,6 @@
use harmony::{ use harmony::{
data::Version, data::Version,
inventory::Inventory,
maestro::Maestro, maestro::Maestro,
modules::lamp::{LAMPConfig, LAMPScore}, modules::lamp::{LAMPConfig, LAMPScore},
topology::{HAClusterTopology, Url}, topology::{HAClusterTopology, Url},
@ -17,8 +18,9 @@ async fn main() {
}, },
}; };
Maestro::<HAClusterTopology>::load_from_env() let inventory = Inventory::autoload();
.interpret(Box::new(lamp_stack)) let topology = HAClusterTopology::autoload();
.await let mut maestro = Maestro::new(inventory, topology);
.unwrap(); maestro.register_all(vec![Box::new(lamp_stack)]);
harmony_tui::init(maestro).await.unwrap();
} }

View File

@ -1,9 +1,20 @@
use std::net::{SocketAddr, SocketAddrV4};
use harmony::{ use harmony::{
inventory::Inventory, inventory::Inventory,
maestro::Maestro, maestro::Maestro,
modules::dummy::{ErrorScore, PanicScore, SuccessScore}, modules::{
topology::HAClusterTopology, 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] #[tokio::main]
async fn main() { async fn main() {
@ -11,10 +22,52 @@ async fn main() {
let topology = HAClusterTopology::autoload(); let topology = HAClusterTopology::autoload();
let mut maestro = Maestro::new(inventory, topology); let mut maestro = Maestro::new(inventory, topology);
maestro.register_all(vec![ maestro.register_all(vec![
Box::new(SuccessScore {}), Box::new(SuccessScore {}),
Box::new(ErrorScore {}), Box::new(ErrorScore {}),
Box::new(PanicScore {}), Box::new(PanicScore {}),
Box::new(DnsScore::new(vec![], None)),
Box::new(build_large_score()),
]); ]);
harmony_tui::init(maestro).await.unwrap(); 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(),
],
}
}

View File

@ -1,10 +1,12 @@
use std::collections::BTreeMap;
use serde::Serialize; use serde::Serialize;
use serde_value::Value; use serde_value::Value;
use super::{interpret::Interpret, topology::Topology}; use super::{interpret::Interpret, topology::Topology};
pub trait Score<T: Topology>: pub trait Score<T: Topology>:
std::fmt::Debug + Send + Sync + CloneBoxScore<T> + SerializeScore<T> std::fmt::Debug + ScoreToString<T> + Send + Sync + CloneBoxScore<T> + SerializeScore<T>
{ {
fn create_interpret(&self) -> Box<dyn Interpret<T>>; fn create_interpret(&self) -> Box<dyn Interpret<T>>;
fn name(&self) -> String; fn name(&self) -> String;
@ -39,3 +41,191 @@ where
Box::new(self.clone()) Box::new(self.clone())
} }
} }
pub trait ScoreToString<T: Topology> {
fn print_score_details(&self) -> String;
fn format_value_as_string(&self, val: &Value, indent: usize) -> String;
fn format_map(&self, map: &BTreeMap<Value, Value>, indent: usize) -> String;
fn wrap_or_truncate(&self, s: &str, width: usize) -> Vec<String>;
}
impl<S, T> ScoreToString<T> for S
where
T: Topology,
S: Score<T> + '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<Value, Value>, 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<String> {
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!("{}<unit>\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!("{}<empty map>\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 =
<DnsScore as ScoreToString<HAClusterTopology>>::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);
}
}

View File

@ -46,6 +46,7 @@ pub struct LoadBalancerService {
#[derive(Debug, PartialEq, Clone, Serialize)] #[derive(Debug, PartialEq, Clone, Serialize)]
pub struct BackendServer { pub struct BackendServer {
// TODO should not be a string, probably IPAddress
pub address: String, pub address: String,
pub port: u16, pub port: u16,
} }

View File

@ -16,3 +16,5 @@ color-eyre = "0.6.3"
tokio-stream = "0.1.17" tokio-stream = "0.1.17"
tui-logger = "0.14.1" tui-logger = "0.14.1"
log-panics = "2.1.0" log-panics = "2.1.0"
serde-value.workspace = true
serde_json = "1.0.140"

View File

@ -159,12 +159,13 @@ impl<T: Topology + std::fmt::Debug + Send + Sync + 'static> HarmonyTUI<T> {
frame.render_widget(&help_block, help_area); frame.render_widget(&help_block, help_area);
frame.render_widget(HelpWidget::new(), help_block.inner(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); Layout::horizontal([Constraint::Min(30), Constraint::Percentage(100)]).areas(app_area);
let block = Block::default().borders(Borders::RIGHT); let block = Block::default().borders(Borders::RIGHT);
frame.render_widget(&block, list_area); frame.render_widget(&block, list_area);
self.score.render(list_area, frame); self.score.render(list_area, frame);
let tui_logger = tui_logger::TuiLoggerWidget::default() let tui_logger = tui_logger::TuiLoggerWidget::default()
.style_error(Style::default().fg(Color::Red)) .style_error(Style::default().fg(Color::Red))
.style_warn(Style::default().fg(Color::LightRed)) .style_warn(Style::default().fg(Color::LightRed))
@ -172,9 +173,9 @@ impl<T: Topology + std::fmt::Debug + Send + Sync + 'static> HarmonyTUI<T> {
.style_debug(Style::default().fg(Color::Gray)) .style_debug(Style::default().fg(Color::Gray))
.style_trace(Style::default().fg(Color::Gray)) .style_trace(Style::default().fg(Color::Gray))
.state(&self.tui_state); .state(&self.tui_state);
frame.render_widget(tui_logger, output_area)
}
frame.render_widget(tui_logger, logger_area);
}
fn scores_list(maestro: &Maestro<T>) -> Vec<Box<dyn Score<T>>> { fn scores_list(maestro: &Maestro<T>) -> Vec<Box<dyn Score<T>>> {
let scores = maestro.scores(); let scores = maestro.scores();
let scores_read = scores.read().expect("Should be able to read scores"); let scores_read = scores.read().expect("Should be able to read scores");

View File

@ -1,5 +1,6 @@
use std::sync::{Arc, RwLock}; use std::sync::{Arc, RwLock};
use crate::HarmonyTuiEvent;
use crossterm::event::{Event, KeyCode, KeyEventKind}; use crossterm::event::{Event, KeyCode, KeyEventKind};
use harmony::{score::Score, topology::Topology}; use harmony::{score::Score, topology::Topology};
use log::{info, warn}; use log::{info, warn};
@ -11,8 +12,6 @@ use ratatui::{
}; };
use tokio::sync::mpsc; use tokio::sync::mpsc;
use crate::HarmonyTuiEvent;
#[derive(Debug)] #[derive(Debug)]
enum ExecutionState { enum ExecutionState {
INITIATED, INITIATED,
@ -53,23 +52,27 @@ impl<T: Topology + std::fmt::Debug> ScoreListWidget<T> {
} }
pub(crate) fn launch_execution(&mut self) { pub(crate) fn launch_execution(&mut self) {
let list_read = self.list_state.read().unwrap(); if let Some(score) = self.get_selected_score() {
if let Some(index) = list_read.selected() {
let score = self
.scores
.get(index)
.expect("List state should always match with internal Vec");
self.execution = Some(Execution { self.execution = Some(Execution {
state: ExecutionState::INITIATED, state: ExecutionState::INITIATED,
score: score.clone_box(), 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 { } else {
warn!("No Score selected, nothing to launch"); warn!("No Score selected, nothing to launch");
} }
} }
pub(crate) fn get_selected_score(&self) -> Option<Box<dyn Score<T>>> {
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) { pub(crate) fn scroll_down(&self) {
self.list_state.write().unwrap().scroll_down_by(1); self.list_state.write().unwrap().scroll_down_by(1);
} }

View File

@ -12,6 +12,7 @@ mod test {
use crate::Config; use crate::Config;
use pretty_assertions::assert_eq; use pretty_assertions::assert_eq;
#[cfg(opnsenseendtoend)]
#[tokio::test] #[tokio::test]
async fn test_public_sdk() { async fn test_public_sdk() {
let mac = "11:22:33:44:55:66"; let mac = "11:22:33:44:55:66";