From 341075146332ac9512a0d90cea73b36ffb6c7afe Mon Sep 17 00:00:00 2001 From: Jean-Gabriel Gill-Couture Date: Mon, 27 Jan 2025 23:24:21 -0500 Subject: [PATCH] feat: add ScoreListWidget with execution confirmation Implement ScoreListWidget to manage score list rendering and execution confirmation flow. This includes methods for scrolling through scores, launching an execution, confirming/denying the execution, and rendering a popup for user confirmation. --- harmony-rs/Cargo.lock | 2 + harmony-rs/harmony/src/domain/maestro/mod.rs | 4 + harmony-rs/harmony/src/domain/score.rs | 2 + harmony-rs/harmony/src/modules/dhcp.rs | 8 + harmony-rs/harmony/src/modules/dns.rs | 8 + harmony-rs/harmony/src/modules/http.rs | 8 + .../harmony/src/modules/k8s/deployment.rs | 10 +- .../harmony/src/modules/k8s/resource.rs | 11 +- .../harmony/src/modules/load_balancer.rs | 8 + .../harmony/src/modules/okd/bootstrap_dhcp.rs | 10 +- .../modules/okd/bootstrap_load_balancer.rs | 10 +- harmony-rs/harmony/src/modules/okd/dhcp.rs | 10 +- harmony-rs/harmony/src/modules/okd/dns.rs | 10 +- .../harmony/src/modules/okd/load_balancer.rs | 10 +- harmony-rs/harmony/src/modules/okd/upgrade.rs | 6 +- harmony-rs/harmony/src/modules/tftp.rs | 8 + harmony-rs/harmony_tui/Cargo.toml | 3 +- harmony-rs/harmony_tui/src/lib.rs | 97 ++++++++++-- harmony-rs/harmony_tui/src/widget/mod.rs | 2 + .../harmony_tui/src/widget/ratatui_utils.rs | 22 +++ harmony-rs/harmony_tui/src/widget/score.rs | 143 ++++++++++++++++++ 21 files changed, 369 insertions(+), 23 deletions(-) create mode 100644 harmony-rs/harmony_tui/src/widget/mod.rs create mode 100644 harmony-rs/harmony_tui/src/widget/ratatui_utils.rs create mode 100644 harmony-rs/harmony_tui/src/widget/score.rs diff --git a/harmony-rs/Cargo.lock b/harmony-rs/Cargo.lock index 6fec3b2..a70ed41 100644 --- a/harmony-rs/Cargo.lock +++ b/harmony-rs/Cargo.lock @@ -496,6 +496,7 @@ checksum = "829d955a0bb380ef178a640b91779e3987da38c9aea133b20614cfed8cdea9c6" dependencies = [ "bitflags 2.6.0", "crossterm_winapi", + "futures-core", "mio", "parking_lot", "rustix", @@ -1111,6 +1112,7 @@ dependencies = [ "log", "ratatui", "tokio", + "tokio-stream", ] [[package]] diff --git a/harmony-rs/harmony/src/domain/maestro/mod.rs b/harmony-rs/harmony/src/domain/maestro/mod.rs index d93c8cb..fb6c890 100644 --- a/harmony-rs/harmony/src/domain/maestro/mod.rs +++ b/harmony-rs/harmony/src/domain/maestro/mod.rs @@ -43,4 +43,8 @@ impl Maestro { info!("Got result {result:?}"); result } + + pub fn scores(&self) -> Arc> { + self.scores.clone() + } } diff --git a/harmony-rs/harmony/src/domain/score.rs b/harmony-rs/harmony/src/domain/score.rs index c9adcd5..bd7c397 100644 --- a/harmony-rs/harmony/src/domain/score.rs +++ b/harmony-rs/harmony/src/domain/score.rs @@ -2,4 +2,6 @@ use super::interpret::Interpret; pub trait Score: std::fmt::Debug { fn create_interpret(&self) -> Box; + fn name(&self) -> String; + fn clone_box(&self) -> Box; } diff --git a/harmony-rs/harmony/src/modules/dhcp.rs b/harmony-rs/harmony/src/modules/dhcp.rs index 751b025..20e67e5 100644 --- a/harmony-rs/harmony/src/modules/dhcp.rs +++ b/harmony-rs/harmony/src/modules/dhcp.rs @@ -24,6 +24,14 @@ impl Score for DhcpScore { fn create_interpret(&self) -> Box { Box::new(DhcpInterpret::new(self.clone())) } + + fn name(&self) -> String { + "DhcpScore".to_string() + } + + fn clone_box(&self) -> Box { + Box::new(self.clone()) + } } // https://docs.opnsense.org/manual/dhcp.html#advanced-settings diff --git a/harmony-rs/harmony/src/modules/dns.rs b/harmony-rs/harmony/src/modules/dns.rs index a935e2c..79c8870 100644 --- a/harmony-rs/harmony/src/modules/dns.rs +++ b/harmony-rs/harmony/src/modules/dns.rs @@ -20,6 +20,14 @@ impl Score for DnsScore { fn create_interpret(&self) -> Box { Box::new(DnsInterpret::new(self.clone())) } + + fn name(&self) -> String { + "DnsScore".to_string() + } + + fn clone_box(&self) -> Box { + Box::new(self.clone()) + } } // https://docs.opnsense.org/manual/dhcp.html#advanced-settings diff --git a/harmony-rs/harmony/src/modules/http.rs b/harmony-rs/harmony/src/modules/http.rs index 7f9d01a..51eed3b 100644 --- a/harmony-rs/harmony/src/modules/http.rs +++ b/harmony-rs/harmony/src/modules/http.rs @@ -18,6 +18,14 @@ impl Score for HttpScore { fn create_interpret(&self) -> Box { Box::new(HttpInterpret::new(self.clone())) } + + fn name(&self) -> String { + "HttpScore".to_string() + } + + fn clone_box(&self) -> Box { + Box::new(self.clone()) + } } #[derive(Debug, new, Clone)] diff --git a/harmony-rs/harmony/src/modules/k8s/deployment.rs b/harmony-rs/harmony/src/modules/k8s/deployment.rs index 28f559b..de93e3a 100644 --- a/harmony-rs/harmony/src/modules/k8s/deployment.rs +++ b/harmony-rs/harmony/src/modules/k8s/deployment.rs @@ -5,7 +5,7 @@ use crate::{interpret::Interpret, score::Score}; use super::resource::{K8sResourceInterpret, K8sResourceScore}; -#[derive(Debug)] +#[derive(Debug, Clone)] pub struct K8sDeploymentScore { pub name: String, pub image: String, @@ -47,4 +47,12 @@ impl Score for K8sDeploymentScore { score: K8sResourceScore::single(deployment.clone()), }) } + + fn name(&self) -> String { + "K8sDeploymentScore".to_string() + } + + fn clone_box(&self) -> Box { + Box::new(self.clone()) + } } diff --git a/harmony-rs/harmony/src/modules/k8s/resource.rs b/harmony-rs/harmony/src/modules/k8s/resource.rs index 546a3a7..9f68cd1 100644 --- a/harmony-rs/harmony/src/modules/k8s/resource.rs +++ b/harmony-rs/harmony/src/modules/k8s/resource.rs @@ -11,7 +11,7 @@ use crate::{ topology::HAClusterTopology, }; -#[derive(Debug)] +#[derive(Debug, Clone)] pub struct K8sResourceScore { pub resource: Vec, } @@ -31,6 +31,7 @@ impl< + DeserializeOwned + Default + serde::Serialize + + 'static + Clone, > Score for K8sResourceScore where @@ -39,6 +40,14 @@ where fn create_interpret(&self) -> Box { todo!() } + + fn name(&self) -> String { + "K8sResourceScore".to_string() + } + + fn clone_box(&self) -> Box { + Box::new(self.clone()) + } } #[derive(Debug)] diff --git a/harmony-rs/harmony/src/modules/load_balancer.rs b/harmony-rs/harmony/src/modules/load_balancer.rs index 8d7186e..da89932 100644 --- a/harmony-rs/harmony/src/modules/load_balancer.rs +++ b/harmony-rs/harmony/src/modules/load_balancer.rs @@ -23,6 +23,14 @@ impl Score for LoadBalancerScore { fn create_interpret(&self) -> Box { Box::new(LoadBalancerInterpret::new(self.clone())) } + + fn name(&self) -> String { + "LoadBalancerScore".to_string() + } + + fn clone_box(&self) -> Box { + Box::new(self.clone()) + } } #[derive(Debug)] diff --git a/harmony-rs/harmony/src/modules/okd/bootstrap_dhcp.rs b/harmony-rs/harmony/src/modules/okd/bootstrap_dhcp.rs index 17a1ea7..4f5c6ee 100644 --- a/harmony-rs/harmony/src/modules/okd/bootstrap_dhcp.rs +++ b/harmony-rs/harmony/src/modules/okd/bootstrap_dhcp.rs @@ -6,7 +6,7 @@ use crate::{ topology::{HAClusterTopology, HostBinding}, }; -#[derive(Debug)] +#[derive(Debug, Clone)] pub struct OKDBootstrapDhcpScore { dhcp_score: DhcpScore, } @@ -50,4 +50,12 @@ impl Score for OKDBootstrapDhcpScore { fn create_interpret(&self) -> Box { self.dhcp_score.create_interpret() } + + fn name(&self) -> String { + "OKDBootstrapDhcpScore".to_string() + } + + fn clone_box(&self) -> Box { + Box::new(self.clone()) + } } diff --git a/harmony-rs/harmony/src/modules/okd/bootstrap_load_balancer.rs b/harmony-rs/harmony/src/modules/okd/bootstrap_load_balancer.rs index 7170be7..4c29026 100644 --- a/harmony-rs/harmony/src/modules/okd/bootstrap_load_balancer.rs +++ b/harmony-rs/harmony/src/modules/okd/bootstrap_load_balancer.rs @@ -10,7 +10,7 @@ use crate::{ }, }; -#[derive(Debug)] +#[derive(Debug, Clone)] pub struct OKDBootstrapLoadBalancerScore { load_balancer_score: LoadBalancerScore, } @@ -73,4 +73,12 @@ impl Score for OKDBootstrapLoadBalancerScore { fn create_interpret(&self) -> Box { self.load_balancer_score.create_interpret() } + + fn name(&self) -> String { + "OKDBootstrapLoadBalancerScore".to_string() + } + + fn clone_box(&self) -> Box { + Box::new(self.clone()) + } } diff --git a/harmony-rs/harmony/src/modules/okd/dhcp.rs b/harmony-rs/harmony/src/modules/okd/dhcp.rs index c2b8e78..be33987 100644 --- a/harmony-rs/harmony/src/modules/okd/dhcp.rs +++ b/harmony-rs/harmony/src/modules/okd/dhcp.rs @@ -6,7 +6,7 @@ use crate::{ topology::{HAClusterTopology, HostBinding}, }; -#[derive(Debug)] +#[derive(Debug, Clone)] pub struct OKDDhcpScore { dhcp_score: DhcpScore, } @@ -42,4 +42,12 @@ impl Score for OKDDhcpScore { fn create_interpret(&self) -> Box { self.dhcp_score.create_interpret() } + + fn name(&self) -> String { + "OKDDhcpScore".to_string() + } + + fn clone_box(&self) -> Box { + Box::new(self.clone()) + } } diff --git a/harmony-rs/harmony/src/modules/okd/dns.rs b/harmony-rs/harmony/src/modules/okd/dns.rs index 80c8b02..f4e7f2c 100644 --- a/harmony-rs/harmony/src/modules/okd/dns.rs +++ b/harmony-rs/harmony/src/modules/okd/dns.rs @@ -5,7 +5,7 @@ use crate::{ topology::{DnsRecord, DnsRecordType, HAClusterTopology}, }; -#[derive(Debug)] +#[derive(Debug, Clone)] pub struct OKDDnsScore { dns_score: DnsScore, } @@ -44,4 +44,12 @@ impl Score for OKDDnsScore { fn create_interpret(&self) -> Box { self.dns_score.create_interpret() } + + fn name(&self) -> String { + "OKDDnsScore".to_string() + } + + fn clone_box(&self) -> Box { + Box::new(self.clone()) + } } diff --git a/harmony-rs/harmony/src/modules/okd/load_balancer.rs b/harmony-rs/harmony/src/modules/okd/load_balancer.rs index 59d678b..38a5d04 100644 --- a/harmony-rs/harmony/src/modules/okd/load_balancer.rs +++ b/harmony-rs/harmony/src/modules/okd/load_balancer.rs @@ -10,7 +10,7 @@ use crate::{ }, }; -#[derive(Debug)] +#[derive(Debug, Clone)] pub struct OKDLoadBalancerScore { load_balancer_score: LoadBalancerScore, } @@ -84,4 +84,12 @@ impl Score for OKDLoadBalancerScore { fn create_interpret(&self) -> Box { self.load_balancer_score.create_interpret() } + + fn name(&self) -> String { + "OKDLoadBalancerScore".to_string() + } + + fn clone_box(&self) -> Box { + Box::new(self.clone()) + } } diff --git a/harmony-rs/harmony/src/modules/okd/upgrade.rs b/harmony-rs/harmony/src/modules/okd/upgrade.rs index 5ffef54..a4fc6b1 100644 --- a/harmony-rs/harmony/src/modules/okd/upgrade.rs +++ b/harmony-rs/harmony/src/modules/okd/upgrade.rs @@ -1,6 +1,6 @@ use crate::{data::Version, score::Score}; -#[derive(Debug)] +#[derive(Debug, Clone)] pub struct OKDUpgradeScore { current_version: Version, target_version: Version, @@ -28,4 +28,8 @@ impl OKDUpgradeScore { // // ] // todo!() // } +// +// fn name(&self) -> String { +// "OKDUpgradeScore".to_string() +// } // } diff --git a/harmony-rs/harmony/src/modules/tftp.rs b/harmony-rs/harmony/src/modules/tftp.rs index c8a3250..a7c2167 100644 --- a/harmony-rs/harmony/src/modules/tftp.rs +++ b/harmony-rs/harmony/src/modules/tftp.rs @@ -18,6 +18,14 @@ impl Score for TftpScore { fn create_interpret(&self) -> Box { Box::new(TftpInterpret::new(self.clone())) } + + fn name(&self) -> String { + "TftpScore".to_string() + } + + fn clone_box(&self) -> Box { + Box::new(self.clone()) + } } #[derive(Debug, new, Clone)] diff --git a/harmony-rs/harmony_tui/Cargo.toml b/harmony-rs/harmony_tui/Cargo.toml index 6e4aeab..09a2d30 100644 --- a/harmony-rs/harmony_tui/Cargo.toml +++ b/harmony-rs/harmony_tui/Cargo.toml @@ -9,5 +9,6 @@ log = { workspace = true } env_logger = { workspace = true } tokio = { workspace = true } ratatui = "0.29.0" -crossterm = "0.28.1" +crossterm = { version = "0.28.1", features = [ "event-stream" ] } color-eyre = "0.6.3" +tokio-stream = "0.1.17" diff --git a/harmony-rs/harmony_tui/src/lib.rs b/harmony-rs/harmony_tui/src/lib.rs index e568ae1..c8561d6 100644 --- a/harmony-rs/harmony_tui/src/lib.rs +++ b/harmony-rs/harmony_tui/src/lib.rs @@ -1,7 +1,18 @@ -use crossterm::event::{self, Event}; -use harmony::maestro::Maestro; -use ratatui::{self, layout::Position, prelude::CrosstermBackend, Frame, Terminal}; -use std::io; +mod widget; + +use tokio_stream::StreamExt; +use widget::score::ScoreListWidget; + +use std::time::Duration; + +use crossterm::event::{Event, EventStream, KeyCode, KeyEventKind}; +use harmony::{maestro::Maestro, score::Score}; +use ratatui::{ + self, + layout::{Constraint, Layout, Position}, + widgets::{Block, Borders, ListItem}, + Frame, +}; pub mod tui { // Export any necessary modules or types from the internal tui module @@ -34,21 +45,35 @@ pub async fn init(maestro: Maestro) -> Result<(), Box> { pub struct HarmonyTUI { maestro: Maestro, + score: ScoreListWidget, + should_quit: bool, } impl HarmonyTUI { pub fn new(maestro: Maestro) -> Self { - HarmonyTUI { maestro } + let score = ScoreListWidget::new(Self::scores_list(&maestro)); + + HarmonyTUI { + maestro, + should_quit: false, + score, + } } - pub async fn init(self) -> Result<(), Box> { + pub async fn init(mut self) -> Result<(), Box> { color_eyre::install()?; let mut terminal = ratatui::init(); - loop { - terminal.draw(|f| self.render(f))?; - if matches!(event::read()?, Event::Key(_)) { - break; + // TODO improve performance here + // Refreshing the entire terminal 10 times a second does not seem very smart + let period = Duration::from_secs_f32(1.0 / 10.0); + let mut interval = tokio::time::interval(period); + let mut events = EventStream::new(); + + while !self.should_quit { + tokio::select! { + _ = interval.tick() => { terminal.draw(|frame| self.render(frame))?; }, + Some(Ok(event)) = events.next() => self.handle_event(&event), } } @@ -60,9 +85,53 @@ impl HarmonyTUI { fn render(&self, frame: &mut Frame) { let size = frame.area(); frame.set_cursor_position(Position::new(size.x / 2, size.y / 2)); - frame.render_widget( - ratatui::widgets::Paragraph::new("Hello World"), - frame.area(), - ); + + let [list_area, output_area] = + Layout::horizontal([Constraint::Min(30), Constraint::Percentage(100)]) + .areas(frame.area()); + + let block = Block::default().borders(Borders::RIGHT); + frame.render_widget(&block, list_area); + self.score.render(list_area, frame); + } + + fn scores_list(maestro: &Maestro) -> Vec { + let scores = maestro.scores(); + let scores_read = scores.read().expect("Should be able to read scores"); + scores_read + .iter() + .map(|s| ScoreItem(s.clone_box())) + .collect() + } + + fn handle_event(&mut self, event: &Event) { + if let Event::Key(key) = event { + if key.kind == KeyEventKind::Press { + match key.code { + KeyCode::Char('q') | KeyCode::Esc => self.should_quit = true, + KeyCode::Char('j') | KeyCode::Down => self.score.scroll_down(), + KeyCode::Char('k') | KeyCode::Up => self.score.scroll_up(), + KeyCode::Enter => self.score.launch_execution(), + KeyCode::Char('y') => self.score.confirm(true), + KeyCode::Char('n') => self.score.confirm(false), + _ => {} + } + } + } + } +} + +#[derive(Debug)] +struct ScoreItem(Box); + +impl ScoreItem { + pub fn clone(&self) -> Self { + Self(self.0.clone_box()) + } +} + +impl Into> for &ScoreItem { + fn into(self) -> ListItem<'static> { + ListItem::new(self.0.name()) } } diff --git a/harmony-rs/harmony_tui/src/widget/mod.rs b/harmony-rs/harmony_tui/src/widget/mod.rs new file mode 100644 index 0000000..63f8cd5 --- /dev/null +++ b/harmony-rs/harmony_tui/src/widget/mod.rs @@ -0,0 +1,2 @@ +pub mod score; +pub mod ratatui_utils; diff --git a/harmony-rs/harmony_tui/src/widget/ratatui_utils.rs b/harmony-rs/harmony_tui/src/widget/ratatui_utils.rs new file mode 100644 index 0000000..84b8659 --- /dev/null +++ b/harmony-rs/harmony_tui/src/widget/ratatui_utils.rs @@ -0,0 +1,22 @@ +use ratatui::layout::{Constraint, Flex, Layout, Rect}; + +/// Centers a [`Rect`] within another [`Rect`] using the provided [`Constraint`]s. +/// +/// # Examples +/// +/// ```rust +/// use ratatui::layout::{Constraint, Rect}; +/// +/// let area = Rect::new(0, 0, 100, 100); +/// let horizontal = Constraint::Percentage(20); +/// let vertical = Constraint::Percentage(30); +/// +/// let centered = center(area, horizontal, vertical); +/// ``` +pub(crate) fn center(area: Rect, horizontal: Constraint, vertical: Constraint) -> Rect { + let [area] = Layout::horizontal([horizontal]) + .flex(Flex::Center) + .areas(area); + let [area] = Layout::vertical([vertical]).flex(Flex::Center).areas(area); + area +} diff --git a/harmony-rs/harmony_tui/src/widget/score.rs b/harmony-rs/harmony_tui/src/widget/score.rs new file mode 100644 index 0000000..abd4fa4 --- /dev/null +++ b/harmony-rs/harmony_tui/src/widget/score.rs @@ -0,0 +1,143 @@ +use std::sync::{Arc, RwLock}; + +use log::warn; +use ratatui::{ + layout::{Constraint, Rect}, + style::{Style, Stylize}, + widgets::{Block, Clear, List, ListState, Paragraph, StatefulWidget, Widget}, + Frame, +}; + +use crate::ScoreItem; + +use super::ratatui_utils::center; + +#[derive(Debug)] +enum ExecutionState { + INITIATED, + CONFIRMED, + CANCELED, + RUNNING, + COMPLETED, +} + +#[derive(Debug)] +struct Execution { + state: ExecutionState, + score: ScoreItem, +} + +#[derive(Debug)] +pub(crate) struct ScoreListWidget { + list_state: Arc>, + scores: Vec, + execution: Option, + execution_history: Vec, +} + +impl ScoreListWidget { + pub(crate) fn new(scores: Vec) -> Self { + let mut list_state = ListState::default(); + list_state.select_first(); + let list_state = Arc::new(RwLock::new(list_state)); + Self { + scores, + list_state, + execution: None, + execution_history: vec![], + } + } + + 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"); + + self.execution = Some(Execution { + state: ExecutionState::INITIATED, + score: score.clone(), + }); + } + } + + pub(crate) fn scroll_down(&self) { + self.list_state.write().unwrap().scroll_down_by(1); + } + + pub(crate) fn scroll_up(&self) { + self.list_state.write().unwrap().scroll_up_by(1); + } + + pub(crate) fn render(&self, area: Rect, frame: &mut Frame) { + frame.render_widget(self, area); + self.render_execution(frame); + } + + pub(crate) fn render_execution(&self, frame: &mut Frame) { + if let None = self.execution { + return; + } + + let execution = self.execution.as_ref().unwrap(); + + // let confirm = Paragraph::new(format!("{:#?}", execution.score.0)).block( + // Block::default() + // .borders(Borders::ALL) + // .title("Confirm Execution") + // .border_type(BorderType::Rounded), + // ); + // let rect = frame.area().inner(Margin::new(5, 5)); + // frame.render_widget(confirm, rect); + + let area = center( + frame.area(), + Constraint::Percentage(80), + Constraint::Percentage(80), // top and bottom border + content + ); + let popup = Paragraph::new(format!("{:#?}", execution.score.0)) + .block(Block::bordered().title("Confirm Execution (Press y/n)")); + frame.render_widget(Clear, area); + frame.render_widget(popup, area); + } + + fn clear_execution(&mut self) { + match self.execution.take() { + Some(execution) => { + self.execution_history.push(execution); + } + None => warn!("Should never clear execution when no execution exists"), + } + } + + pub(crate) fn confirm(&mut self, confirm: bool) { + if let Some(execution) = &mut self.execution { + match confirm { + true => { + execution.state = ExecutionState::CONFIRMED; + todo!("launch execution {:#?}", execution); + }, + false => { + execution.state = ExecutionState::CANCELED; + self.clear_execution(); + } + } + } + } +} + +impl Widget for &ScoreListWidget { + fn render(self, area: ratatui::prelude::Rect, buf: &mut ratatui::prelude::Buffer) + where + Self: Sized, + { + let mut list_state = self.list_state.write().unwrap(); + let list = List::new(&self.scores) + .highlight_style(Style::new().bold().italic()) + .highlight_symbol("🠊 "); + + StatefulWidget::render(list, area, buf, &mut list_state) + } +}