From 943161e131adb31cff0e52ef42c62b8cfb63b7af Mon Sep 17 00:00:00 2001 From: Bo Maryniuk Date: Sun, 14 Jun 2026 00:35:19 +0200 Subject: [PATCH 01/22] Add initial basic minion registration --- src/netadd/mod.rs | 8 +- src/netadd/workflow.rs | 100 ++++-- src/ui/minreg.rs | 701 +++++++++++++++++++++++++++++++++++++++++ src/ui/mod.rs | 106 ++++++- src/ui/statusbar.rs | 12 + src/ui/wgt.rs | 6 +- 6 files changed, 894 insertions(+), 39 deletions(-) create mode 100644 src/ui/minreg.rs diff --git a/src/netadd/mod.rs b/src/netadd/mod.rs index 0df15cc3..90083308 100644 --- a/src/netadd/mod.rs +++ b/src/netadd/mod.rs @@ -1,10 +1,10 @@ //! CLI-facing planning types for `sysinspect network --add`. -mod artifact; -mod parser; +pub(crate) mod artifact; +pub(crate) mod parser; mod render; -mod types; -mod workflow; +pub(crate) mod types; +pub(crate) mod workflow; #[cfg(test)] pub(crate) use artifact::{ArtifactArch, ArtifactFamily, MinionCatalogue, PlatformId}; diff --git a/src/netadd/workflow.rs b/src/netadd/workflow.rs index f9ccc674..d7800537 100644 --- a/src/netadd/workflow.rs +++ b/src/netadd/workflow.rs @@ -33,7 +33,7 @@ use std::{ use tokio::{runtime::Handle, task::block_in_place}; #[derive(Debug, Clone)] -struct SetupContext { +pub(crate) struct SetupContext { repo_root: std::path::PathBuf, cfg: MasterConfig, master_fp: String, @@ -41,7 +41,7 @@ struct SetupContext { } #[derive(Debug, Clone, PartialEq, Eq)] -struct RemoteLayout { +pub(crate) struct RemoteLayout { root_dir: String, stage_bin: String, install_bin: String, @@ -57,10 +57,11 @@ struct RemoteLayout { } #[derive(Debug, Clone, PartialEq, Eq)] -struct HostSetup { +pub(crate) struct HostSetup { target: ProbedHost, art: MinionArtifact, minion_id: Option, + sudo_override: Option, } #[derive(Debug, Clone, PartialEq, Eq)] @@ -73,7 +74,7 @@ struct Readiness { } #[derive(Debug, Clone, Copy, PartialEq, Eq)] -enum AddFailureStage { +pub(crate) enum AddFailureStage { Upload, Setup, Register, @@ -82,10 +83,10 @@ enum AddFailureStage { } #[derive(Debug, Clone, PartialEq, Eq)] -struct ProbedHost { - host: crate::netadd::types::AddHost, - info: ProbeInfo, - layout: RemoteLayout, +pub(crate) struct ProbedHost { + pub(crate) host: crate::netadd::types::AddHost, + pub(crate) info: ProbeInfo, + pub(crate) layout: RemoteLayout, } #[derive(Debug, Clone, Copy, PartialEq, Eq)] @@ -213,9 +214,14 @@ impl NetworkAddWorkflow { self.remove_host(ctx, target)?; } } - return HostSetup { target: target.clone(), art: self.select_artifact(&ctx.repo_root, &target.info)?, minion_id: None } - .run(ctx) - .map(|_| AddStatus::Online); + return HostSetup { + target: target.clone(), + art: self.select_artifact(&ctx.repo_root, &target.info)?, + minion_id: None, + sudo_override: None, + } + .run(ctx) + .map(|_| AddStatus::Online); } if self.req.op == HostOp::Upgrade { return self.upgrade_host(ctx, target); @@ -271,19 +277,19 @@ impl NetworkAddWorkflow { } impl ProbedHost { - fn new(host: crate::netadd::types::AddHost, info: ProbeInfo) -> Result { + pub(crate) fn new(host: crate::netadd::types::AddHost, info: ProbeInfo) -> Result { Ok(Self { layout: RemoteLayout::from_probe(&host, &info)?, host, info }) } - fn outcome(&self, status: AddStatus) -> AddOutcome { + pub(crate) fn outcome(&self, status: AddStatus) -> AddOutcome { AddOutcome { display_path: self.layout.root_dir.clone(), platform: self.info.os_arch(), status, host: self.host.clone() } } - fn ssh(&self) -> SSHSession { + pub(crate) fn ssh(&self) -> SSHSession { SSHSession::new(SSHEndpoint::new(&self.host.host, &self.host.user)) } - fn elevation(&self) -> Result { + pub(crate) fn elevation(&self) -> Result { if self.info.privilege == crate::sshprobe::detect::PrivilegeMode::Root || self.info.destination.writable { return Ok(ElevationMode::None); } @@ -295,7 +301,7 @@ impl ProbedHost { } impl SetupContext { - fn from_cfg(cfg: &MasterConfig) -> Result { + pub(crate) fn from_cfg(cfg: &MasterConfig) -> Result { Ok(Self { repo_root: cfg.get_mod_repo_root(), cfg: cfg.clone(), @@ -309,7 +315,11 @@ impl SetupContext { }) } - fn master_addr_for(&self, host: &str) -> Result { + pub(crate) fn repo_root(&self) -> &Path { + &self.repo_root + } + + pub(crate) fn master_addr_for(&self, host: &str) -> Result { if !matches!(self.cfg.bind_addr().split(':').next().unwrap_or_default(), "" | "0.0.0.0" | "::" | "[::]") { return Ok(self.cfg.bind_addr()); } @@ -375,15 +385,56 @@ impl RemoteLayout { } impl HostSetup { + /// Create a new host setup from a probed target and selected artefact. + pub(crate) fn new(target: ProbedHost, art: MinionArtifact) -> Self { + Self { target, art, minion_id: None, sudo_override: None } + } + + /// Force sudo usage (Some(true)) or force no-sudo (Some(false)). None lets the probe decide. + pub(crate) fn set_sudo(&mut self, use_sudo: bool) { + self.sudo_override = Some(use_sudo); + } + + /// Resolve the final elevation mode, respecting any manual override. + pub(crate) fn resolve_elevation(&self) -> Result { + if let Some(use_sudo) = self.sudo_override { + if use_sudo { + if self.target.info.has_sudo { + return Ok(ElevationMode::Sudo); + } + return Err(SysinspectError::MinionGeneralError(format!("Sudo requested but not available on {}", self.target.host.host))); + } + return Ok(ElevationMode::None); + } + self.target.elevation() + } + + /// Return the minion ID discovered during provisioning. + pub(crate) fn minion_id(&self) -> &Option { + &self.minion_id + } + + /// Run the full provisioning flow without progress reporting. fn run(&self, ctx: &SetupContext) -> Result<(), SysinspectError> { + let _ = self.run_with_progress(ctx, |_, _| {})?; + Ok(()) + } + + /// Run the full provisioning flow, calling `progress(step, message)` before each step. + /// Returns the minion ID discovered during provisioning. + pub(crate) fn run_with_progress(&self, ctx: &SetupContext, mut progress: impl FnMut(usize, &str)) -> Result, SysinspectError> { let ssh = self.target.ssh(); - let elevate = self.target.elevation()?; + let elevate = self.resolve_elevation()?; + progress(0, "Uploading binary..."); log::info!("Auto-add {}: upload {}", self.target.host.host, self.art.path.display()); ssh.upload(&UploadRequest::new(&self.art.path, &self.target.layout.stage_bin).methods(vec![UploadMethod::Stream, UploadMethod::Scp])) .inspect_err(|err| self.recover_add_failure(ctx, &ssh, elevate, AddFailureStage::Upload, None, err))?; + progress(1, "Setting permissions..."); ssh.exec(&RemoteCommand::new(format!("chmod 0755 {}", shell_quote(&self.target.layout.stage_bin)))) .inspect_err(|err| self.recover_add_failure(ctx, &ssh, elevate, AddFailureStage::Upload, None, err))?; + progress(2, "Verifying binary..."); self.verify_stage_bin(&ssh).inspect_err(|err| self.recover_add_failure(ctx, &ssh, elevate, AddFailureStage::Upload, None, err))?; + progress(3, "Running setup..."); log::info!("Auto-add {}: setup {}", self.target.host.host, self.target.layout.config); ssh.exec( &RemoteCommand::new(format!( @@ -396,42 +447,52 @@ impl HostSetup { .elevate(elevate), ) .inspect_err(|err| self.recover_add_failure(ctx, &ssh, elevate, AddFailureStage::Setup, None, err))?; + progress(4, "Reading minion ID..."); let setup = Self { minion_id: self .read_minion_id(&ssh) .inspect_err(|err| self.recover_add_failure(ctx, &ssh, elevate, AddFailureStage::Setup, None, err))?, ..self.clone() }; + progress(5, "Preparing runtime..."); setup .prepare_runtime(&ssh, elevate) .inspect_err(|err| setup.recover_add_failure(ctx, &ssh, elevate, AddFailureStage::Setup, setup.minion_id.as_deref(), err))?; + progress(6, "Writing onboarding traits..."); log::info!("Auto-add {}: write onboarding traits {}", self.target.host.host, setup.target.layout.onboarding_traits); setup .write_onboarding_traits(&ssh, elevate) .inspect_err(|err| setup.recover_add_failure(ctx, &ssh, elevate, AddFailureStage::Setup, setup.minion_id.as_deref(), err))?; + progress(7, "Registering with master..."); log::info!("Auto-add {}: register", self.target.host.host); setup .register(ctx, &ssh, elevate) .inspect_err(|err| setup.recover_add_failure(ctx, &ssh, elevate, AddFailureStage::Register, setup.minion_id.as_deref(), err))?; + progress(8, "Starting daemon..."); log::info!("Auto-add {}: start daemon", self.target.host.host); setup .start_runtime(&ssh, elevate) .inspect_err(|err| setup.recover_add_failure(ctx, &ssh, elevate, AddFailureStage::Start, setup.minion_id.as_deref(), err))?; + progress(9, "Waiting for runtime..."); log::info!("Auto-add {}: wait for daemon pid", self.target.host.host); setup .wait_runtime(&ssh, elevate) .inspect_err(|err| setup.recover_add_failure(ctx, &ssh, elevate, AddFailureStage::Start, setup.minion_id.as_deref(), err))?; + progress(10, "Waiting for bootstrap..."); log::info!("Auto-add {}: wait for bootstrap log", self.target.host.host); setup .wait_attempt(&ssh, elevate) .inspect_err(|err| setup.recover_add_failure(ctx, &ssh, elevate, AddFailureStage::Start, setup.minion_id.as_deref(), err))?; + progress(11, "Waiting for readiness..."); log::info!("Auto-add {}: wait for master readiness", self.target.host.host); setup .wait_ready(ctx, &ssh, elevate) .inspect_err(|err| setup.recover_add_failure(ctx, &ssh, elevate, AddFailureStage::Ready, setup.minion_id.as_deref(), err))?; + progress(12, "Syncing CMDB..."); log::info!("Auto-add {}: sync master CMDB", self.target.host.host); setup.sync_cmdb(ctx)?; - Ok(()) + progress(13, "Complete"); + Ok(setup.minion_id.clone()) } fn sync_cmdb(&self, ctx: &SetupContext) -> Result<(), SysinspectError> { @@ -828,6 +889,7 @@ impl NetworkAddWorkflow { .into(), target: target.clone(), art: artifact, + sudo_override: None, }; log::info!("Auto-upgrade {}: upload {}", target.host.host, setup.art.path.display()); ssh.upload(&UploadRequest::new(&setup.art.path, &setup.target.layout.stage_bin).methods(vec![UploadMethod::Stream, UploadMethod::Scp]))?; diff --git a/src/ui/minreg.rs b/src/ui/minreg.rs new file mode 100644 index 00000000..631f2b00 --- /dev/null +++ b/src/ui/minreg.rs @@ -0,0 +1,701 @@ +use super::{ + palette, + title::{self, TitleSegment, TitleStyle}, +}; +use crate::netadd::{ + artifact::{MinionCatalogue, PlatformId}, + parser::parse_entry, + types::AddHost, + workflow::{HostSetup, ProbedHost, SetupContext}, +}; +use crate::sshprobe::detect::SSHPlatformDetector; +use crossterm::event::{KeyCode, KeyEvent, KeyModifiers}; +use libsysinspect::cfg::mmconf::MasterConfig; +use ratatui::{ + layout::{Position, Rect}, + style::{Color, Modifier, Style}, + widgets::{Block, BorderType, Borders, Clear, StatefulWidget, Widget}, +}; +use ratatui_cheese::input::{Input, InputState}; +use ratatui_glamour::color::blend_2d; +use std::sync::{ + Arc, Mutex, + atomic::{AtomicBool, Ordering}, +}; +use unicode_width::UnicodeWidthStr; + +static DEFAULT_PATH: &str = "~/sysinspect"; + +#[derive(Clone, Copy, PartialEq, Eq, Debug)] +pub(crate) enum FormFocus { + Hostname, + User, + Path, + SudoCheck, + Ok, + Cancel, +} + +impl FormFocus { + fn next(self) -> Self { + match self { + Self::Hostname => Self::User, + Self::User => Self::Path, + Self::Path => Self::SudoCheck, + Self::SudoCheck => Self::Ok, + Self::Ok => Self::Cancel, + Self::Cancel => Self::Hostname, + } + } + + fn prev(self) -> Self { + match self { + Self::Hostname => Self::Cancel, + Self::User => Self::Hostname, + Self::Path => Self::User, + Self::SudoCheck => Self::Path, + Self::Ok => Self::Path, + Self::Cancel => Self::Ok, + } + } +} + +/// Pre-flight input form before provisioning begins. +#[derive(Debug)] +pub struct RegistrationForm { + pub visible: bool, + pub hostname: InputState, + pub user: InputState, + pub path: InputState, + pub use_sudo: bool, + pub(crate) focus: FormFocus, + pub ok_pressed: bool, +} + +impl Default for RegistrationForm { + fn default() -> Self { + let mut hostname = InputState::new(); + hostname.set_focused(true); + + let mut user = InputState::new(); + if let Some(u) = current_user() { + user.set_value(u); + } + + let mut path = InputState::new(); + path.set_value(DEFAULT_PATH.to_string()); + + Self { visible: false, hostname, user, path, use_sudo: false, focus: FormFocus::Hostname, ok_pressed: false } + } +} + +impl RegistrationForm { + /// Render the form popup. + pub fn render(&self, parent: Rect, buf: &mut ratatui::prelude::Buffer) { + if !self.visible { + return; + } + let dlg_w = (parent.width * 3 / 4).clamp(54, 66); + let dlg_h = 11u16; + let x = parent.x + (parent.width.saturating_sub(dlg_w)) / 2; + let y = parent.y + (parent.height.saturating_sub(dlg_h)) / 2; + let canvas = Rect { x, y, width: dlg_w, height: dlg_h }; + + Clear.render(canvas, buf); + + let grad = blend_2d(canvas.width as usize, canvas.height as usize, 10.0, &[palette::GRAY_0, palette::BG_2] as &[Color]); + for row in 0..canvas.height { + for col in 0..canvas.width { + let idx = row as usize * canvas.width as usize + col as usize; + if let Some(cell) = buf.cell_mut(Position::new(canvas.x + col, canvas.y + row)) { + cell.set_bg(grad[idx]); + } + } + } + + let block = Block::default() + .borders(Borders::ALL) + .border_type(BorderType::Rounded) + .border_style(Style::default().fg(palette::PROCESSING_GLOW)) + .style(Style::default()); + let inner = block.inner(canvas); + block.render(canvas, buf); + + let title_style = TitleStyle::cyberpunk(palette::PROCESSING_GLOW); + title::overlay_gradient_title( + buf, + canvas, + &title_style, + &[TitleSegment { text: " Register Minion ".into(), bg: palette::PROCESSING_BASE, fg: palette::FG, modifier: Modifier::empty() }], + ); + + if inner.height < 3 { + return; + } + + let label_w = 14u16; + let focus_style = Style::default().fg(palette::ACCENT).add_modifier(Modifier::BOLD); + let muted = Style::default().fg(palette::MUTED); + + let mut row_y = inner.y + 1; + + Self::render_input_row(inner.x, &mut row_y, inner.width, buf, " Hostname:", &self.hostname, self.focus == FormFocus::Hostname, label_w); + Self::render_input_row(inner.x, &mut row_y, inner.width, buf, " SSH User:", &self.user, self.focus == FormFocus::User, label_w); + Self::render_input_row(inner.x, &mut row_y, inner.width, buf, " Path:", &self.path, self.focus == FormFocus::Path, label_w); + + row_y += 1; + + // Sudo checkbox + let sudo_chk = if self.use_sudo { "[x] Use sudo (wheel)" } else { "[ ] Use sudo (wheel)" }; + let sudo_style = if self.focus == FormFocus::SudoCheck { focus_style } else { muted }; + buf.set_string(inner.x + 3, row_y, sudo_chk, sudo_style); + + row_y += 1; + + let ok_label = " [ OK ] "; + let cancel_label = " [ Cancel ] "; + let btn_w = ok_label.width() as u16 + cancel_label.width() as u16 + 6; + let btn_x = inner.x + (inner.width.saturating_sub(btn_w)) / 2; + + let ok_style = if self.focus == FormFocus::Ok { + Style::default().fg(palette::WHITE).bg(palette::PROCESSING_HEAT).add_modifier(Modifier::BOLD) + } else { + Style::default().fg(palette::FG).bg(palette::BG_2).add_modifier(Modifier::BOLD) + }; + let cancel_style = if self.focus == FormFocus::Cancel { + Style::default().fg(palette::WHITE).bg(palette::PROCESSING_HEAT).add_modifier(Modifier::BOLD) + } else { + Style::default().fg(palette::FG).bg(palette::BG_2).add_modifier(Modifier::BOLD) + }; + + buf.set_string(btn_x, row_y, ok_label, ok_style); + buf.set_string(btn_x + ok_label.width() as u16 + 4, row_y, cancel_label, cancel_style); + + draw_shadow(buf, canvas, dlg_w, dlg_h); + } + + /// Handle keyboard input for the form. Returns true if the event was consumed. + pub fn handle_key(&mut self, key: KeyEvent) -> bool { + if !self.visible { + return false; + } + match key.code { + KeyCode::Tab => { + self.focus = if key.modifiers.contains(KeyModifiers::SHIFT) { self.focus.prev() } else { self.focus.next() }; + } + KeyCode::BackTab => self.focus = self.focus.prev(), + KeyCode::Esc => { + self.visible = false; + } + KeyCode::Char(' ') => { + if self.focus == FormFocus::SudoCheck { + self.use_sudo = !self.use_sudo; + } + } + KeyCode::Enter => match self.focus { + FormFocus::Ok => { + self.ok_pressed = true; + } + FormFocus::Cancel => { + self.visible = false; + } + FormFocus::SudoCheck => { + self.use_sudo = !self.use_sudo; + } + _ => {} + }, + KeyCode::Backspace => { + if let Some(i) = self.focused_input_mut() { + i.delete_before() + } + } + KeyCode::Delete => { + if let Some(i) = self.focused_input_mut() { + i.delete_at() + } + } + KeyCode::Left => { + if let Some(i) = self.focused_input_mut() { + i.move_left() + } + } + KeyCode::Right => { + if let Some(i) = self.focused_input_mut() { + i.move_right() + } + } + KeyCode::Home => { + if let Some(i) = self.focused_input_mut() { + i.home() + } + } + KeyCode::End => { + if let Some(i) = self.focused_input_mut() { + i.end() + } + } + KeyCode::Char(c) => { + if let Some(i) = self.focused_input_mut() { + i.insert_char(c) + } + } + _ => {} + } + true + } + + fn focused_input_mut(&mut self) -> Option<&mut InputState> { + match self.focus { + FormFocus::Hostname => Some(&mut self.hostname), + FormFocus::User => Some(&mut self.user), + FormFocus::Path => Some(&mut self.path), + _ => None, + } + } + + #[allow(clippy::too_many_arguments)] + fn render_input_row( + base_x: u16, row_y: &mut u16, inner_width: u16, buf: &mut ratatui::prelude::Buffer, label: &str, state: &InputState, focused: bool, + label_w: u16, + ) { + let muted = Style::default().fg(palette::MUTED); + let focus_style = Style::default().fg(palette::ACCENT).add_modifier(Modifier::BOLD); + let lstyle = if focused { focus_style } else { muted }; + let label_padded = format!("{:width$}", label, width = label_w as usize); + buf.set_string(base_x + 3, *row_y, &label_padded, lstyle); + let input_x = base_x + 3 + label_w; + let input_w = inner_width.saturating_sub(label_w + 6); + if input_w > 0 { + let mut is = copy_input_state(state, focused); + let inp = Input::new("").prompt("").placeholder(""); + StatefulWidget::render(&inp, Rect::new(input_x, *row_y, input_w, 1), buf, &mut is); + } + *row_y += 1; + } +} + +/// Shared progress state between the UI thread and the background provisioning task. +#[derive(Debug)] +pub struct RegistrationProgress { + pub visible: bool, + pub step: usize, + pub total: usize, + pub message: String, + pub host: String, + pub platform: String, + pub minion_id: Option, + pub done: bool, + pub error: Option, + pub error_scroll: usize, + pub cancelled: Arc, +} + +impl RegistrationProgress { + /// Create a new progress state with an active registration. + pub fn new(host: String) -> Self { + Self { + visible: true, + step: 0, + total: STEP_LABELS.len(), + message: "Connecting...".into(), + host, + platform: String::new(), + minion_id: None, + done: false, + error: None, + error_scroll: 0, + cancelled: Arc::new(AtomicBool::new(false)), + } + } + + /// Create an inactive progress placeholder. + pub fn placeholder() -> Self { + Self { + visible: false, + step: 0, + total: STEP_LABELS.len(), + message: String::new(), + host: String::new(), + platform: String::new(), + minion_id: None, + done: false, + error: None, + error_scroll: 0, + cancelled: Arc::new(AtomicBool::new(false)), + } + } +} + +/// Render the progress popup. +pub fn render_progress(progress: &RegistrationProgress, parent: Rect, buf: &mut ratatui::prelude::Buffer) { + if !progress.visible { + return; + } + let has_error = progress.error.is_some(); + let dlg_w = (parent.width * 3 / 4).clamp(52, 72); + let dlg_h = if has_error { 16u16 } else { 10u16 }; + let x = parent.x + (parent.width.saturating_sub(dlg_w)) / 2; + let y = parent.y + (parent.height.saturating_sub(dlg_h)) / 2; + let canvas = Rect { x, y, width: dlg_w, height: dlg_h }; + + Clear.render(canvas, buf); + + let grad = blend_2d(canvas.width as usize, canvas.height as usize, 13.0, &[palette::GRAY_0, palette::PROCESSING_GLOW] as &[Color]); + for row in 0..canvas.height { + for col in 0..canvas.width { + let idx = row as usize * canvas.width as usize + col as usize; + if let Some(cell) = buf.cell_mut(Position::new(canvas.x + col, canvas.y + row)) { + cell.set_bg(grad[idx]); + } + } + } + + let block = Block::default() + .borders(Borders::ALL) + .border_type(BorderType::Rounded) + .border_style(Style::default().fg(palette::PROCESSING_GLOW)) + .style(Style::default()); + let inner = block.inner(canvas); + block.render(canvas, buf); + + let title_style = TitleStyle::cyberpunk(palette::PROCESSING_GLOW); + title::overlay_gradient_title( + buf, + canvas, + &title_style, + &[TitleSegment { text: " Minion Registration ".into(), bg: palette::PROCESSING_BASE, fg: palette::FG, modifier: Modifier::empty() }], + ); + + if inner.height < 3 { + return; + } + + let mut row_y = inner.y; + + let host_label = format!(" Registering: {}", progress.host); + buf.set_string(inner.x + 2, row_y, truncate_str(&host_label, (inner.width.saturating_sub(4)) as usize), Style::default().fg(palette::FG)); + row_y += 1; + + if !progress.platform.is_empty() { + let plat_label = format!(" Detected: {}", progress.platform); + buf.set_string(inner.x + 2, row_y, truncate_str(&plat_label, (inner.width.saturating_sub(4)) as usize), Style::default().fg(palette::MUTED)); + row_y += 1; + } + + row_y += 1; + + if let Some(ref err) = progress.error { + buf.set_string(inner.x + 2, row_y, "Registration failed:", Style::default().fg(palette::ERROR_PEAK).add_modifier(Modifier::BOLD)); + row_y += 1; + let log_w = inner.width.saturating_sub(5); + let lines = wrap_text(err, log_w); + let view_h = (inner.bottom().saturating_sub(row_y + 1)) as usize; + let max_scroll = lines.len().saturating_sub(view_h); + let s = progress.error_scroll.min(max_scroll); + if view_h > 0 { + let log_start_y = row_y; + for line in lines.iter().skip(s).take(view_h) { + buf.set_string(inner.x + 2, row_y, truncate_str(line, log_w as usize), Style::default().fg(palette::MUTED)); + row_y += 1; + } + if lines.len() > view_h { + let sb_x = inner.right().saturating_sub(2); + for ty in log_start_y..row_y { + buf.set_string(sb_x, ty, "│", Style::default().fg(palette::MUTED)); + } + let thumb_y = (s as f64 / max_scroll.max(1) as f64 * (view_h - 1) as f64) as u16; + buf.set_string(sb_x, log_start_y + thumb_y, "█", Style::default().fg(palette::PROCESSING_HEAT)); + } + } + let close = "[ Close ]"; + let btn_x = inner.x + (inner.width.saturating_sub(close.len() as u16)) / 2; + buf.set_string( + btn_x, + inner.bottom().saturating_sub(1), + close, + Style::default().fg(palette::FG).bg(palette::BG_2).add_modifier(Modifier::BOLD), + ); + } else if progress.done { + let done_msg = if let Some(ref mid) = progress.minion_id { format!(" Registered: {mid}") } else { " Complete".into() }; + buf.set_string(inner.x + 2, row_y, &done_msg, Style::default().fg(palette::SUCCESS_PEAK).add_modifier(Modifier::BOLD)); + row_y += 1; + let close = "[ Close ]"; + let btn_x = inner.x + (inner.width.saturating_sub(close.len() as u16)) / 2; + buf.set_string(btn_x, row_y + 1, close, Style::default().fg(palette::FG).bg(palette::BG_2).add_modifier(Modifier::BOLD)); + } else { + let msg = progress.message.clone(); + buf.set_string(inner.x + 2, row_y, truncate_str(&msg, (inner.width.saturating_sub(4)) as usize), Style::default().fg(palette::FG)); + row_y += 1; + + let bar_y = row_y + 1; + let bar_w = inner.width.saturating_sub(4); + let pct = (progress.step * 100).checked_div(progress.total.max(1)).unwrap_or(0); + let filled = (bar_w as usize * progress.step).checked_div(progress.total.max(1)).unwrap_or(0) as u16; + + if filled > 0 { + buf.set_string(inner.x + 2, bar_y, "█".repeat(filled as usize), Style::default().fg(palette::PROCESSING_PEAK)); + } + if filled < bar_w { + let unfilled = (bar_w - filled) as usize; + buf.set_string(inner.x + 2 + filled, bar_y, "─".repeat(unfilled), Style::default().fg(palette::MUTED)); + } + let pct_text = format!("{pct}%"); + let pct_x = inner.x + (inner.width.saturating_sub(pct_text.len() as u16)) / 2; + buf.set_string(pct_x, bar_y, &pct_text, Style::default().fg(palette::FG).add_modifier(Modifier::BOLD)); + + let cancel = "[ Cancel ]"; + let btn_x = inner.x + (inner.width.saturating_sub(cancel.len() as u16)) / 2; + buf.set_string(btn_x, bar_y + 2, cancel, Style::default().fg(palette::FG).bg(palette::BG_2).add_modifier(Modifier::BOLD)); + } + + draw_shadow(buf, canvas, dlg_w, dlg_h); +} + +/// Handle keyboard input for the progress popup. +pub fn handle_progress_key(key: KeyEvent, progress: &mut RegistrationProgress) -> bool { + if !progress.visible || progress.done { + if matches!(key.code, KeyCode::Esc | KeyCode::Enter) || key.code == KeyCode::Char(' ') { + return true; + } + return false; + } + if let Some(ref _err) = progress.error { + match key.code { + KeyCode::Up => { + progress.error_scroll = progress.error_scroll.saturating_sub(1); + return false; + } + KeyCode::Down => { + progress.error_scroll += 1; + return false; + } + KeyCode::PageUp => { + progress.error_scroll = progress.error_scroll.saturating_sub(5); + return false; + } + KeyCode::PageDown => { + progress.error_scroll += 5; + return false; + } + KeyCode::Esc | KeyCode::Enter => return true, + _ => {} + } + if key.code == KeyCode::Char(' ') { + return true; + } + return false; + } + if key.code == KeyCode::Esc { + progress.cancelled.store(true, Ordering::SeqCst); + return true; + } + false +} + +/// Spawn the background provisioning task. Returns a join handle. +pub fn spawn_registration( + hostname: String, user: String, path: String, use_sudo: bool, cfg: MasterConfig, progress: Arc>, +) -> tokio::task::JoinHandle<()> { + tokio::task::spawn_blocking(move || { + let result = run_provision(hostname, user, path, use_sudo, &cfg, &progress); + let mut p = progress.lock().unwrap(); + match result { + Ok(mid) => { + p.minion_id = mid; + p.message = "Complete".into(); + } + Err(err) => { + p.error = Some(err.to_string()); + } + } + p.done = true; + }) +} + +/// Execute the full provisioning flow synchronously. +fn run_provision( + hostname: String, user: String, path: String, use_sudo: bool, cfg: &MasterConfig, progress: &Arc>, +) -> Result, String> { + let host_display = format!("{user}@{hostname}:{path}"); + { + let mut p = progress.lock().unwrap(); + p.host = host_display.clone(); + } + + if progress.lock().unwrap().cancelled.load(Ordering::SeqCst) { + return Err("Cancelled".into()); + } + + let raw = if path == DEFAULT_PATH { format!("{user}@{hostname}") } else { format!("{user}@{hostname}:{path}") }; + let spec = parse_entry(&raw).map_err(|e| format!("Invalid host entry: {e}"))?; + + let mut p = progress.lock().unwrap(); + if spec.host.trim().is_empty() { + return Err("Hostname is required".into()); + } + p.step = 0; + p.message = STEP_LABELS[0].into(); + drop(p); + + let detector = SSHPlatformDetector::new(&spec.host).set_user(&user).check_writable(true); + let info = detector.info().map_err(|e| format!("Probe failed: {e}"))?; + + { + let mut p = progress.lock().unwrap(); + p.platform = info.os_arch(); + if p.cancelled.load(Ordering::SeqCst) { + return Err("Cancelled".into()); + } + p.step = 1; + p.message = STEP_LABELS[1].into(); + } + + let ctx = SetupContext::from_cfg(cfg).map_err(|e| format!("Setup context: {e}"))?; + let host = AddHost { + raw: spec.raw.clone(), + host: spec.host.clone(), + host_norm: spec.host.to_ascii_lowercase(), + user, + path: spec.path.clone(), + path_norm: None, + }; + let probed = ProbedHost::new(host, info).map_err(|e| format!("Layout: {e}"))?; + let art = MinionCatalogue::open(ctx.repo_root()) + .map_err(|e| format!("Catalogue: {e}"))? + .select(&PlatformId::from_probe(&probed.info).map_err(|e| format!("Platform: {e}"))?) + .map_err(|e| format!("Artefact: {e}"))?; + let mut setup = HostSetup::new(probed, art); + setup.set_sudo(use_sudo); + + if progress.lock().unwrap().cancelled.load(Ordering::SeqCst) { + return Err("Cancelled".into()); + } + + let progress_clone = Arc::clone(progress); + let mid = setup + .run_with_progress(&ctx, move |step, msg| { + let mut p = progress_clone.lock().unwrap(); + p.step = step + 2; + p.message = msg.into(); + }) + .map_err(|e| e.to_string())?; + + { + let mut p = progress.lock().unwrap(); + p.step = p.total; + p.message = "Complete".into(); + } + + Ok(mid) +} + +/// Labels shown in the progress bar in provisioning order. +static STEP_LABELS: &[&str] = &[ + "Probing platform...", + "Selecting sysminion artefact...", + "Uploading binary...", + "Setting permissions...", + "Verifying binary...", + "Running setup...", + "Reading minion ID...", + "Preparing runtime...", + "Writing onboarding traits...", + "Registering with master...", + "Starting daemon...", + "Waiting for runtime...", + "Waiting for bootstrap...", + "Waiting for readiness...", + "Syncing CMDB...", +]; + +fn current_user() -> Option { + ["USER", "LOGNAME", "USERNAME"].into_iter().find_map(|k| std::env::var(k).ok().filter(|v| !v.trim().is_empty())) +} + +/// Word-wrap text to fit within max_width, preserving line breaks. +fn wrap_text(text: &str, max_w: u16) -> Vec { + let mut lines = Vec::new(); + for raw_line in text.lines() { + if raw_line.is_empty() { + lines.push(String::new()); + continue; + } + let mut cur = String::new(); + let mut cur_w = 0u16; + for word in raw_line.split_inclusive(' ') { + let w = UnicodeWidthStr::width(word) as u16; + if cur_w + w > max_w && cur_w > 0 { + lines.push(cur.trim_end().to_string()); + cur = word.to_string(); + cur_w = w; + } else { + cur.push_str(word); + cur_w += w; + } + } + if !cur.is_empty() { + lines.push(cur.trim_end().to_string()); + } + } + lines +} + +fn copy_input_state(src: &InputState, focused: bool) -> InputState { + let mut is = InputState::new(); + is.set_value(src.value().to_string()); + is.set_focused(focused); + let fc = src.cursor_pos(); + while is.cursor_pos() < fc { + is.move_right(); + } + is +} + +fn truncate_str(s: &str, max_w: usize) -> String { + let w = UnicodeWidthStr::width(s); + if w <= max_w { + return s.to_string(); + } + let mut result = String::with_capacity(max_w + 1); + let mut cur_w = 0usize; + for ch in s.chars() { + let ch_w = UnicodeWidthStr::width(ch.to_string().as_str()); + if cur_w + ch_w > max_w.saturating_sub(1) { + result.push('…'); + break; + } + result.push(ch); + cur_w += ch_w; + } + result +} + +fn draw_shadow(buf: &mut ratatui::prelude::Buffer, canvas: Rect, dlg_w: u16, dlg_h: u16) { + let buf_area = buf.area(); + let max_x = buf_area.right().saturating_sub(1); + let max_y = buf_area.bottom().saturating_sub(1); + for idx in 0..dlg_w { + let sx = canvas.x.saturating_add(2).saturating_add(idx); + let sy = canvas.y.saturating_add(dlg_h); + if sx > max_x || sy > max_y { + continue; + } + if let Some(cell) = buf.cell_mut(Position::new(sx, sy)) { + cell.set_bg(palette::SHADOW_BG); + cell.set_fg(palette::SHADOW_FG); + } + } + for off in 0..2u16 { + for idx in 0..dlg_h { + let sx = canvas.x.saturating_add(dlg_w).saturating_add(off); + let sy = canvas.y.saturating_add(idx).saturating_add(1); + if sx > max_x || sy > max_y { + continue; + } + if let Some(cell) = buf.cell_mut(Position::new(sx, sy)) { + cell.set_bg(palette::SHADOW_BG); + cell.set_fg(palette::SHADOW_FG); + } + } + } +} diff --git a/src/ui/mod.rs b/src/ui/mod.rs index de5ff048..dab9d62f 100644 --- a/src/ui/mod.rs +++ b/src/ui/mod.rs @@ -19,8 +19,8 @@ use libsysproto::query::{ SCHEME_COMMAND, commands::{ CLUSTER_LIBRARY_INDEX, CLUSTER_MASTER_LOGS, CLUSTER_MINION_HOPSTART, CLUSTER_MINION_INFO, CLUSTER_MINION_LOGS, CLUSTER_MINION_RECONNECT, - CLUSTER_MINION_SHUTDOWN, CLUSTER_MODELS, CLUSTER_MODULE_INDEX, CLUSTER_ONLINE_MINIONS, CLUSTER_PROFILE, CLUSTER_RECONNECT, CLUSTER_SHUTDOWN, - CLUSTER_TRAITS_UPDATE, + CLUSTER_MINION_SHUTDOWN, CLUSTER_MODELS, CLUSTER_MODULE_INDEX, CLUSTER_ONLINE_MINIONS, CLUSTER_PROFILE, CLUSTER_RECONNECT, + CLUSTER_REMOVE_MINION, CLUSTER_SHUTDOWN, CLUSTER_TRAITS_UPDATE, }, }; use ratatui::{ @@ -46,6 +46,7 @@ mod dslbrowser; mod elements; mod filepicker; mod macts; +mod minreg; mod online; mod palette; mod platforms; @@ -216,6 +217,11 @@ pub struct SysInspectUX { pub offline: bool, pub last_reconnect_attempt: Instant, + // Minion registration + pub registration_form: minreg::RegistrationForm, + pub registration_progress: Arc>, + pub registration_task: Option>, + // Exit-after-popup state (for setup config-written notice) pub pending_exit: bool, pub pending_exit_message: Option, @@ -319,6 +325,10 @@ impl Default for SysInspectUX { offline: false, last_reconnect_attempt: Instant::now(), + registration_form: minreg::RegistrationForm::default(), + registration_progress: Arc::new(std::sync::Mutex::new(minreg::RegistrationProgress::placeholder())), + registration_task: None, + pending_exit: false, pending_exit_message: None, cycles_buf: Vec::new(), @@ -611,7 +621,11 @@ impl SysInspectUX { fn on_events(&mut self) -> io::Result<()> { self.sync_main_focus_for_overlays(); - let poll_dur = if self.repo_manager.progress.lock().unwrap().is_some() { Duration::from_millis(50) } else { Duration::from_secs(1) }; + let poll_dur = if self.repo_manager.progress.lock().unwrap().is_some() || self.registration_progress.lock().unwrap().visible { + Duration::from_millis(50) + } else { + Duration::from_secs(1) + }; if event::poll(poll_dur)? { if let Event::Key(e) = event::read()? && e.kind == KeyEventKind::Press @@ -671,6 +685,19 @@ impl SysInspectUX { self.repo_manager.needs_reload = true; } } + // Registration completion check (only auto-dismiss on success) + if self.registration_progress.lock().unwrap().visible && self.registration_progress.lock().unwrap().done { + let p = self.registration_progress.lock().unwrap(); + let has_error = p.error.is_some(); + if !has_error { + let msg = format!("Minion registered: {}", p.minion_id.as_deref().unwrap_or("?")); + drop(p); + *self.registration_progress.lock().unwrap() = minreg::RegistrationProgress::placeholder(); + self.restore_status(); + self.info_alert_visible = true; + self.info_alert_message = msg; + } + } Ok(()) } @@ -1039,7 +1066,7 @@ impl SysInspectUX { self.cluster_confirm_choice = AlertResult::ClusterConfirm; self.pending_cluster_action = 2; } else if self.minions_menu_sel == 8 { - // TODO: do_minion_add() + self.registration_form.visible = true; } } _ => { @@ -1253,6 +1280,8 @@ impl SysInspectUX { || self.repo_manager.profiles.create_visible || self.repo_manager.profiles.delete_visible || self.repo_manager.platforms.delete_visible + || self.registration_form.visible + || self.registration_progress.lock().unwrap().visible } fn sync_main_focus_for_overlays(&mut self) { @@ -1446,7 +1475,7 @@ impl SysInspectUX { true } KeyCode::Insert => { - // TODO: do_minion_add() + self.registration_form.visible = true; true } KeyCode::Delete => { @@ -1532,9 +1561,26 @@ impl SysInspectUX { return; } }; - let _host = Self::online_host(&row); - let _mid = row.minion_id.clone(); - // TODO: call_master_console with CLUSTER_REMOVE_MINION + let mid = row.minion_id.clone(); + match tokio::task::block_in_place(|| { + tokio::runtime::Handle::current().block_on(async { + call_master_console(&self.cfg, &format!("{SCHEME_COMMAND}{CLUSTER_REMOVE_MINION}"), "*", None, Some(&mid), None).await + }) + }) { + Ok(rsp) if matches!(rsp.payload, ConsolePayload::Ack { .. }) => { + self.info_alert_visible = true; + self.info_alert_message = format!("Minion deleted: {mid}"); + self.refresh_minions(); + } + Ok(_) => { + self.error_alert_visible = true; + self.error_alert_message = "Master did not acknowledge minion removal.".to_string(); + } + Err(err) => { + self.error_alert_visible = true; + self.error_alert_message = format!("Failed to delete minion: {err}"); + } + } self.status_at_minions_browser(); } @@ -2862,8 +2908,7 @@ impl SysInspectUX { } KeyCode::Char('r') if e.modifiers.contains(KeyModifiers::CONTROL) => { self.master_menu_visible = false; - self.error_alert_visible = true; - self.error_alert_message = "Not implemented yet".to_string(); + self.registration_form.visible = true; } KeyCode::Char('g') if e.modifiers.contains(KeyModifiers::CONTROL) => { self.master_menu_visible = false; @@ -2923,8 +2968,7 @@ impl SysInspectUX { } }, 2 => { - self.error_alert_visible = true; - self.error_alert_message = "Not implemented yet".to_string(); + self.registration_form.visible = true; } 3 => { if let Err(err) = self.load_module_index() { @@ -3242,7 +3286,40 @@ impl SysInspectUX { return; } - // File picker is modal + // Registration form is modal + if self.registration_form.visible { + self.status_at_registration_form(); + self.registration_form.handle_key(e); + if self.registration_form.ok_pressed { + self.registration_form.ok_pressed = false; + self.registration_form.visible = false; + self.status_at_registration_progress(); + let hostname = self.registration_form.hostname.value().to_string(); + let user = self.registration_form.user.value().to_string(); + let path = self.registration_form.path.value().to_string(); + let use_sudo = self.registration_form.use_sudo; + let progress = Arc::new(std::sync::Mutex::new(minreg::RegistrationProgress::new(String::new()))); + self.registration_progress = Arc::clone(&progress); + self.registration_task = Some(minreg::spawn_registration(hostname, user, path, use_sudo, self.cfg.clone(), progress)); + } + return; + } + + // Registration progress is modal + let progress_visible = self.registration_progress.lock().unwrap().visible; + if progress_visible { + self.status_at_registration_progress(); + let mut progress = self.registration_progress.lock().unwrap(); + if minreg::handle_progress_key(e, &mut progress) { + if progress.error.is_some() || progress.done { + drop(progress); + *self.registration_progress.lock().unwrap() = minreg::RegistrationProgress::placeholder(); + self.restore_status(); + } + return; + } + return; + } if self.file_picker.visible && self.file_picker.handle_key(e) { return; } @@ -3514,8 +3591,7 @@ impl SysInspectUX { } }, KeyCode::Char('r') if e.modifiers.contains(KeyModifiers::CONTROL) => { - self.error_alert_visible = true; - self.error_alert_message = "Not implemented yet".to_string(); + self.registration_form.visible = true; } KeyCode::Char('g') if e.modifiers.contains(KeyModifiers::CONTROL) => { if let Err(err) = self.load_module_index() { diff --git a/src/ui/statusbar.rs b/src/ui/statusbar.rs index 55a8f681..2151e6f3 100644 --- a/src/ui/statusbar.rs +++ b/src/ui/statusbar.rs @@ -233,4 +233,16 @@ impl SysInspectUX { Span::styled("close", Style::default().fg(palette::FAINT)), ]); } + + pub(crate) fn status_at_registration_form(&mut self) { + let key = |s| Span::styled(s, Style::default().fg(palette::FG)); + let desc = |s| Span::styled(s, Style::default().fg(palette::FAINT)); + self.status_text = Line::from(vec![key("Tab "), desc("switch focus, "), key("Enter "), desc("register, "), key("Esc "), desc("cancel")]); + } + + pub(crate) fn status_at_registration_progress(&mut self) { + let key = |s| Span::styled(s, Style::default().fg(palette::FG)); + let desc = |s| Span::styled(s, Style::default().fg(palette::FAINT)); + self.status_text = Line::from(vec![key("Esc "), desc("cancel registration")]); + } } diff --git a/src/ui/wgt.rs b/src/ui/wgt.rs index 59d4fad4..fb8da2b1 100644 --- a/src/ui/wgt.rs +++ b/src/ui/wgt.rs @@ -1,7 +1,7 @@ use super::{ SysInspectUX, UISizes, elements::{ActiveBox, DbListItem, EventListItem}, - palette, typecolors, + minreg, palette, typecolors, }; use ratatui::{ layout::{Constraint, Direction, Layout}, @@ -297,6 +297,10 @@ impl Widget for &SysInspectUX { if !self.error_alert_visible && !self.file_picker.visible { self.setup_wizard.render(area, buf); } + self.registration_form.render(area, buf); + if let Ok(p) = self.registration_progress.lock() { + minreg::render_progress(&p, area, buf); + } self.dialog_purge(area, buf); self.dialog_exit(area, buf); self.dialog_help(area, buf); From fe0c820fef3211ce6ee55cddb683fc8165b69d81 Mon Sep 17 00:00:00 2001 From: Bo Maryniuk Date: Sun, 14 Jun 2026 02:09:46 +0200 Subject: [PATCH 02/22] Implement minion removal from the target host --- src/ui/alert.rs | 5 ++++- src/ui/mod.rs | 16 +++++++++++++--- sysmaster/src/console.rs | 26 +++++++++++++++++++++++++- sysmaster/src/hopstart.rs | 2 +- sysmaster/src/registry/rec.rs | 22 +++++++++++----------- 5 files changed, 54 insertions(+), 17 deletions(-) diff --git a/src/ui/alert.rs b/src/ui/alert.rs index b7765500..3accd8c3 100644 --- a/src/ui/alert.rs +++ b/src/ui/alert.rs @@ -166,7 +166,8 @@ impl SysInspectUX { 2 => ("\nForce every online minion to drop\nand re-establish its connection?".to_string(), None), 3 => { let host = self.selected_popup_minion().map(|r| Self::online_host(&r)).unwrap_or_else(|| "unknown".to_string()); - let plain = format!("\nDo you want to unregister {host} from this cluster?"); + let chk = if self.delete_force_remove { "[x] Also remove from host over SSH" } else { "[ ] Also remove from host over SSH" }; + let plain = format!("\nDo you want to unregister {host} from this cluster?\n\n{chk}"); let styled = Text::from(vec![ Line::from(""), Line::from(vec![ @@ -174,6 +175,8 @@ impl SysInspectUX { Span::styled(host.clone(), Style::default().fg(palette::SUCCESS)), Span::raw(" from this cluster?"), ]), + Line::from(""), + Line::from(vec![Span::styled(chk, Style::default().fg(palette::MUTED))]), ]); (plain, Some(styled)) } diff --git a/src/ui/mod.rs b/src/ui/mod.rs index dab9d62f..6c452d5b 100644 --- a/src/ui/mod.rs +++ b/src/ui/mod.rs @@ -187,6 +187,7 @@ pub struct SysInspectUX { pub cluster_confirm_visible: bool, pub cluster_confirm_choice: AlertResult, pub pending_cluster_action: u8, // 0=none, 1=shutdown all, 2=reconnect all, 3=delete minion + pub delete_force_remove: bool, // checkbox: also remove from host over SSH // Tag popup pub tag_visible: bool, @@ -309,6 +310,7 @@ impl Default for SysInspectUX { cluster_confirm_visible: false, cluster_confirm_choice: AlertResult::default(), pending_cluster_action: 0, + delete_force_remove: false, tag_visible: false, tag_key_buf: String::new(), @@ -1500,22 +1502,29 @@ impl SysInspectUX { self.cluster_confirm_choice = AlertResult::Default; } } + KeyCode::Char(' ') => { + if self.pending_cluster_action == 3 { + self.delete_force_remove = !self.delete_force_remove; + } + } KeyCode::Enter => { self.cluster_confirm_visible = false; if self.cluster_confirm_choice == AlertResult::ClusterConfirm { match self.pending_cluster_action { 1 => self.do_cluster_shutdown(), 2 => self.do_cluster_reconnect(), - 3 => self.do_minion_delete(), + 3 => self.do_minion_delete(self.delete_force_remove), _ => {} } } self.pending_cluster_action = 0; + self.delete_force_remove = false; self.status_at_minions_browser(); } KeyCode::Esc => { self.cluster_confirm_visible = false; self.pending_cluster_action = 0; + self.delete_force_remove = false; self.status_at_minions_browser(); } _ => {} @@ -1551,7 +1560,7 @@ impl SysInspectUX { } } - fn do_minion_delete(&mut self) { + fn do_minion_delete(&mut self, force: bool) { let row = match self.selected_popup_minion() { Some(row) => row, None => { @@ -1562,9 +1571,10 @@ impl SysInspectUX { } }; let mid = row.minion_id.clone(); + let force_ctx = if force { Some(serde_json::json!({"force": true}).to_string()) } else { None }; match tokio::task::block_in_place(|| { tokio::runtime::Handle::current().block_on(async { - call_master_console(&self.cfg, &format!("{SCHEME_COMMAND}{CLUSTER_REMOVE_MINION}"), "*", None, Some(&mid), None).await + call_master_console(&self.cfg, &format!("{SCHEME_COMMAND}{CLUSTER_REMOVE_MINION}"), "*", None, Some(&mid), force_ctx.as_ref()).await }) }) { Ok(rsp) if matches!(rsp.payload, ConsolePayload::Ack { .. }) => { diff --git a/sysmaster/src/console.rs b/sysmaster/src/console.rs index 2d4482a1..6e26010e 100644 --- a/sysmaster/src/console.rs +++ b/sysmaster/src/console.rs @@ -7,7 +7,7 @@ use super::*; -use crate::hopstart::{HopStartTarget, HopStarter}; +use crate::hopstart::{HopStartTarget, HopStarter, shell_quote}; use libmodpak::{SysInspectModPak, compare_versions, mpk::ModPakRepoIndex}; use libsysinspect::{ cfg::mmconf::MinionConfig, @@ -844,6 +844,30 @@ impl SysMaster { } if query.model.eq(&format!("{SCHEME_COMMAND}{CLUSTER_REMOVE_MINION}")) { + // Force cleanup: SSH into host and remove files before unregistering + if !query.context.is_empty() + && let Ok(v) = serde_json::from_str::(&query.context) + && v.get("force").and_then(|f| f.as_bool()).unwrap_or(false) + { + let cmdb_opt = master.lock().await.mreg.lock().await.get_cmdb(&query.mid).ok().flatten(); + if let Some(cmdb) = cmdb_opt + && cmdb.backend.as_deref() == Some("hopstart") + && let (Some(user), Some(host), Some(root), Some(bin), Some(config)) = + (cmdb.user.as_deref(), cmdb.host.as_deref(), cmdb.root.as_deref(), cmdb.bin.as_deref(), cmdb.config.as_deref()) + { + let cmd = format!( + "sh -lc '{} -c {} --stop >/dev/null 2>&1 || true; rm -rf {}'", + shell_quote(bin), + shell_quote(config), + shell_quote(root) + ); + match tokio::process::Command::new("ssh").arg(format!("{user}@{host}")).arg(&cmd).status().await { + Ok(s) if s.success() => log::info!("Force-removed minion {} from host {host}", query.mid), + Ok(s) => log::warn!("SSH cleanup for {} exited with {s}", query.mid), + Err(e) => log::warn!("SSH cleanup for {} failed: {e}", query.mid), + } + } + } let (response, msgs) = { let mut guard = master.lock().await; match guard.unregister_console_response(&query.mid).await { diff --git a/sysmaster/src/hopstart.rs b/sysmaster/src/hopstart.rs index 8987a77f..63add540 100644 --- a/sysmaster/src/hopstart.rs +++ b/sysmaster/src/hopstart.rs @@ -79,7 +79,7 @@ impl HopStarter { } } -fn shell_quote(value: &str) -> String { +pub(crate) fn shell_quote(value: &str) -> String { format!("'{}'", value.replace('\'', "'\"'\"'")) } diff --git a/sysmaster/src/registry/rec.rs b/sysmaster/src/registry/rec.rs index 0f5c6c46..381f159c 100644 --- a/sysmaster/src/registry/rec.rs +++ b/sysmaster/src/registry/rec.rs @@ -25,26 +25,26 @@ pub struct MinionCmdbStartup { #[derive(Debug, Serialize, Deserialize, Clone, PartialEq, Eq)] pub struct MinionCmdbRecord { - mid: String, + pub(crate) mid: String, #[serde(default)] - user: Option, + pub(crate) user: Option, #[serde(default)] - host: Option, + pub(crate) host: Option, #[serde(default)] - hostname: Option, + pub(crate) hostname: Option, #[serde(default)] - fqdn: Option, + pub(crate) fqdn: Option, #[serde(default)] - ip: Option, + pub(crate) ip: Option, #[serde(default)] - root: Option, + pub(crate) root: Option, #[serde(default)] - bin: Option, + pub(crate) bin: Option, #[serde(default)] - config: Option, + pub(crate) config: Option, #[serde(default)] - backend: Option, - updated_at: DateTime, + pub(crate) backend: Option, + pub(crate) updated_at: DateTime, } impl MinionCmdbStartup { From eab02d78f849e1b327ec5352874f10ea6d181e96 Mon Sep 17 00:00:00 2001 From: Bo Maryniuk Date: Sun, 14 Jun 2026 15:27:34 +0200 Subject: [PATCH 03/22] Add DialogFormWidget with additional form elements --- src/ui/alert.rs | 217 ++++++++++++++++++++++++++++++++++++++++++------ src/ui/mod.rs | 83 +++++++++++++----- 2 files changed, 253 insertions(+), 47 deletions(-) diff --git a/src/ui/alert.rs b/src/ui/alert.rs index 3accd8c3..79c18368 100644 --- a/src/ui/alert.rs +++ b/src/ui/alert.rs @@ -8,6 +8,54 @@ use ratatui::{ }; use ratatui_glamour::color::blend_2d; +#[derive(Debug, Clone)] +pub(crate) enum DialogFormWidget { + Checkbox { label: String, checked: bool }, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub(crate) enum DialogFormFocus { + Widget(usize), + LeftButton, + RightButton, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub(crate) enum DialogFormAlignment { + Left, + Center, +} + +impl DialogFormFocus { + pub(crate) fn next(self, widgets_len: usize, has_right_button: bool) -> Self { + let total = widgets_len + 1 + usize::from(has_right_button); + Self::from_index((self.index(widgets_len, has_right_button) + 1) % total, widgets_len, has_right_button) + } + + pub(crate) fn prev(self, widgets_len: usize, has_right_button: bool) -> Self { + let total = widgets_len + 1 + usize::from(has_right_button); + Self::from_index((self.index(widgets_len, has_right_button) + total - 1) % total, widgets_len, has_right_button) + } + + fn index(self, widgets_len: usize, has_right_button: bool) -> usize { + match self { + Self::Widget(idx) => idx.min(widgets_len.saturating_sub(1)), + Self::LeftButton => widgets_len, + Self::RightButton => widgets_len + usize::from(has_right_button), + } + } + + fn from_index(index: usize, widgets_len: usize, has_right_button: bool) -> Self { + if index < widgets_len { + Self::Widget(index) + } else if has_right_button && index == widgets_len + 1 { + Self::RightButton + } else { + Self::LeftButton + } + } +} + #[derive(Default)] enum AlertButtons { YesNo, @@ -52,6 +100,9 @@ impl SysInspectUX { None, None, None, + None, + None, + DialogFormAlignment::Left, Some((10.0, &[palette::GRAY_0, palette::BG_2] as &[Color])), ); } @@ -77,6 +128,9 @@ impl SysInspectUX { None, None, None, + None, + None, + DialogFormAlignment::Left, Some((10.0, &[palette::GRAY_0, palette::BG_2] as &[Color])), ); } @@ -102,6 +156,9 @@ impl SysInspectUX { None, None, None, + None, + None, + DialogFormAlignment::Left, Some((10.0, &[palette::GRAY_0, palette::BG_2] as &[Color])), ); } @@ -128,6 +185,9 @@ impl SysInspectUX { None, None, None, + None, + DialogFormAlignment::Left, + None, ); } @@ -153,6 +213,9 @@ impl SysInspectUX { Some("Yep!"), Some("Nope"), None, + None, + None, + DialogFormAlignment::Left, Some((10.0, &[palette::GRAY_0, palette::BG_2] as &[Color])), ); } @@ -161,24 +224,46 @@ impl SysInspectUX { if !self.cluster_confirm_visible { return; } - let (plain_text, styled_text): (String, Option>) = match self.pending_cluster_action { - 1 => ("\nShut down every online minion\nin the entire cluster?".to_string(), None), - 2 => ("\nForce every online minion to drop\nand re-establish its connection?".to_string(), None), + let (plain_text, styled_text, widgets, form_focus, text_align, widget_align): ( + String, + Option>, + Option>, + Option, + Alignment, + DialogFormAlignment, + ) = match self.pending_cluster_action { + 1 => ( + "\nShut down every online minion\nin the entire cluster?".to_string(), + None, + None, + None, + Alignment::Center, + DialogFormAlignment::Left, + ), + 2 => ( + "\nForce every online minion to drop\nand re-establish its connection?".to_string(), + None, + None, + None, + Alignment::Center, + DialogFormAlignment::Left, + ), 3 => { let host = self.selected_popup_minion().map(|r| Self::online_host(&r)).unwrap_or_else(|| "unknown".to_string()); - let chk = if self.delete_force_remove { "[x] Also remove from host over SSH" } else { "[ ] Also remove from host over SSH" }; - let plain = format!("\nDo you want to unregister {host} from this cluster?\n\n{chk}"); - let styled = Text::from(vec![ - Line::from(""), - Line::from(vec![ - Span::raw("Do you want to unregister "), - Span::styled(host.clone(), Style::default().fg(palette::SUCCESS)), - Span::raw(" from this cluster?"), - ]), - Line::from(""), - Line::from(vec![Span::styled(chk, Style::default().fg(palette::MUTED))]), - ]); - (plain, Some(styled)) + let plain = format!("Do you want to unregister {host} from this cluster?"); + let styled = Text::from(vec![Line::from(vec![ + Span::raw("Do you want to unregister "), + Span::styled(host.clone(), Style::default().fg(palette::SUCCESS)), + Span::raw(" from this cluster?"), + ])]); + ( + plain, + Some(styled), + Some(vec![DialogFormWidget::Checkbox { label: "Remove client from the host".to_string(), checked: self.delete_force_remove }]), + Some(self.cluster_confirm_form_focus), + Alignment::Center, + DialogFormAlignment::Center, + ) } _ => return, }; @@ -188,7 +273,7 @@ impl SysInspectUX { Some("Cluster Operation"), &plain_text, None, - Alignment::Center, + text_align, self.cluster_confirm_choice.clone(), AlertButtons::YesNo, Some(0), @@ -199,6 +284,9 @@ impl SysInspectUX { None, None, styled_text, + widgets.as_deref(), + form_focus, + widget_align, Some((10.0, &[palette::GRAY_0, palette::BG_2] as &[Color])), ); } @@ -230,6 +318,9 @@ impl SysInspectUX { None, None, None, + None, + None, + DialogFormAlignment::Left, Some((10.0, &[palette::GRAY_0, palette::BG_2] as &[Color])), ); } @@ -270,6 +361,7 @@ impl SysInspectUX { parent: Rect, buf: &mut Buffer, title: Option<&str>, text: &str, background: Option, text_align: Alignment, choice: AlertResult, buttons: AlertButtons, width: Option, border_color: Option, border_type: Option, text_color: Option, title_color: Option, left_label: Option<&str>, right_label: Option<&str>, styled_text: Option>, + form_widgets: Option<&[DialogFormWidget]>, form_focus: Option, form_alignment: DialogFormAlignment, gradient: Option<(f32, &[Color])>, ) { let background = background.unwrap_or(palette::POPUP_BG_BASE); @@ -278,15 +370,18 @@ impl SysInspectUX { let text_color = text_color.unwrap_or(palette::FG); let title_color = title_color.unwrap_or(palette::BLACK); let has_gradient = gradient.is_some(); + let form_widgets = form_widgets.unwrap_or(&[]); + let has_form_widgets = !form_widgets.is_empty(); - let text = format!("\n{text}"); + let text = if has_form_widgets { text.to_string() } else { format!("\n{text}") }; let text_lines = Self::get_text_lines(&text); - let height = text_lines + 3; + let widget_rows = form_widgets.len() as u16; + let height = text_lines + widget_rows + if has_form_widgets { 5 } else { 3 }; #[allow(clippy::unnecessary_unwrap)] let mut width = if width.is_none() { (parent.width / 4).max(20) } else { width.unwrap() }; if width == 0 { - width = Self::get_max_width_lines(&text) + 6; + width = Self::get_max_width_lines(&text).max(Self::get_max_width_widgets(form_widgets)) + 6; } let x = parent.x + (parent.width.saturating_sub(width)) / 2; @@ -329,16 +424,33 @@ impl SysInspectUX { popup_block.render(canvas, buf); let text_bg = if has_gradient { Style::default().fg(text_color) } else { Style::default().fg(text_color).bg(background) }; - let vertical_chunks = - Layout::default().direction(Direction::Vertical).constraints([Constraint::Length(text_lines), Constraint::Length(1)]).split(popup_inner); + let vertical_chunks = if has_form_widgets { + Layout::default() + .direction(Direction::Vertical) + .constraints([ + Constraint::Length(1), + Constraint::Length(text_lines), + Constraint::Length(1), + Constraint::Length(widget_rows), + Constraint::Length(1), + Constraint::Length(1), + ]) + .split(popup_inner) + } else { + Layout::default().direction(Direction::Vertical).constraints([Constraint::Length(text_lines), Constraint::Length(1)]).split(popup_inner) + }; - let text_area = vertical_chunks[0]; - let button_area = vertical_chunks[1]; + let text_area = if has_form_widgets { vertical_chunks[1] } else { vertical_chunks[0] }; + let widget_area = if has_form_widgets { Some(vertical_chunks[3]) } else { None }; + let button_area = if has_form_widgets { vertical_chunks[5] } else { vertical_chunks[1] }; if let Some(st) = styled_text { Paragraph::new(st).alignment(text_align).style(text_bg).render(text_area, buf); } else { Paragraph::new(text).alignment(text_align).style(text_bg).render(text_area, buf); } + if let Some(area) = widget_area { + Self::render_form_widgets(buf, area, background, form_widgets, form_focus, form_alignment); + } let (lbtn_label, rbtn_label) = match buttons { AlertButtons::YesNo => (Self::format_button(YES_LABEL), Self::format_button(NO_LABEL)), AlertButtons::OkCancel => (Self::format_button(left_label.unwrap_or(OK_LABEL)), Self::format_button(right_label.unwrap_or(CANCEL_LABEL))), @@ -373,7 +485,17 @@ impl SysInspectUX { ]) .split(button_area); - let (left_style, right_style) = if choice == AlertResult::Default { (b_unselected, b_selected) } else { (b_selected, b_unselected) }; + let (left_style, right_style) = if has_form_widgets { + match form_focus.unwrap_or(DialogFormFocus::LeftButton) { + DialogFormFocus::LeftButton => (b_selected, b_unselected), + DialogFormFocus::RightButton => (b_unselected, b_selected), + DialogFormFocus::Widget(_) => (b_unselected, b_unselected), + } + } else if choice == AlertResult::Default { + (b_unselected, b_selected) + } else { + (b_selected, b_unselected) + }; Paragraph::new(lbtn_label).style(left_style).render(button_splits[1], buf); Paragraph::new(rbtn_label).style(right_style).render(button_splits[3], buf); @@ -532,6 +654,51 @@ impl SysInspectUX { } } } + + fn render_form_widgets( + buf: &mut Buffer, area: Rect, background: Color, widgets: &[DialogFormWidget], focus: Option, alignment: DialogFormAlignment, + ) { + for (idx, widget) in widgets.iter().enumerate() { + let y = area.y + idx as u16; + let is_focused = matches!(focus, Some(DialogFormFocus::Widget(focused)) if focused == idx); + + match widget { + DialogFormWidget::Checkbox { label, checked } => { + let checkbox = if *checked { "▣" } else { "□" }; + let row_text = format!("{checkbox} {label}"); + let row_width = row_text.chars().count() as u16; + let start_x = match alignment { + DialogFormAlignment::Left => area.x, + DialogFormAlignment::Center => area.x + area.width.saturating_sub(row_width) / 2, + }; + let row_style = if is_focused { + Style::default().fg(palette::HIGHLIGHT).bg(background).add_modifier(Modifier::BOLD) + } else { + Style::default().fg(palette::FG).bg(background) + }; + let checkbox_style = if is_focused { + row_style + } else if *checked { + Style::default().fg(palette::SUCCESS).bg(background) + } else { + Style::default().fg(palette::GRAY_1).bg(background) + }; + buf.set_string(start_x, y, checkbox, checkbox_style); + buf.set_string(start_x + 3, y, label, row_style); + } + } + } + } + + fn get_max_width_widgets(widgets: &[DialogFormWidget]) -> u16 { + widgets + .iter() + .map(|widget| match widget { + DialogFormWidget::Checkbox { label, .. } => 3 + label.chars().count() as u16, + }) + .max() + .unwrap_or(0) + } } /// Wrap text to a maximum width, preserving leading whitespace per paragraph. diff --git a/src/ui/mod.rs b/src/ui/mod.rs index 6c452d5b..5ddcaea5 100644 --- a/src/ui/mod.rs +++ b/src/ui/mod.rs @@ -61,6 +61,8 @@ mod traittag; mod typecolors; mod wgt; +use alert::DialogFormFocus; + pub async fn run(cfg: MasterConfig, config_found: bool) -> io::Result<()> { let mut terminal = ratatui::init(); let result = tokio_run(cfg, config_found, &mut terminal).await; @@ -188,6 +190,7 @@ pub struct SysInspectUX { pub cluster_confirm_choice: AlertResult, pub pending_cluster_action: u8, // 0=none, 1=shutdown all, 2=reconnect all, 3=delete minion pub delete_force_remove: bool, // checkbox: also remove from host over SSH + cluster_confirm_form_focus: DialogFormFocus, // Tag popup pub tag_visible: bool, @@ -311,6 +314,7 @@ impl Default for SysInspectUX { cluster_confirm_choice: AlertResult::default(), pending_cluster_action: 0, delete_force_remove: false, + cluster_confirm_form_focus: DialogFormFocus::LeftButton, tag_visible: false, tag_key_buf: String::new(), @@ -1056,17 +1060,11 @@ impl SysInspectUX { } else if self.minions_menu_sel == 4 { self.do_minion_reconnect(); } else if self.minions_menu_sel == 5 { - self.cluster_confirm_visible = true; - self.cluster_confirm_choice = AlertResult::ClusterConfirm; - self.pending_cluster_action = 3; + self.open_cluster_confirm(3); } else if self.minions_menu_sel == 6 { - self.cluster_confirm_visible = true; - self.cluster_confirm_choice = AlertResult::ClusterConfirm; - self.pending_cluster_action = 1; + self.open_cluster_confirm(1); } else if self.minions_menu_sel == 7 { - self.cluster_confirm_visible = true; - self.cluster_confirm_choice = AlertResult::ClusterConfirm; - self.pending_cluster_action = 2; + self.open_cluster_confirm(2); } else if self.minions_menu_sel == 8 { self.registration_form.visible = true; } @@ -1465,15 +1463,11 @@ impl SysInspectUX { true } KeyCode::Char('x') if e.modifiers.contains(KeyModifiers::CONTROL) => { - self.cluster_confirm_visible = true; - self.cluster_confirm_choice = AlertResult::ClusterConfirm; - self.pending_cluster_action = 1; + self.open_cluster_confirm(1); true } KeyCode::Char('a') if e.modifiers.contains(KeyModifiers::CONTROL) => { - self.cluster_confirm_visible = true; - self.cluster_confirm_choice = AlertResult::ClusterConfirm; - self.pending_cluster_action = 2; + self.open_cluster_confirm(2); true } KeyCode::Insert => { @@ -1481,9 +1475,7 @@ impl SysInspectUX { true } KeyCode::Delete => { - self.cluster_confirm_visible = true; - self.cluster_confirm_choice = AlertResult::ClusterConfirm; - self.pending_cluster_action = 3; + self.open_cluster_confirm(3); true } _ => false, @@ -1494,6 +1486,40 @@ impl SysInspectUX { if !self.cluster_confirm_visible { return false; } + if self.pending_cluster_action == 3 { + match e.code { + KeyCode::Tab => { + self.cluster_confirm_form_focus = self.cluster_confirm_form_focus.next(1, true); + } + KeyCode::BackTab => { + self.cluster_confirm_form_focus = self.cluster_confirm_form_focus.prev(1, true); + } + KeyCode::Char(' ') => { + if matches!(self.cluster_confirm_form_focus, DialogFormFocus::Widget(0)) { + self.delete_force_remove = !self.delete_force_remove; + } + } + KeyCode::Enter => match self.cluster_confirm_form_focus { + DialogFormFocus::Widget(0) => { + self.delete_force_remove = !self.delete_force_remove; + } + DialogFormFocus::LeftButton => { + let force = self.delete_force_remove; + self.close_cluster_confirm(); + self.do_minion_delete(force); + } + DialogFormFocus::RightButton => { + self.close_cluster_confirm(); + } + DialogFormFocus::Widget(_) => {} + }, + KeyCode::Esc => { + self.close_cluster_confirm(); + } + _ => {} + } + return true; + } match e.code { KeyCode::Tab => { if self.cluster_confirm_choice == AlertResult::Default { @@ -1519,19 +1545,32 @@ impl SysInspectUX { } self.pending_cluster_action = 0; self.delete_force_remove = false; + self.cluster_confirm_form_focus = DialogFormFocus::LeftButton; self.status_at_minions_browser(); } KeyCode::Esc => { - self.cluster_confirm_visible = false; - self.pending_cluster_action = 0; - self.delete_force_remove = false; - self.status_at_minions_browser(); + self.close_cluster_confirm(); } _ => {} } true } + fn open_cluster_confirm(&mut self, action: u8) { + self.cluster_confirm_visible = true; + self.cluster_confirm_choice = AlertResult::ClusterConfirm; + self.pending_cluster_action = action; + self.cluster_confirm_form_focus = if action == 3 { DialogFormFocus::Widget(0) } else { DialogFormFocus::LeftButton }; + } + + fn close_cluster_confirm(&mut self) { + self.cluster_confirm_visible = false; + self.pending_cluster_action = 0; + self.delete_force_remove = false; + self.cluster_confirm_form_focus = DialogFormFocus::LeftButton; + self.status_at_minions_browser(); + } + fn do_cluster_shutdown(&mut self) { match tokio::task::block_in_place(|| { tokio::runtime::Handle::current() From 9990711bea8125c3ada97c11b4c5e2bdce2ca94d Mon Sep 17 00:00:00 2001 From: Bo Maryniuk Date: Sun, 14 Jun 2026 16:05:46 +0200 Subject: [PATCH 04/22] Fix DialogFormWidget, add spinner when minion removal --- src/ui/alert.rs | 328 ++++++++++++++++++++++++++++++++---------------- src/ui/mod.rs | 96 ++++++++++---- src/ui/wgt.rs | 1 + 3 files changed, 296 insertions(+), 129 deletions(-) diff --git a/src/ui/alert.rs b/src/ui/alert.rs index 79c18368..523f0676 100644 --- a/src/ui/alert.rs +++ b/src/ui/alert.rs @@ -7,6 +7,7 @@ use ratatui::{ widgets::{Block, Borders, Clear, Padding, Paragraph, Widget}, }; use ratatui_glamour::color::blend_2d; +use unicode_width::UnicodeWidthStr; #[derive(Debug, Clone)] pub(crate) enum DialogFormWidget { @@ -100,9 +101,6 @@ impl SysInspectUX { None, None, None, - None, - None, - DialogFormAlignment::Left, Some((10.0, &[palette::GRAY_0, palette::BG_2] as &[Color])), ); } @@ -128,9 +126,6 @@ impl SysInspectUX { None, None, None, - None, - None, - DialogFormAlignment::Left, Some((10.0, &[palette::GRAY_0, palette::BG_2] as &[Color])), ); } @@ -156,9 +151,6 @@ impl SysInspectUX { None, None, None, - None, - None, - DialogFormAlignment::Left, Some((10.0, &[palette::GRAY_0, palette::BG_2] as &[Color])), ); } @@ -185,9 +177,6 @@ impl SysInspectUX { None, None, None, - None, - DialogFormAlignment::Left, - None, ); } @@ -213,9 +202,6 @@ impl SysInspectUX { Some("Yep!"), Some("Nope"), None, - None, - None, - DialogFormAlignment::Left, Some((10.0, &[palette::GRAY_0, palette::BG_2] as &[Color])), ); } @@ -224,71 +210,106 @@ impl SysInspectUX { if !self.cluster_confirm_visible { return; } - let (plain_text, styled_text, widgets, form_focus, text_align, widget_align): ( - String, - Option>, - Option>, - Option, - Alignment, - DialogFormAlignment, - ) = match self.pending_cluster_action { - 1 => ( - "\nShut down every online minion\nin the entire cluster?".to_string(), + match self.pending_cluster_action { + 1 => Self::_popup_ex( + parent, + buf, + Some("Cluster Operation"), + "\nShut down every online minion\nin the entire cluster?", None, + Alignment::Center, + self.cluster_confirm_choice.clone(), + AlertButtons::YesNo, + Some(0), + Some(palette::PROCESSING_PEAK), None, None, - Alignment::Center, - DialogFormAlignment::Left, - ), - 2 => ( - "\nForce every online minion to drop\nand re-establish its connection?".to_string(), + Some(palette::WHITE), None, None, None, + Some((10.0, &[palette::GRAY_0, palette::BG_2] as &[Color])), + ), + 2 => Self::_popup_ex( + parent, + buf, + Some("Cluster Operation"), + "\nForce every online minion to drop\nand re-establish its connection?", + None, Alignment::Center, - DialogFormAlignment::Left, + self.cluster_confirm_choice.clone(), + AlertButtons::YesNo, + Some(0), + Some(palette::PROCESSING_PEAK), + None, + None, + Some(palette::WHITE), + None, + None, + None, + Some((10.0, &[palette::GRAY_0, palette::BG_2] as &[Color])), ), 3 => { let host = self.selected_popup_minion().map(|r| Self::online_host(&r)).unwrap_or_else(|| "unknown".to_string()); - let plain = format!("Do you want to unregister {host} from this cluster?"); let styled = Text::from(vec![Line::from(vec![ Span::raw("Do you want to unregister "), - Span::styled(host.clone(), Style::default().fg(palette::SUCCESS)), + Span::styled(host, Style::default().fg(palette::SUCCESS)), Span::raw(" from this cluster?"), ])]); - ( - plain, - Some(styled), - Some(vec![DialogFormWidget::Checkbox { label: "Remove client from the host".to_string(), checked: self.delete_force_remove }]), + Self::_popup_widgets( + parent, + buf, + Some("Cluster Operation"), + styled, + &[DialogFormWidget::Checkbox { label: "Remove client from the host".to_string(), checked: self.delete_force_remove }], Some(self.cluster_confirm_form_focus), Alignment::Center, DialogFormAlignment::Center, + Some(palette::PROCESSING_PEAK), + Some(palette::WHITE), + Some((10.0, &[palette::GRAY_0, palette::BG_2] as &[Color])), ) } - _ => return, - }; - Self::_popup_ex( - parent, - buf, - Some("Cluster Operation"), - &plain_text, - None, - text_align, - self.cluster_confirm_choice.clone(), - AlertButtons::YesNo, - Some(0), - Some(palette::PROCESSING_PEAK), - None, - None, - Some(palette::WHITE), - None, - None, - styled_text, - widgets.as_deref(), - form_focus, - widget_align, - Some((10.0, &[palette::GRAY_0, palette::BG_2] as &[Color])), - ); + _ => {} + } + } + + pub fn dialog_delete_progress(&self, parent: Rect, buf: &mut Buffer) { + if !self.delete_progress.visible { + return; + } + + let text = Line::from(vec![Span::styled( + format!("{} {}", self.delete_progress.spinner.view(), self.delete_progress.message), + Style::default().fg(palette::FG), + )]); + let width = (UnicodeWidthStr::width(self.delete_progress.message.as_str()) as u16 + 12).max(38); + let height = 5u16; + let x = parent.x + (parent.width.saturating_sub(width)) / 2; + let y = parent.y + (parent.height.saturating_sub(height)) / 2; + let canvas = Rect { x, y, width, height }; + + Clear.render(canvas, buf); + + let popup_block = Block::default() + .borders(Borders::ALL) + .border_type(ratatui::widgets::BorderType::Rounded) + .border_style(Style::default().fg(palette::PROCESSING_PEAK)) + .padding(Padding::horizontal(2)) + .style(Style::default().bg(palette::POPUP_BG_BASE)); + let popup_inner = popup_block.inner(canvas); + popup_block.render(canvas, buf); + + let [_, text_area, _]: [Rect; 3] = Layout::default() + .direction(Direction::Vertical) + .constraints([Constraint::Length(1), Constraint::Length(1), Constraint::Min(0)]) + .split(popup_inner) + .as_ref() + .try_into() + .unwrap(); + Paragraph::new(text).alignment(Alignment::Center).render(text_area, buf); + + Self::draw_popup_shadow(buf, canvas, height); } pub fn dialog_master_confirm(&self, parent: Rect, buf: &mut Buffer) { @@ -318,9 +339,6 @@ impl SysInspectUX { None, None, None, - None, - None, - DialogFormAlignment::Left, Some((10.0, &[palette::GRAY_0, palette::BG_2] as &[Color])), ); } @@ -361,7 +379,6 @@ impl SysInspectUX { parent: Rect, buf: &mut Buffer, title: Option<&str>, text: &str, background: Option, text_align: Alignment, choice: AlertResult, buttons: AlertButtons, width: Option, border_color: Option, border_type: Option, text_color: Option, title_color: Option, left_label: Option<&str>, right_label: Option<&str>, styled_text: Option>, - form_widgets: Option<&[DialogFormWidget]>, form_focus: Option, form_alignment: DialogFormAlignment, gradient: Option<(f32, &[Color])>, ) { let background = background.unwrap_or(palette::POPUP_BG_BASE); @@ -370,18 +387,15 @@ impl SysInspectUX { let text_color = text_color.unwrap_or(palette::FG); let title_color = title_color.unwrap_or(palette::BLACK); let has_gradient = gradient.is_some(); - let form_widgets = form_widgets.unwrap_or(&[]); - let has_form_widgets = !form_widgets.is_empty(); - let text = if has_form_widgets { text.to_string() } else { format!("\n{text}") }; + let text = format!("\n{text}"); let text_lines = Self::get_text_lines(&text); - let widget_rows = form_widgets.len() as u16; - let height = text_lines + widget_rows + if has_form_widgets { 5 } else { 3 }; + let height = text_lines + 3; #[allow(clippy::unnecessary_unwrap)] let mut width = if width.is_none() { (parent.width / 4).max(20) } else { width.unwrap() }; if width == 0 { - width = Self::get_max_width_lines(&text).max(Self::get_max_width_widgets(form_widgets)) + 6; + width = Self::get_max_width_lines(&text) + 6; } let x = parent.x + (parent.width.saturating_sub(width)) / 2; @@ -424,33 +438,16 @@ impl SysInspectUX { popup_block.render(canvas, buf); let text_bg = if has_gradient { Style::default().fg(text_color) } else { Style::default().fg(text_color).bg(background) }; - let vertical_chunks = if has_form_widgets { - Layout::default() - .direction(Direction::Vertical) - .constraints([ - Constraint::Length(1), - Constraint::Length(text_lines), - Constraint::Length(1), - Constraint::Length(widget_rows), - Constraint::Length(1), - Constraint::Length(1), - ]) - .split(popup_inner) - } else { - Layout::default().direction(Direction::Vertical).constraints([Constraint::Length(text_lines), Constraint::Length(1)]).split(popup_inner) - }; + let vertical_chunks = + Layout::default().direction(Direction::Vertical).constraints([Constraint::Length(text_lines), Constraint::Length(1)]).split(popup_inner); - let text_area = if has_form_widgets { vertical_chunks[1] } else { vertical_chunks[0] }; - let widget_area = if has_form_widgets { Some(vertical_chunks[3]) } else { None }; - let button_area = if has_form_widgets { vertical_chunks[5] } else { vertical_chunks[1] }; + let text_area = vertical_chunks[0]; + let button_area = vertical_chunks[1]; if let Some(st) = styled_text { Paragraph::new(st).alignment(text_align).style(text_bg).render(text_area, buf); } else { Paragraph::new(text).alignment(text_align).style(text_bg).render(text_area, buf); } - if let Some(area) = widget_area { - Self::render_form_widgets(buf, area, background, form_widgets, form_focus, form_alignment); - } let (lbtn_label, rbtn_label) = match buttons { AlertButtons::YesNo => (Self::format_button(YES_LABEL), Self::format_button(NO_LABEL)), AlertButtons::OkCancel => (Self::format_button(left_label.unwrap_or(OK_LABEL)), Self::format_button(right_label.unwrap_or(CANCEL_LABEL))), @@ -485,17 +482,7 @@ impl SysInspectUX { ]) .split(button_area); - let (left_style, right_style) = if has_form_widgets { - match form_focus.unwrap_or(DialogFormFocus::LeftButton) { - DialogFormFocus::LeftButton => (b_selected, b_unselected), - DialogFormFocus::RightButton => (b_unselected, b_selected), - DialogFormFocus::Widget(_) => (b_unselected, b_unselected), - } - } else if choice == AlertResult::Default { - (b_unselected, b_selected) - } else { - (b_selected, b_unselected) - }; + let (left_style, right_style) = if choice == AlertResult::Default { (b_unselected, b_selected) } else { (b_selected, b_unselected) }; Paragraph::new(lbtn_label).style(left_style).render(button_splits[1], buf); Paragraph::new(rbtn_label).style(right_style).render(button_splits[3], buf); @@ -533,6 +520,96 @@ impl SysInspectUX { } } + fn _popup_widgets( + parent: Rect, buf: &mut Buffer, title: Option<&str>, text: Text<'_>, widgets: &[DialogFormWidget], focus: Option, + text_align: Alignment, widget_align: DialogFormAlignment, border_color: Option, title_color: Option, + gradient: Option<(f32, &[Color])>, + ) { + let background = palette::POPUP_BG_BASE; + let border_color = border_color.unwrap_or(palette::BORDER); + let title_color = title_color.unwrap_or(palette::BLACK); + let has_gradient = gradient.is_some(); + let text_lines = text.lines.len() as u16; + let widget_rows = widgets.len() as u16; + let height = text_lines + widget_rows + 6; + let mut width = Self::get_max_width_text(&text).max(Self::get_max_width_widgets(widgets)) + 6; + width = width.max((parent.width / 4).max(20)); + + let x = parent.x + (parent.width.saturating_sub(width)) / 2; + let y = parent.y + (parent.height.saturating_sub(height)) / 2; + let canvas = Rect { x, y, width, height }; + Clear.render(canvas, buf); + + let popup_block = Block::default() + .title(if let Some(t) = title { + Line::from(vec![ + Span::styled("\u{E0B2}", Style::default().fg(border_color)), + Span::styled(t.to_string(), Style::default().fg(title_color).bg(border_color)), + Span::styled("\u{E0B0}", Style::default().fg(border_color)), + ]) + } else { + Line::from("") + }) + .title_alignment(Alignment::Center) + .borders(Borders::ALL) + .border_type(ratatui::widgets::BorderType::Rounded) + .border_style(Style::default().fg(border_color)) + .padding(Padding::horizontal(2)) + .style(if has_gradient { Style::default() } else { Style::default().bg(background) }); + let popup_inner = popup_block.inner(canvas); + + if let Some((angle, stops)) = gradient { + let colors = blend_2d(canvas.width as usize, canvas.height as usize, angle, stops); + for row in 0..canvas.height { + for col in 0..canvas.width { + let idx = row as usize * canvas.width as usize + col as usize; + if let Some(cell) = buf.cell_mut(Position::new(canvas.x + col, canvas.y + row)) { + cell.set_bg(colors[idx]); + } + } + } + } + + popup_block.render(canvas, buf); + let chunks = Layout::default() + .direction(Direction::Vertical) + .constraints([ + Constraint::Length(1), + Constraint::Length(text_lines), + Constraint::Length(1), + Constraint::Length(widget_rows), + Constraint::Length(1), + Constraint::Length(1), + ]) + .split(popup_inner); + + Paragraph::new(text).alignment(text_align).style(Style::default().fg(palette::FG)).render(chunks[1], buf); + Self::render_form_widgets(buf, chunks[3], background, widgets, focus, widget_align); + + let lbtn_label = Self::format_button(YES_LABEL); + let rbtn_label = Self::format_button(NO_LABEL); + let button_splits = Layout::default() + .direction(Direction::Horizontal) + .constraints([ + Constraint::Length((chunks[5].width.saturating_sub(lbtn_label.len() as u16 + 3 + rbtn_label.len() as u16)) / 2), + Constraint::Length(lbtn_label.len().try_into().unwrap_or(DEFAULT_BUTTON_WIDTH)), + Constraint::Length(3), + Constraint::Length(rbtn_label.len().try_into().unwrap_or(DEFAULT_BUTTON_WIDTH)), + ]) + .split(chunks[5]); + let b_selected = Style::default().fg(palette::WHITE).bg(palette::PROCESSING_HEAT).add_modifier(Modifier::BOLD); + let b_unselected = Style::default().fg(palette::FG).bg(palette::BG_2).add_modifier(Modifier::BOLD); + let (left_style, right_style) = match focus.unwrap_or(DialogFormFocus::LeftButton) { + DialogFormFocus::LeftButton => (b_selected, b_unselected), + DialogFormFocus::RightButton => (b_unselected, b_selected), + DialogFormFocus::Widget(_) => (b_unselected, b_unselected), + }; + Paragraph::new(lbtn_label).style(left_style).render(button_splits[1], buf); + Paragraph::new(rbtn_label).style(right_style).render(button_splits[3], buf); + + Self::draw_popup_shadow(buf, canvas, height); + } + /// Draws a popup area fn _popup( parent: Rect, buf: &mut Buffer, title: Option<&str>, text: &str, background: Option, text_align: Alignment, choice: AlertResult, @@ -656,7 +733,8 @@ impl SysInspectUX { } fn render_form_widgets( - buf: &mut Buffer, area: Rect, background: Color, widgets: &[DialogFormWidget], focus: Option, alignment: DialogFormAlignment, + buf: &mut Buffer, area: Rect, _background: Color, widgets: &[DialogFormWidget], focus: Option, + alignment: DialogFormAlignment, ) { for (idx, widget) in widgets.iter().enumerate() { let y = area.y + idx as u16; @@ -672,16 +750,16 @@ impl SysInspectUX { DialogFormAlignment::Center => area.x + area.width.saturating_sub(row_width) / 2, }; let row_style = if is_focused { - Style::default().fg(palette::HIGHLIGHT).bg(background).add_modifier(Modifier::BOLD) + Style::default().fg(palette::HIGHLIGHT).add_modifier(Modifier::BOLD) } else { - Style::default().fg(palette::FG).bg(background) + Style::default().fg(palette::FG) }; let checkbox_style = if is_focused { row_style } else if *checked { - Style::default().fg(palette::SUCCESS).bg(background) + Style::default().fg(palette::SUCCESS) } else { - Style::default().fg(palette::GRAY_1).bg(background) + Style::default().fg(palette::GRAY_1) }; buf.set_string(start_x, y, checkbox, checkbox_style); buf.set_string(start_x + 3, y, label, row_style); @@ -699,6 +777,40 @@ impl SysInspectUX { .max() .unwrap_or(0) } + + fn get_max_width_text(text: &Text<'_>) -> u16 { + text.lines.iter().map(|l| UnicodeWidthStr::width(l.to_string().as_str()) as u16).max().unwrap_or(0) + } + + fn draw_popup_shadow(buf: &mut Buffer, canvas: Rect, height: u16) { + let buf_area = buf.area(); + let max_x = buf_area.right().saturating_sub(1); + let max_y = buf_area.bottom().saturating_sub(1); + for idx in 0..canvas.width { + let sx = canvas.x.saturating_add(2).saturating_add(idx); + let sy = canvas.y.saturating_add(height); + if sx > max_x || sy > max_y { + continue; + } + if let Some(cell) = buf.cell_mut(Position::new(sx, sy)) { + cell.set_bg(palette::SHADOW_BG); + cell.set_fg(palette::SHADOW_FG); + } + } + for offset in 0..2 { + for idx in 0..height { + let sx = canvas.x.saturating_add(canvas.width).saturating_add(offset); + let sy = canvas.y.saturating_add(idx).saturating_add(1); + if sx > max_x || sy > max_y { + continue; + } + if let Some(cell) = buf.cell_mut(Position::new(sx, sy)) { + cell.set_bg(palette::SHADOW_BG); + cell.set_fg(palette::SHADOW_FG); + } + } + } + } } /// Wrap text to a maximum width, preserving leading whitespace per paragraph. diff --git a/src/ui/mod.rs b/src/ui/mod.rs index 5ddcaea5..32c3fd7f 100644 --- a/src/ui/mod.rs +++ b/src/ui/mod.rs @@ -31,6 +31,7 @@ use ratatui::{ widgets::{Paragraph, Row}, }; use ratatui_cheese::tree::TreeState; +use ratatui_glamour::widgets::spinner; use std::{ cell::{Cell, RefCell}, io::{self}, @@ -102,6 +103,23 @@ pub struct UISizes { pub table_info: usize, } +#[derive(Debug)] +pub struct DeleteProgressState { + pub visible: bool, + pub message: String, + pub spinner: spinner::Model, + pub last_tick: Instant, +} + +impl Default for DeleteProgressState { + fn default() -> Self { + let mut spinner_model = spinner::Model::new(); + spinner_model.spinner = spinner::Spinner::mini_dot(); + spinner_model.style = Style::default().fg(palette::PROCESSING_PEAK); + Self { visible: false, message: String::new(), spinner: spinner_model, last_tick: Instant::now() } + } +} + #[derive(Debug)] pub struct SysInspectUX { exit: bool, @@ -225,6 +243,8 @@ pub struct SysInspectUX { pub registration_form: minreg::RegistrationForm, pub registration_progress: Arc>, pub registration_task: Option>, + pub delete_progress: DeleteProgressState, + pub delete_task: Option>>, // Exit-after-popup state (for setup config-written notice) pub pending_exit: bool, @@ -334,6 +354,8 @@ impl Default for SysInspectUX { registration_form: minreg::RegistrationForm::default(), registration_progress: Arc::new(std::sync::Mutex::new(minreg::RegistrationProgress::placeholder())), registration_task: None, + delete_progress: DeleteProgressState::default(), + delete_task: None, pending_exit: false, pending_exit_message: None, @@ -627,7 +649,10 @@ impl SysInspectUX { fn on_events(&mut self) -> io::Result<()> { self.sync_main_focus_for_overlays(); - let poll_dur = if self.repo_manager.progress.lock().unwrap().is_some() || self.registration_progress.lock().unwrap().visible { + let poll_dur = if self.repo_manager.progress.lock().unwrap().is_some() + || self.registration_progress.lock().unwrap().visible + || self.delete_progress.visible + { Duration::from_millis(50) } else { Duration::from_secs(1) @@ -663,6 +688,35 @@ impl SysInspectUX { } } } + if self.delete_progress.visible && self.delete_progress.last_tick.elapsed() >= self.delete_progress.spinner.spinner.fps { + let tick = self.delete_progress.spinner.tick(); + self.delete_progress.spinner.update(tick); + self.delete_progress.last_tick = Instant::now(); + } + if self.delete_progress.visible + && self.delete_task.as_ref().is_some_and(|task| task.is_finished()) + && let Some(task) = self.delete_task.take() + { + let result = tokio::task::block_in_place(|| tokio::runtime::Handle::current().block_on(task)); + self.delete_progress.visible = false; + self.delete_progress.message.clear(); + self.restore_status(); + match result { + Ok(Ok(mid)) => { + self.info_alert_visible = true; + self.info_alert_message = format!("Minion deleted: {mid}"); + self.refresh_minions(); + } + Ok(Err(err)) => { + self.error_alert_visible = true; + self.error_alert_message = err; + } + Err(err) => { + self.error_alert_visible = true; + self.error_alert_message = format!("Failed to join delete task: {err}"); + } + } + } // Process file picker result for repo manager if self.repo_manager.visible && let Some(path) = self.file_picker.selected.take() @@ -1282,6 +1336,7 @@ impl SysInspectUX { || self.repo_manager.platforms.delete_visible || self.registration_form.visible || self.registration_progress.lock().unwrap().visible + || self.delete_progress.visible } fn sync_main_focus_for_overlays(&mut self) { @@ -1560,7 +1615,7 @@ impl SysInspectUX { self.cluster_confirm_visible = true; self.cluster_confirm_choice = AlertResult::ClusterConfirm; self.pending_cluster_action = action; - self.cluster_confirm_form_focus = if action == 3 { DialogFormFocus::Widget(0) } else { DialogFormFocus::LeftButton }; + self.cluster_confirm_form_focus = if action == 3 { DialogFormFocus::RightButton } else { DialogFormFocus::LeftButton }; } fn close_cluster_confirm(&mut self) { @@ -1611,26 +1666,21 @@ impl SysInspectUX { }; let mid = row.minion_id.clone(); let force_ctx = if force { Some(serde_json::json!({"force": true}).to_string()) } else { None }; - match tokio::task::block_in_place(|| { - tokio::runtime::Handle::current().block_on(async { - call_master_console(&self.cfg, &format!("{SCHEME_COMMAND}{CLUSTER_REMOVE_MINION}"), "*", None, Some(&mid), force_ctx.as_ref()).await - }) - }) { - Ok(rsp) if matches!(rsp.payload, ConsolePayload::Ack { .. }) => { - self.info_alert_visible = true; - self.info_alert_message = format!("Minion deleted: {mid}"); - self.refresh_minions(); - } - Ok(_) => { - self.error_alert_visible = true; - self.error_alert_message = "Master did not acknowledge minion removal.".to_string(); - } - Err(err) => { - self.error_alert_visible = true; - self.error_alert_message = format!("Failed to delete minion: {err}"); + self.delete_progress.visible = true; + self.delete_progress.message = + if force { "Removing client from host, please wait...".to_string() } else { "Removing client, please wait...".to_string() }; + self.delete_progress.last_tick = Instant::now(); + self.delete_task = Some(tokio::spawn({ + let cfg = self.cfg.clone(); + async move { + match call_master_console(&cfg, &format!("{SCHEME_COMMAND}{CLUSTER_REMOVE_MINION}"), "*", None, Some(&mid), force_ctx.as_ref()).await + { + Ok(rsp) if matches!(rsp.payload, ConsolePayload::Ack { .. }) => Ok(mid), + Ok(_) => Err("Master did not acknowledge minion removal.".to_string()), + Err(err) => Err(format!("Failed to delete minion: {err}")), + } } - } - self.status_at_minions_browser(); + })); } fn open_logs_popup(&mut self) { @@ -3311,6 +3361,10 @@ impl SysInspectUX { return; } + if self.delete_progress.visible { + return; + } + // Master operations menu is modal if self.on_master_menu(e) { return; diff --git a/src/ui/wgt.rs b/src/ui/wgt.rs index fb8da2b1..942f9bb1 100644 --- a/src/ui/wgt.rs +++ b/src/ui/wgt.rs @@ -311,6 +311,7 @@ impl Widget for &SysInspectUX { self.dialog_master_logs(area, buf); self.dialog_trait_tag(area, buf); self.dialog_cluster_confirm(area, buf); + self.dialog_delete_progress(area, buf); self.dialog_master_confirm(area, buf); self.master_actions_menu(area, buf); self.repo_manager.render(area, buf); From 6a04385f69090e05342eb77e6972487dabf26c27 Mon Sep 17 00:00:00 2001 From: Bo Maryniuk Date: Sun, 14 Jun 2026 19:52:44 +0200 Subject: [PATCH 05/22] Fix multiple bugs on the way, add form dialog --- src/netadd/workflow.rs | 23 ++++++++++++ src/ui/alert.rs | 2 +- src/ui/minreg.rs | 84 ++++++++++++++++++++++-------------------- src/ui/mod.rs | 75 ++++++++++++++++++++++++++++++++++--- src/ui/online.rs | 2 +- src/ui/palette.rs | 3 ++ src/ui/platforms.rs | 3 +- src/ui/profiles.rs | 3 +- src/ui/rawlogs.rs | 4 +- src/ui/repomanager.rs | 3 +- src/ui/traitsview.rs | 4 +- 11 files changed, 153 insertions(+), 53 deletions(-) diff --git a/src/netadd/workflow.rs b/src/netadd/workflow.rs index d7800537..f32f17e7 100644 --- a/src/netadd/workflow.rs +++ b/src/netadd/workflow.rs @@ -454,6 +454,9 @@ impl HostSetup { .inspect_err(|err| self.recover_add_failure(ctx, &ssh, elevate, AddFailureStage::Setup, None, err))?, ..self.clone() }; + setup + .ensure_not_already_registered(ctx) + .inspect_err(|err| setup.recover_add_failure(ctx, &ssh, elevate, AddFailureStage::Setup, setup.minion_id.as_deref(), err))?; progress(5, "Preparing runtime..."); setup .prepare_runtime(&ssh, elevate) @@ -729,6 +732,26 @@ impl HostSetup { Ok(rows_have_traits(&rows)) } + fn ensure_not_already_registered(&self, ctx: &SetupContext) -> Result<(), SysinspectError> { + let Some(mid) = self.minion_id.as_deref() else { + return Ok(()); + }; + let rsp = match call_console(&ctx.cfg, &format!("{SCHEME_COMMAND}{CLUSTER_MINION_INFO}"), "*", Some(mid), None) { + Ok(rsp) => rsp, + Err(err) if is_waitable_console_miss(&err) => return Ok(()), + Err(err) => return Err(err), + }; + let ConsolePayload::MinionInfo { rows } = rsp.payload else { + return Err(SysinspectError::MasterGeneralError( + "Master returned an unexpected payload while checking existing minion registration".to_string(), + )); + }; + if rows.is_empty() { + return Ok(()); + } + Err(SysinspectError::MinionGeneralError("Minion is already registered. Please unregister it first.".to_string())) + } + fn has_transport(&self, ctx: &SetupContext) -> Result { let rsp = match call_console( &ctx.cfg, diff --git a/src/ui/alert.rs b/src/ui/alert.rs index 523f0676..27632bd2 100644 --- a/src/ui/alert.rs +++ b/src/ui/alert.rs @@ -101,7 +101,7 @@ impl SysInspectUX { None, None, None, - Some((10.0, &[palette::GRAY_0, palette::BG_2] as &[Color])), + Some((15.0, &[palette::BG_2, palette::BG_1] as &[Color])), ); } diff --git a/src/ui/minreg.rs b/src/ui/minreg.rs index 631f2b00..6536ff62 100644 --- a/src/ui/minreg.rs +++ b/src/ui/minreg.rs @@ -1,5 +1,5 @@ use super::{ - palette, + SysInspectUX, palette, title::{self, TitleSegment, TitleStyle}, }; use crate::netadd::{ @@ -134,26 +134,27 @@ impl RegistrationForm { } let label_w = 14u16; - let focus_style = Style::default().fg(palette::ACCENT).add_modifier(Modifier::BOLD); - let muted = Style::default().fg(palette::MUTED); + let checkbox_focus_style = Style::default().fg(palette::HIGHLIGHT); + let form_label = Style::default().fg(palette::FORM_LABEL); let mut row_y = inner.y + 1; - Self::render_input_row(inner.x, &mut row_y, inner.width, buf, " Hostname:", &self.hostname, self.focus == FormFocus::Hostname, label_w); - Self::render_input_row(inner.x, &mut row_y, inner.width, buf, " SSH User:", &self.user, self.focus == FormFocus::User, label_w); - Self::render_input_row(inner.x, &mut row_y, inner.width, buf, " Path:", &self.path, self.focus == FormFocus::Path, label_w); + Self::render_input_row(inner.x, &mut row_y, inner.width, buf, "Hostname:", &self.hostname, self.focus == FormFocus::Hostname, label_w); + Self::render_input_row(inner.x, &mut row_y, inner.width, buf, "SSH User:", &self.user, self.focus == FormFocus::User, label_w); + Self::render_input_row(inner.x, &mut row_y, inner.width, buf, "Path:", &self.path, self.focus == FormFocus::Path, label_w); row_y += 1; // Sudo checkbox - let sudo_chk = if self.use_sudo { "[x] Use sudo (wheel)" } else { "[ ] Use sudo (wheel)" }; - let sudo_style = if self.focus == FormFocus::SudoCheck { focus_style } else { muted }; - buf.set_string(inner.x + 3, row_y, sudo_chk, sudo_style); + let sudo_chk = if self.use_sudo { "▣ Use sudo (wheel)" } else { "□ Use sudo (wheel)" }; + let sudo_style = if self.focus == FormFocus::SudoCheck { checkbox_focus_style } else { form_label }; + buf.set_string(inner.x + 2, row_y, sudo_chk, sudo_style); + row_y += 1; row_y += 1; - let ok_label = " [ OK ] "; - let cancel_label = " [ Cancel ] "; + let ok_label = SysInspectUX::format_button("OK"); + let cancel_label = SysInspectUX::format_button("Cancel"); let btn_w = ok_label.width() as u16 + cancel_label.width() as u16 + 6; let btn_x = inner.x + (inner.width.saturating_sub(btn_w)) / 2; @@ -168,8 +169,8 @@ impl RegistrationForm { Style::default().fg(palette::FG).bg(palette::BG_2).add_modifier(Modifier::BOLD) }; - buf.set_string(btn_x, row_y, ok_label, ok_style); - buf.set_string(btn_x + ok_label.width() as u16 + 4, row_y, cancel_label, cancel_style); + buf.set_string(btn_x, row_y, &ok_label, ok_style); + buf.set_string(btn_x + ok_label.width() as u16 + 4, row_y, &cancel_label, cancel_style); draw_shadow(buf, canvas, dlg_w, dlg_h); } @@ -258,12 +259,12 @@ impl RegistrationForm { base_x: u16, row_y: &mut u16, inner_width: u16, buf: &mut ratatui::prelude::Buffer, label: &str, state: &InputState, focused: bool, label_w: u16, ) { - let muted = Style::default().fg(palette::MUTED); - let focus_style = Style::default().fg(palette::ACCENT).add_modifier(Modifier::BOLD); - let lstyle = if focused { focus_style } else { muted }; + let label_style = Style::default().fg(palette::FORM_LABEL); + let focus_style = Style::default().fg(palette::FORM_LABEL); + let lstyle = if focused { focus_style } else { label_style }; let label_padded = format!("{:width$}", label, width = label_w as usize); - buf.set_string(base_x + 3, *row_y, &label_padded, lstyle); - let input_x = base_x + 3 + label_w; + buf.set_string(base_x + 2, *row_y, &label_padded, lstyle); + let input_x = base_x + 2 + label_w + 1; let input_w = inner_width.saturating_sub(label_w + 6); if input_w > 0 { let mut is = copy_input_state(state, focused); @@ -282,6 +283,7 @@ pub struct RegistrationProgress { pub total: usize, pub message: String, pub host: String, + pub host_label: String, pub platform: String, pub minion_id: Option, pub done: bool, @@ -299,6 +301,7 @@ impl RegistrationProgress { total: STEP_LABELS.len(), message: "Connecting...".into(), host, + host_label: String::new(), platform: String::new(), minion_id: None, done: false, @@ -316,6 +319,7 @@ impl RegistrationProgress { total: STEP_LABELS.len(), message: String::new(), host: String::new(), + host_label: String::new(), platform: String::new(), minion_id: None, done: false, @@ -333,14 +337,14 @@ pub fn render_progress(progress: &RegistrationProgress, parent: Rect, buf: &mut } let has_error = progress.error.is_some(); let dlg_w = (parent.width * 3 / 4).clamp(52, 72); - let dlg_h = if has_error { 16u16 } else { 10u16 }; + let dlg_h = if has_error { 11u16 } else { 10u16 }; let x = parent.x + (parent.width.saturating_sub(dlg_w)) / 2; let y = parent.y + (parent.height.saturating_sub(dlg_h)) / 2; let canvas = Rect { x, y, width: dlg_w, height: dlg_h }; Clear.render(canvas, buf); - let grad = blend_2d(canvas.width as usize, canvas.height as usize, 13.0, &[palette::GRAY_0, palette::PROCESSING_GLOW] as &[Color]); + let grad = blend_2d(canvas.width as usize, canvas.height as usize, 15.0, &[palette::BG_2, palette::BG_1] as &[Color]); for row in 0..canvas.height { for col in 0..canvas.width { let idx = row as usize * canvas.width as usize + col as usize; @@ -350,20 +354,23 @@ pub fn render_progress(progress: &RegistrationProgress, parent: Rect, buf: &mut } } + let border_color = if has_error { palette::ERROR_PEAK } else { palette::PROCESSING_GLOW }; + let title_bg = if has_error { palette::ERROR_PEAK } else { palette::PROCESSING_BASE }; + let block = Block::default() .borders(Borders::ALL) .border_type(BorderType::Rounded) - .border_style(Style::default().fg(palette::PROCESSING_GLOW)) + .border_style(Style::default().fg(border_color)) .style(Style::default()); let inner = block.inner(canvas); block.render(canvas, buf); - let title_style = TitleStyle::cyberpunk(palette::PROCESSING_GLOW); + let title_style = TitleStyle::cyberpunk(border_color); title::overlay_gradient_title( buf, canvas, &title_style, - &[TitleSegment { text: " Minion Registration ".into(), bg: palette::PROCESSING_BASE, fg: palette::FG, modifier: Modifier::empty() }], + &[TitleSegment { text: " Minion Registration ".into(), bg: title_bg, fg: palette::FG, modifier: Modifier::empty() }], ); if inner.height < 3 { @@ -373,29 +380,30 @@ pub fn render_progress(progress: &RegistrationProgress, parent: Rect, buf: &mut let mut row_y = inner.y; let host_label = format!(" Registering: {}", progress.host); - buf.set_string(inner.x + 2, row_y, truncate_str(&host_label, (inner.width.saturating_sub(4)) as usize), Style::default().fg(palette::FG)); + buf.set_string(inner.x + 1, row_y, truncate_str(&host_label, (inner.width.saturating_sub(3)) as usize), Style::default().fg(palette::FG)); row_y += 1; if !progress.platform.is_empty() { let plat_label = format!(" Detected: {}", progress.platform); - buf.set_string(inner.x + 2, row_y, truncate_str(&plat_label, (inner.width.saturating_sub(4)) as usize), Style::default().fg(palette::MUTED)); + buf.set_string(inner.x + 1, row_y, truncate_str(&plat_label, (inner.width.saturating_sub(3)) as usize), Style::default().fg(palette::MUTED)); row_y += 1; } row_y += 1; if let Some(ref err) = progress.error { - buf.set_string(inner.x + 2, row_y, "Registration failed:", Style::default().fg(palette::ERROR_PEAK).add_modifier(Modifier::BOLD)); + buf.set_string(inner.x + 1, row_y, "Registration failed:", Style::default().fg(palette::ERROR).add_modifier(Modifier::BOLD)); row_y += 1; - let log_w = inner.width.saturating_sub(5); + let log_w = inner.width.saturating_sub(4); let lines = wrap_text(err, log_w); - let view_h = (inner.bottom().saturating_sub(row_y + 1)) as usize; + let close_y = inner.bottom().saturating_sub(2); + let view_h = close_y.saturating_sub(row_y + 1) as usize; let max_scroll = lines.len().saturating_sub(view_h); let s = progress.error_scroll.min(max_scroll); if view_h > 0 { let log_start_y = row_y; for line in lines.iter().skip(s).take(view_h) { - buf.set_string(inner.x + 2, row_y, truncate_str(line, log_w as usize), Style::default().fg(palette::MUTED)); + buf.set_string(inner.x + 1, row_y, truncate_str(line, log_w as usize), Style::default().fg(palette::ERROR_GLOW)); row_y += 1; } if lines.len() > view_h { @@ -407,21 +415,16 @@ pub fn render_progress(progress: &RegistrationProgress, parent: Rect, buf: &mut buf.set_string(sb_x, log_start_y + thumb_y, "█", Style::default().fg(palette::PROCESSING_HEAT)); } } - let close = "[ Close ]"; + let close = SysInspectUX::format_button("Close"); let btn_x = inner.x + (inner.width.saturating_sub(close.len() as u16)) / 2; - buf.set_string( - btn_x, - inner.bottom().saturating_sub(1), - close, - Style::default().fg(palette::FG).bg(palette::BG_2).add_modifier(Modifier::BOLD), - ); + buf.set_string(btn_x, close_y, &close, Style::default().fg(palette::WHITE).bg(palette::PROCESSING_HEAT).add_modifier(Modifier::BOLD)); } else if progress.done { let done_msg = if let Some(ref mid) = progress.minion_id { format!(" Registered: {mid}") } else { " Complete".into() }; buf.set_string(inner.x + 2, row_y, &done_msg, Style::default().fg(palette::SUCCESS_PEAK).add_modifier(Modifier::BOLD)); row_y += 1; - let close = "[ Close ]"; + let close = SysInspectUX::format_button("Close"); let btn_x = inner.x + (inner.width.saturating_sub(close.len() as u16)) / 2; - buf.set_string(btn_x, row_y + 1, close, Style::default().fg(palette::FG).bg(palette::BG_2).add_modifier(Modifier::BOLD)); + buf.set_string(btn_x, row_y + 1, &close, Style::default().fg(palette::FG).bg(palette::BG_2).add_modifier(Modifier::BOLD)); } else { let msg = progress.message.clone(); buf.set_string(inner.x + 2, row_y, truncate_str(&msg, (inner.width.saturating_sub(4)) as usize), Style::default().fg(palette::FG)); @@ -443,9 +446,9 @@ pub fn render_progress(progress: &RegistrationProgress, parent: Rect, buf: &mut let pct_x = inner.x + (inner.width.saturating_sub(pct_text.len() as u16)) / 2; buf.set_string(pct_x, bar_y, &pct_text, Style::default().fg(palette::FG).add_modifier(Modifier::BOLD)); - let cancel = "[ Cancel ]"; + let cancel = SysInspectUX::format_button("Cancel"); let btn_x = inner.x + (inner.width.saturating_sub(cancel.len() as u16)) / 2; - buf.set_string(btn_x, bar_y + 2, cancel, Style::default().fg(palette::FG).bg(palette::BG_2).add_modifier(Modifier::BOLD)); + buf.set_string(btn_x, bar_y + 2, &cancel, Style::default().fg(palette::FG).bg(palette::BG_2).add_modifier(Modifier::BOLD)); } draw_shadow(buf, canvas, dlg_w, dlg_h); @@ -520,6 +523,7 @@ fn run_provision( { let mut p = progress.lock().unwrap(); p.host = host_display.clone(); + p.host_label = hostname.clone(); } if progress.lock().unwrap().cancelled.load(Ordering::SeqCst) { diff --git a/src/ui/mod.rs b/src/ui/mod.rs index 32c3fd7f..c74eb66d 100644 --- a/src/ui/mod.rs +++ b/src/ui/mod.rs @@ -245,6 +245,7 @@ pub struct SysInspectUX { pub registration_task: Option>, pub delete_progress: DeleteProgressState, pub delete_task: Option>>, + pub delete_success_message: String, // Exit-after-popup state (for setup config-written notice) pub pending_exit: bool, @@ -356,6 +357,7 @@ impl Default for SysInspectUX { registration_task: None, delete_progress: DeleteProgressState::default(), delete_task: None, + delete_success_message: String::new(), pending_exit: false, pending_exit_message: None, @@ -704,7 +706,11 @@ impl SysInspectUX { match result { Ok(Ok(mid)) => { self.info_alert_visible = true; - self.info_alert_message = format!("Minion deleted: {mid}"); + self.info_alert_message = if self.delete_success_message.is_empty() { + Self::format_machine_message("Minion deleted", None, None, Some(&mid)) + } else { + std::mem::take(&mut self.delete_success_message) + }; self.refresh_minions(); } Ok(Err(err)) => { @@ -750,7 +756,16 @@ impl SysInspectUX { let p = self.registration_progress.lock().unwrap(); let has_error = p.error.is_some(); if !has_error { - let msg = format!("Minion registered: {}", p.minion_id.as_deref().unwrap_or("?")); + let host = p.host_label.trim(); + let host_value = if !host.is_empty() { + Some(host) + } else if !p.host.trim().is_empty() { + Some(p.host.trim()) + } else { + None + }; + let platform = (!p.platform.trim().is_empty()).then_some(p.platform.trim()); + let msg = Self::format_machine_message("Minion registered", host_value, platform, p.minion_id.as_deref()); drop(p); *self.registration_progress.lock().unwrap() = minreg::RegistrationProgress::placeholder(); self.restore_status(); @@ -1665,6 +1680,26 @@ impl SysInspectUX { } }; let mid = row.minion_id.clone(); + let host_label = Self::online_host(&row); + let host = if !host_label.trim().is_empty() && host_label != "unknown" { + Some(host_label) + } else if !row.ip.trim().is_empty() { + Some(row.ip.clone()) + } else { + None + }; + let platform: Option = if !row.os_name.trim().is_empty() { + if row.os_version.trim().is_empty() { + Some(os_display_name(&row.os_name).to_string()) + } else { + Some(format!("{} {}", os_display_name(&row.os_name), row.os_version.trim())) + } + } else if !row.os_distribution.trim().is_empty() { + Some(row.os_distribution.clone()) + } else { + None + }; + self.delete_success_message = Self::format_machine_message("Minion deleted", host.as_deref(), platform.as_deref(), Some(&mid)); let force_ctx = if force { Some(serde_json::json!({"force": true}).to_string()) } else { None }; self.delete_progress.visible = true; self.delete_progress.message = @@ -3037,11 +3072,17 @@ impl SysInspectUX { self.master_confirm_choice = AlertResult::Default; self.master_confirm_action = 2; } - KeyCode::Up => { - self.master_menu_sel = self.master_menu_sel.saturating_sub(1); + KeyCode::Up | KeyCode::PageUp => { + let total = macts::total_master_menu_items(); + if total > 0 { + self.master_menu_sel = if self.master_menu_sel == 0 { total - 1 } else { self.master_menu_sel - 1 }; + } } - KeyCode::Down => { - self.master_menu_sel = (self.master_menu_sel + 1).min(macts::total_master_menu_items().saturating_sub(1)); + KeyCode::Down | KeyCode::PageDown => { + let total = macts::total_master_menu_items(); + if total > 0 { + self.master_menu_sel = (self.master_menu_sel + 1) % total; + } } KeyCode::Enter => { self.master_menu_visible = false; @@ -3959,6 +4000,28 @@ impl SysInspectUX { }) } + fn format_machine_message(action: &str, primary_value: Option<&str>, platform: Option<&str>, minion_id: Option<&str>) -> String { + let mut rows = Vec::new(); + if let Some(value) = primary_value.filter(|v| !v.trim().is_empty()) { + rows.push((format!("{action}:"), value.trim().to_string())); + } else { + rows.push((format!("{action}:"), "OK".to_string())); + } + if let Some(value) = platform.filter(|v| !v.trim().is_empty()) { + rows.push(("Platform:".to_string(), value.trim().to_string())); + } + if let Some(value) = minion_id.filter(|v| !v.trim().is_empty()) { + rows.push(("Machine ID:".to_string(), Self::short_machine_id(value))); + } + let width = rows.iter().map(|(label, _)| label.len()).max().unwrap_or(0); + rows.into_iter().map(|(label, value)| format!("{label:>().join("\n") + } + + fn short_machine_id(mid: &str) -> String { + let trimmed = mid.trim(); + if trimmed.chars().count() <= 8 { trimmed.to_string() } else { format!("{}...", trimmed.chars().take(8).collect::()) } + } + /// Count the vertical space for the alert display, plus three empty lines fn get_text_lines(s: &str) -> u16 { s.matches('\n').count() as u16 + 3 diff --git a/src/ui/online.rs b/src/ui/online.rs index 7c1e256c..ef53ef7a 100644 --- a/src/ui/online.rs +++ b/src/ui/online.rs @@ -133,7 +133,7 @@ impl SysInspectUX { fn _render_filter(area: Rect, buf: &mut Buffer, focused: bool, filter_state: &InputState) { let label_style = - if focused { Style::default().fg(palette::ACCENT).add_modifier(Modifier::BOLD) } else { Style::default().fg(palette::MUTED) }; + if focused { Style::default().fg(palette::FORM_LABEL).add_modifier(Modifier::BOLD) } else { Style::default().fg(palette::FORM_LABEL) }; buf.set_string(area.x, area.y, "Filter: ", label_style); let input_x = area.x + 8u16; diff --git a/src/ui/palette.rs b/src/ui/palette.rs index a11e283a..1abc6ed2 100644 --- a/src/ui/palette.rs +++ b/src/ui/palette.rs @@ -117,6 +117,9 @@ pub const WARNING: Color = Color::Indexed(178); /// Success / confirmation / positive. pub const SUCCESS: Color = Color::Indexed(49); +/// Form labels / structured input captions. +pub const FORM_LABEL: Color = SUCCESS_PEAK; + /// Work in progress / active processing. pub const PROCESSING: Color = Color::Indexed(171); diff --git a/src/ui/platforms.rs b/src/ui/platforms.rs index c155ceb3..abb42d12 100644 --- a/src/ui/platforms.rs +++ b/src/ui/platforms.rs @@ -303,7 +303,8 @@ impl PlatformsManager { } fn render_filter_row(area: Rect, buf: &mut Buffer, focused: bool, filter_state: &InputState) { - let label_style = if focused { Style::default().fg(palette::ACCENT) } else { Style::default().fg(palette::MUTED) }; + let label_style = + if focused { Style::default().fg(palette::FORM_LABEL).add_modifier(Modifier::BOLD) } else { Style::default().fg(palette::FORM_LABEL) }; buf.set_string(area.x + 2, area.y, "filter: ", label_style); let input_x = area.x + 10; diff --git a/src/ui/profiles.rs b/src/ui/profiles.rs index 7f3df316..c6381a33 100644 --- a/src/ui/profiles.rs +++ b/src/ui/profiles.rs @@ -783,7 +783,8 @@ impl ProfilesManager { // ── Helpers ── fn render_filter_row(area: Rect, buf: &mut Buffer, focused: bool, filter_state: &InputState) { - let label_style = if focused { Style::default().fg(palette::ACCENT) } else { Style::default().fg(palette::MUTED) }; + let label_style = + if focused { Style::default().fg(palette::FORM_LABEL).add_modifier(Modifier::BOLD) } else { Style::default().fg(palette::FORM_LABEL) }; buf.set_string(area.x + 2, area.y, "filter: ", label_style); let input_x = area.x + 10; diff --git a/src/ui/rawlogs.rs b/src/ui/rawlogs.rs index f36b8ca4..91f34982 100644 --- a/src/ui/rawlogs.rs +++ b/src/ui/rawlogs.rs @@ -269,7 +269,9 @@ impl SysInspectUX { } fn _render_logs_filter(area: Rect, buf: &mut Buffer, focused: bool, filter_state: &InputState) { - buf.set_string(area.x, area.y, "Filter: ", Style::default().fg(palette::FG)); + let label_style = + if focused { Style::default().fg(palette::FORM_LABEL).add_modifier(Modifier::BOLD) } else { Style::default().fg(palette::FORM_LABEL) }; + buf.set_string(area.x, area.y, "Filter: ", label_style); let input_x = area.x + 8u16; let input_w = area.width.saturating_sub(8); diff --git a/src/ui/repomanager.rs b/src/ui/repomanager.rs index 70257526..a98d3f26 100644 --- a/src/ui/repomanager.rs +++ b/src/ui/repomanager.rs @@ -875,7 +875,8 @@ impl RepoManager { } fn render_filter_row(area: Rect, buf: &mut Buffer, focused: bool, filter_state: &InputState) { - let label_style = if focused { Style::default().fg(palette::ACCENT) } else { Style::default().fg(palette::MUTED) }; + let label_style = + if focused { Style::default().fg(palette::FORM_LABEL).add_modifier(Modifier::BOLD) } else { Style::default().fg(palette::FORM_LABEL) }; buf.set_string(area.x + 2, area.y, "filter: ", label_style); let input_x = area.x + 10u16; diff --git a/src/ui/traitsview.rs b/src/ui/traitsview.rs index 2b8d894d..60d39d9a 100644 --- a/src/ui/traitsview.rs +++ b/src/ui/traitsview.rs @@ -165,7 +165,9 @@ impl SysInspectUX { } fn _render_info_filter(area: Rect, buf: &mut Buffer, focused: bool, filter_state: &InputState) { - buf.set_string(area.x, area.y, "Filter: ", Style::default().fg(palette::FG)); + let label_style = + if focused { Style::default().fg(palette::FORM_LABEL).add_modifier(Modifier::BOLD) } else { Style::default().fg(palette::FORM_LABEL) }; + buf.set_string(area.x, area.y, "Filter: ", label_style); let input_x = area.x + 8u16; let input_w = area.width.saturating_sub(8); From 3d128c8cc00b9b374b709b677fc52cca109a0fec Mon Sep 17 00:00:00 2001 From: Bo Maryniuk Date: Sun, 14 Jun 2026 20:36:14 +0200 Subject: [PATCH 06/22] Adjust palette, update titles --- scripts/show-ui-palette.sh | 2 +- src/ui/alert.rs | 13 ++++----- src/ui/filepicker.rs | 4 +-- src/ui/minreg.rs | 16 +++++++---- src/ui/mod.rs | 59 ++++++++++++++++++++++++++++++++++++-- src/ui/palette.rs | 2 +- src/ui/profiles.rs | 4 +-- src/ui/setup.rs | 4 +-- src/ui/wgt.rs | 2 +- 9 files changed, 81 insertions(+), 25 deletions(-) diff --git a/scripts/show-ui-palette.sh b/scripts/show-ui-palette.sh index e26f16eb..ab2b1c8e 100755 --- a/scripts/show-ui-palette.sh +++ b/scripts/show-ui-palette.sh @@ -52,7 +52,7 @@ show_group '-- Success' \ 'SUCCESS_BASE' 23 \ 'SUCCESS_GLOW' 29 \ 'SUCCESS_HEAT' 36 \ - 'SUCCESS_PEAK' 44 \ + 'SUCCESS_PEAK' 43 \ 'SUCCESS' 49 \ 'ACCENT' 36 diff --git a/src/ui/alert.rs b/src/ui/alert.rs index 27632bd2..37443596 100644 --- a/src/ui/alert.rs +++ b/src/ui/alert.rs @@ -105,27 +105,24 @@ impl SysInspectUX { ); } - pub fn dialog_info(&self, parent: Rect, buf: &mut Buffer, title: &str, text: &str, quit_button: bool) { - let max_w = ((parent.width * 3 / 4).max(50)) as usize; - let wrapped_lines = wrap_text(text, max_w); - let text = if wrapped_lines.is_empty() { "".to_string() } else { wrapped_lines.join("\n") }; + pub fn dialog_info(&self, parent: Rect, buf: &mut Buffer, title: &str, text: &str, styled_text: Option>, quit_button: bool) { Self::_popup_ex( parent, buf, Some(title), - &text, + text, None, Alignment::Left, AlertResult::Quit, if quit_button { AlertButtons::Quit } else { AlertButtons::Close }, Some(0), - Some(palette::SUCCESS_PEAK), + Some(palette::SUCCESS), None, None, Some(palette::BG_1), None, None, - None, + styled_text, Some((10.0, &[palette::GRAY_0, palette::BG_2] as &[Color])), ); } @@ -169,7 +166,7 @@ impl SysInspectUX { AlertResult::Close, AlertButtons::Close, Some(0), - Some(palette::SUCCESS_PEAK), + Some(palette::SUCCESS), None, None, Some(palette::BG_1), diff --git a/src/ui/filepicker.rs b/src/ui/filepicker.rs index 3f3020c9..d19e407f 100644 --- a/src/ui/filepicker.rs +++ b/src/ui/filepicker.rs @@ -526,7 +526,7 @@ impl FilePicker { buf, " Directories ", palette::PROCESSING, - palette::PROCESSING, + palette::PRIMARY, palette::PROCESSING_DIMMED, ); row_y += 1; @@ -550,7 +550,7 @@ impl FilePicker { buf, " Files ", palette::PROCESSING, - palette::PROCESSING, + palette::PRIMARY, palette::PROCESSING_DIMMED, ); row_y += 1; diff --git a/src/ui/minreg.rs b/src/ui/minreg.rs index 6536ff62..f07c7791 100644 --- a/src/ui/minreg.rs +++ b/src/ui/minreg.rs @@ -17,7 +17,7 @@ use ratatui::{ widgets::{Block, BorderType, Borders, Clear, StatefulWidget, Widget}, }; use ratatui_cheese::input::{Input, InputState}; -use ratatui_glamour::color::blend_2d; +use ratatui_glamour::color::{blend_2d, lerp_color}; use std::sync::{ Arc, Mutex, atomic::{AtomicBool, Ordering}, @@ -337,7 +337,7 @@ pub fn render_progress(progress: &RegistrationProgress, parent: Rect, buf: &mut } let has_error = progress.error.is_some(); let dlg_w = (parent.width * 3 / 4).clamp(52, 72); - let dlg_h = if has_error { 11u16 } else { 10u16 }; + let dlg_h = if has_error { 12u16 } else { 11u16 }; let x = parent.x + (parent.width.saturating_sub(dlg_w)) / 2; let y = parent.y + (parent.height.saturating_sub(dlg_h)) / 2; let canvas = Rect { x, y, width: dlg_w, height: dlg_h }; @@ -436,7 +436,12 @@ pub fn render_progress(progress: &RegistrationProgress, parent: Rect, buf: &mut let filled = (bar_w as usize * progress.step).checked_div(progress.total.max(1)).unwrap_or(0) as u16; if filled > 0 { - buf.set_string(inner.x + 2, bar_y, "█".repeat(filled as usize), Style::default().fg(palette::PROCESSING_PEAK)); + let bar_x = inner.x + 2; + for i in 0..filled { + let t = if bar_w > 1 { i as f32 / (bar_w - 1) as f32 } else { 0.0 }; + let color = lerp_color(palette::PROCESSING_DIMMED, palette::SUCCESS, t); + buf.set_string(bar_x + i, bar_y, "█", Style::default().fg(color)); + } } if filled < bar_w { let unfilled = (bar_w - filled) as usize; @@ -444,11 +449,11 @@ pub fn render_progress(progress: &RegistrationProgress, parent: Rect, buf: &mut } let pct_text = format!("{pct}%"); let pct_x = inner.x + (inner.width.saturating_sub(pct_text.len() as u16)) / 2; - buf.set_string(pct_x, bar_y, &pct_text, Style::default().fg(palette::FG).add_modifier(Modifier::BOLD)); + buf.set_string(pct_x, bar_y + 1, &pct_text, Style::default().fg(palette::FG).add_modifier(Modifier::BOLD)); let cancel = SysInspectUX::format_button("Cancel"); let btn_x = inner.x + (inner.width.saturating_sub(cancel.len() as u16)) / 2; - buf.set_string(btn_x, bar_y + 2, &cancel, Style::default().fg(palette::FG).bg(palette::BG_2).add_modifier(Modifier::BOLD)); + buf.set_string(btn_x, bar_y + 3, &cancel, Style::default().fg(palette::FG).bg(palette::BG_2).add_modifier(Modifier::BOLD)); } draw_shadow(buf, canvas, dlg_w, dlg_h); @@ -511,6 +516,7 @@ pub fn spawn_registration( p.error = Some(err.to_string()); } } + std::thread::sleep(std::time::Duration::from_millis(250)); p.done = true; }) } diff --git a/src/ui/mod.rs b/src/ui/mod.rs index c74eb66d..331d6d9a 100644 --- a/src/ui/mod.rs +++ b/src/ui/mod.rs @@ -27,7 +27,7 @@ use ratatui::{ DefaultTerminal, Frame, layout::{Constraint, Direction, Layout, Rect}, style::{Modifier, Style}, - text::{Line, Span}, + text::{Line, Span, Text}, widgets::{Paragraph, Row}, }; use ratatui_cheese::tree::TreeState; @@ -148,6 +148,8 @@ pub struct SysInspectUX { /// Information alert (success/info popups) pub info_alert_visible: bool, pub info_alert_message: String, + pub info_alert_title: String, + pub info_alert_styled: Option>, /// Exit alert pub exit_alert_visible: bool, @@ -246,6 +248,7 @@ pub struct SysInspectUX { pub delete_progress: DeleteProgressState, pub delete_task: Option>>, pub delete_success_message: String, + pub delete_success_styled: Option>, // Exit-after-popup state (for setup config-written notice) pub pending_exit: bool, @@ -286,6 +289,8 @@ impl Default for SysInspectUX { error_alert_message: String::new(), info_alert_visible: false, info_alert_message: String::new(), + info_alert_title: String::new(), + info_alert_styled: None, help_popup_visible: false, minions_visible: false, @@ -358,6 +363,7 @@ impl Default for SysInspectUX { delete_progress: DeleteProgressState::default(), delete_task: None, delete_success_message: String::new(), + delete_success_styled: None, pending_exit: false, pending_exit_message: None, @@ -443,6 +449,8 @@ impl SysInspectUX { // Not yet up — stay in setup loop, will auto-reconnect self.info_alert_visible = true; + self.info_alert_title = "Setup Complete".to_string(); + self.info_alert_styled = None; self.info_alert_message = format!( "Config written to:\n{}\n\nMaster is starting in the background.\nThe UI will reconnect automatically.", config_path.display(), @@ -706,6 +714,8 @@ impl SysInspectUX { match result { Ok(Ok(mid)) => { self.info_alert_visible = true; + self.info_alert_title = "Minion Removal".to_string(); + self.info_alert_styled = self.delete_success_styled.take(); self.info_alert_message = if self.delete_success_message.is_empty() { Self::format_machine_message("Minion deleted", None, None, Some(&mid)) } else { @@ -766,10 +776,13 @@ impl SysInspectUX { }; let platform = (!p.platform.trim().is_empty()).then_some(p.platform.trim()); let msg = Self::format_machine_message("Minion registered", host_value, platform, p.minion_id.as_deref()); + let styled = Self::format_machine_message_styled("Minion registered", host_value, platform, p.minion_id.as_deref()); drop(p); *self.registration_progress.lock().unwrap() = minreg::RegistrationProgress::placeholder(); self.restore_status(); self.info_alert_visible = true; + self.info_alert_title = "Minion Registration".to_string(); + self.info_alert_styled = Some(styled); self.info_alert_message = msg; } } @@ -1111,10 +1124,25 @@ impl SysInspectUX { self.status_at_minions_browser(); } KeyCode::Up => { - self.minions_menu_sel = self.minions_menu_sel.saturating_sub(1); + let len = Self::minions_menu_len(); + if len > 0 { + self.minions_menu_sel = if self.minions_menu_sel == 0 { len - 1 } else { self.minions_menu_sel - 1 }; + } } KeyCode::Down => { - self.minions_menu_sel = (self.minions_menu_sel + 1).min(Self::minions_menu_len().saturating_sub(1)); + let len = Self::minions_menu_len(); + if len > 0 { + self.minions_menu_sel = if self.minions_menu_sel >= len - 1 { 0 } else { self.minions_menu_sel + 1 }; + } + } + KeyCode::PageUp => { + self.minions_menu_sel = self.minions_menu_sel.saturating_sub(3); + } + KeyCode::PageDown => { + let len = Self::minions_menu_len(); + if len > 0 { + self.minions_menu_sel = (self.minions_menu_sel + 3).min(len - 1); + } } KeyCode::Enter => { self.minions_menu_visible = false; @@ -1700,6 +1728,7 @@ impl SysInspectUX { None }; self.delete_success_message = Self::format_machine_message("Minion deleted", host.as_deref(), platform.as_deref(), Some(&mid)); + self.delete_success_styled = Some(Self::format_machine_message_styled("Minion deleted", host.as_deref(), platform.as_deref(), Some(&mid))); let force_ctx = if force { Some(serde_json::json!({"force": true}).to_string()) } else { None }; self.delete_progress.visible = true; self.delete_progress.message = @@ -4017,6 +4046,30 @@ impl SysInspectUX { rows.into_iter().map(|(label, value)| format!("{label:>().join("\n") } + fn format_machine_message_styled(action: &str, primary_value: Option<&str>, platform: Option<&str>, minion_id: Option<&str>) -> Text<'static> { + let mut rows = Vec::new(); + if let Some(value) = primary_value.filter(|v| !v.trim().is_empty()) { + rows.push((format!("{action}:"), value.trim().to_string())); + } else { + rows.push((format!("{action}:"), "OK".to_string())); + } + if let Some(value) = platform.filter(|v| !v.trim().is_empty()) { + rows.push(("Platform:".to_string(), value.trim().to_string())); + } + if let Some(value) = minion_id.filter(|v| !v.trim().is_empty()) { + rows.push(("Machine ID:".to_string(), Self::short_machine_id(value))); + } + let width = rows.iter().map(|(label, _)| label.len()).max().unwrap_or(0); + let mut lines: Vec> = vec![Line::from("")]; + lines.extend(rows.into_iter().map(|(label, value)| { + Line::from(vec![ + Span::styled(format!("{label: String { let trimmed = mid.trim(); if trimmed.chars().count() <= 8 { trimmed.to_string() } else { format!("{}...", trimmed.chars().take(8).collect::()) } diff --git a/src/ui/palette.rs b/src/ui/palette.rs index 1abc6ed2..a2019c2c 100644 --- a/src/ui/palette.rs +++ b/src/ui/palette.rs @@ -63,7 +63,7 @@ pub const WARNING_RAMP: [Color; 4] = [WARNING_BASE, WARNING_GLOW, WARNING_HEAT, pub const SUCCESS_BASE: Color = Color::Indexed(23); pub const SUCCESS_GLOW: Color = Color::Indexed(29); pub const SUCCESS_HEAT: Color = Color::Indexed(36); -pub const SUCCESS_PEAK: Color = Color::Indexed(44); +pub const SUCCESS_PEAK: Color = Color::Indexed(43); pub const SUCCESS_RAMP: [Color; 4] = [SUCCESS_BASE, SUCCESS_GLOW, SUCCESS_HEAT, SUCCESS_PEAK]; diff --git a/src/ui/profiles.rs b/src/ui/profiles.rs index c6381a33..f3e33e7d 100644 --- a/src/ui/profiles.rs +++ b/src/ui/profiles.rs @@ -507,7 +507,7 @@ impl ProfilesManager { buf, " Modules ", palette::PROCESSING, - palette::PROCESSING_PEAK, + palette::PRIMARY, palette::PROCESSING_DIMMED, ); row_y += 1; @@ -525,7 +525,7 @@ impl ProfilesManager { buf, " Libraries ", palette::PROCESSING, - palette::PROCESSING_PEAK, + palette::PRIMARY, palette::PROCESSING_DIMMED, ); row_y += 1; diff --git a/src/ui/setup.rs b/src/ui/setup.rs index 265fd64d..dfed4adf 100644 --- a/src/ui/setup.rs +++ b/src/ui/setup.rs @@ -317,7 +317,7 @@ impl MasterSetupWizard { buf, " Installation ", palette::PROCESSING, - palette::PROCESSING, + palette::PRIMARY, palette::PROCESSING_DIMMED, ); row_y += 1; @@ -372,7 +372,7 @@ impl MasterSetupWizard { buf, " Configuration ", palette::PROCESSING, - palette::PROCESSING, + palette::PRIMARY, palette::PROCESSING_DIMMED, ); row_y += 1; diff --git a/src/ui/wgt.rs b/src/ui/wgt.rs index 942f9bb1..8f451862 100644 --- a/src/ui/wgt.rs +++ b/src/ui/wgt.rs @@ -321,7 +321,7 @@ impl Widget for &SysInspectUX { self.dialog_dsl_browser(area, buf); self.dialog_error(area, buf); if self.info_alert_visible { - self.dialog_info(area, buf, "Setup Complete", &self.info_alert_message, true); + self.dialog_info(area, buf, &self.info_alert_title, &self.info_alert_message, self.info_alert_styled.clone(), true); } } } From 3ac4edb745c3ae00c9d8928c873823249ef3abde Mon Sep 17 00:00:00 2001 From: Bo Maryniuk Date: Sun, 14 Jun 2026 20:49:04 +0200 Subject: [PATCH 07/22] Update registration progress bar --- src/ui/minreg.rs | 22 ++++++++++++---------- 1 file changed, 12 insertions(+), 10 deletions(-) diff --git a/src/ui/minreg.rs b/src/ui/minreg.rs index f07c7791..70495c3d 100644 --- a/src/ui/minreg.rs +++ b/src/ui/minreg.rs @@ -439,7 +439,7 @@ pub fn render_progress(progress: &RegistrationProgress, parent: Rect, buf: &mut let bar_x = inner.x + 2; for i in 0..filled { let t = if bar_w > 1 { i as f32 / (bar_w - 1) as f32 } else { 0.0 }; - let color = lerp_color(palette::PROCESSING_DIMMED, palette::SUCCESS, t); + let color = lerp_color(palette::PROCESSING_DIMMED, palette::PRIMARY, t); buf.set_string(bar_x + i, bar_y, "█", Style::default().fg(color)); } } @@ -506,18 +506,20 @@ pub fn spawn_registration( ) -> tokio::task::JoinHandle<()> { tokio::task::spawn_blocking(move || { let result = run_provision(hostname, user, path, use_sudo, &cfg, &progress); - let mut p = progress.lock().unwrap(); - match result { - Ok(mid) => { - p.minion_id = mid; - p.message = "Complete".into(); - } - Err(err) => { - p.error = Some(err.to_string()); + { + let mut p = progress.lock().unwrap(); + match result { + Ok(mid) => { + p.minion_id = mid; + p.message = "Complete".into(); + } + Err(err) => { + p.error = Some(err.to_string()); + } } } std::thread::sleep(std::time::Duration::from_millis(250)); - p.done = true; + progress.lock().unwrap().done = true; }) } From 718d3542e3c5ecdc06459fa0b80c34510431297c Mon Sep 17 00:00:00 2001 From: Bo Maryniuk Date: Sun, 14 Jun 2026 20:59:44 +0200 Subject: [PATCH 08/22] Add mouse support for buttons --- src/ui/alert.rs | 60 ++++++++++++++++---------- src/ui/mod.rs | 109 +++++++++++++++++++++++++++++++++++++++++++++--- src/ui/wgt.rs | 1 + 3 files changed, 143 insertions(+), 27 deletions(-) diff --git a/src/ui/alert.rs b/src/ui/alert.rs index 37443596..7f8b71bc 100644 --- a/src/ui/alert.rs +++ b/src/ui/alert.rs @@ -67,6 +67,12 @@ enum AlertButtons { Close, } +#[derive(Copy, Clone, Debug)] +pub(crate) struct PopupButtonRects { + pub left_button: Option, + pub right_button: Rect, +} + static YES_LABEL: &str = "Yes"; static NO_LABEL: &str = "No"; static OK_LABEL: &str = "OK"; @@ -84,7 +90,7 @@ impl SysInspectUX { let max_w = ((parent.width * 3 / 4).max(50)) as usize; let wrapped_lines = wrap_text(&self.error_alert_message, max_w); let text = if wrapped_lines.is_empty() { "".to_string() } else { wrapped_lines.join("\n") }; - Self::_popup_ex( + let rects = Self::_popup_ex( parent, buf, Some("Error"), @@ -103,10 +109,11 @@ impl SysInspectUX { None, Some((15.0, &[palette::BG_2, palette::BG_1] as &[Color])), ); + self.popup_button_rects.set(Some(rects)); } pub fn dialog_info(&self, parent: Rect, buf: &mut Buffer, title: &str, text: &str, styled_text: Option>, quit_button: bool) { - Self::_popup_ex( + let rects = Self::_popup_ex( parent, buf, Some(title), @@ -125,13 +132,14 @@ impl SysInspectUX { styled_text, Some((10.0, &[palette::GRAY_0, palette::BG_2] as &[Color])), ); + self.popup_button_rects.set(Some(rects)); } pub fn dialog_purge(&self, parent: Rect, buf: &mut Buffer) { if !self.purge_alert_visible { return; } - Self::_popup_ex( + let rects = Self::_popup_ex( parent, buf, Some("Delete everything?"), @@ -150,13 +158,14 @@ impl SysInspectUX { None, Some((10.0, &[palette::GRAY_0, palette::BG_2] as &[Color])), ); + self.popup_button_rects.set(Some(rects)); } pub fn dialog_help(&self, parent: Rect, buf: &mut Buffer) { if !self.help_popup_visible { return; } - Self::_popup_ex( + let rects = Self::_popup_ex( parent, buf, Some("Help"), @@ -175,6 +184,7 @@ impl SysInspectUX { None, None, ); + self.popup_button_rects.set(Some(rects)); } pub fn dialog_exit(&self, parent: Rect, buf: &mut Buffer) { @@ -182,7 +192,7 @@ impl SysInspectUX { return; } - Self::_popup_ex( + let rects = Self::_popup_ex( parent, buf, None, @@ -201,13 +211,14 @@ impl SysInspectUX { None, Some((10.0, &[palette::GRAY_0, palette::BG_2] as &[Color])), ); + self.popup_button_rects.set(Some(rects)); } pub fn dialog_cluster_confirm(&self, parent: Rect, buf: &mut Buffer) { if !self.cluster_confirm_visible { return; } - match self.pending_cluster_action { + let rects = match self.pending_cluster_action { 1 => Self::_popup_ex( parent, buf, @@ -267,8 +278,9 @@ impl SysInspectUX { Some((10.0, &[palette::GRAY_0, palette::BG_2] as &[Color])), ) } - _ => {} - } + _ => return, + }; + self.popup_button_rects.set(Some(rects)); } pub fn dialog_delete_progress(&self, parent: Rect, buf: &mut Buffer) { @@ -319,7 +331,7 @@ impl SysInspectUX { 3 => "Stop the master?\n\nThis will terminate the daemon process.", _ => return, }; - Self::_popup_ex( + let rects = Self::_popup_ex( parent, buf, Some("Master Operation"), @@ -338,6 +350,7 @@ impl SysInspectUX { None, Some((10.0, &[palette::GRAY_0, palette::BG_2] as &[Color])), ); + self.popup_button_rects.set(Some(rects)); } /// Draws a button in MS-DOS style (no shadow) @@ -377,7 +390,7 @@ impl SysInspectUX { buttons: AlertButtons, width: Option, border_color: Option, border_type: Option, text_color: Option, title_color: Option, left_label: Option<&str>, right_label: Option<&str>, styled_text: Option>, gradient: Option<(f32, &[Color])>, - ) { + ) -> PopupButtonRects { let background = background.unwrap_or(palette::POPUP_BG_BASE); let border_color = border_color.unwrap_or(palette::BORDER); let border_type = border_type.unwrap_or(ratatui::widgets::BorderType::Rounded); @@ -458,16 +471,15 @@ impl SysInspectUX { let b_selected = Style::default().fg(palette::WHITE).bg(palette::PROCESSING_HEAT).add_modifier(Modifier::BOLD); let b_unselected = Style::default().fg(palette::FG).bg(palette::BG_2).add_modifier(Modifier::BOLD); - if rbtn_label.is_empty() { - Paragraph::new(lbtn_label.clone()).style(b_selected).render( - Rect { - x: button_area.x + (btn_w.saturating_sub(lbtn_label.len() as u16)) / 2, - y: button_area.y, - width: lbtn_label.len() as u16, - height: 1, - }, - buf, - ); + let popup_rects = if rbtn_label.is_empty() { + let rect = Rect { + x: button_area.x + (btn_w.saturating_sub(lbtn_label.len() as u16)) / 2, + y: button_area.y, + width: lbtn_label.len() as u16, + height: 1, + }; + Paragraph::new(lbtn_label.clone()).style(b_selected).render(rect, buf); + PopupButtonRects { left_button: None, right_button: rect } } else { let button_splits = Layout::default() .direction(Direction::Horizontal) @@ -483,7 +495,8 @@ impl SysInspectUX { Paragraph::new(lbtn_label).style(left_style).render(button_splits[1], buf); Paragraph::new(rbtn_label).style(right_style).render(button_splits[3], buf); - } + PopupButtonRects { left_button: Some(button_splits[1]), right_button: button_splits[3] } + }; // MS-DOS style shadows let buf_area = buf.area(); @@ -515,13 +528,15 @@ impl SysInspectUX { } } } + + popup_rects } fn _popup_widgets( parent: Rect, buf: &mut Buffer, title: Option<&str>, text: Text<'_>, widgets: &[DialogFormWidget], focus: Option, text_align: Alignment, widget_align: DialogFormAlignment, border_color: Option, title_color: Option, gradient: Option<(f32, &[Color])>, - ) { + ) -> PopupButtonRects { let background = palette::POPUP_BG_BASE; let border_color = border_color.unwrap_or(palette::BORDER); let title_color = title_color.unwrap_or(palette::BLACK); @@ -605,6 +620,7 @@ impl SysInspectUX { Paragraph::new(rbtn_label).style(right_style).render(button_splits[3], buf); Self::draw_popup_shadow(buf, canvas, height); + PopupButtonRects { left_button: Some(button_splits[1]), right_button: button_splits[3] } } /// Draws a popup area diff --git a/src/ui/mod.rs b/src/ui/mod.rs index 331d6d9a..1cf7c8c3 100644 --- a/src/ui/mod.rs +++ b/src/ui/mod.rs @@ -1,5 +1,8 @@ use crate::{call_master_console, ui::elements::DbListItem}; -use crossterm::event::{self, Event, KeyCode, KeyEventKind, KeyModifiers}; +use crossterm::{ + event::{self, Event, KeyCode, KeyEventKind, KeyModifiers, MouseButton, MouseEvent, MouseEventKind}, + execute, +}; use elements::{ActiveBox, AlertResult, CycleListItem, EventListItem, MinionListItem}; use indexmap::IndexMap; use libcommon::SysinspectError; @@ -66,7 +69,9 @@ use alert::DialogFormFocus; pub async fn run(cfg: MasterConfig, config_found: bool) -> io::Result<()> { let mut terminal = ratatui::init(); + let _ = execute!(io::stdout(), crossterm::event::EnableMouseCapture); let result = tokio_run(cfg, config_found, &mut terminal).await; + let _ = execute!(io::stdout(), crossterm::event::DisableMouseCapture); ratatui::restore(); result @@ -211,6 +216,7 @@ pub struct SysInspectUX { pub pending_cluster_action: u8, // 0=none, 1=shutdown all, 2=reconnect all, 3=delete minion pub delete_force_remove: bool, // checkbox: also remove from host over SSH cluster_confirm_form_focus: DialogFormFocus, + pub popup_button_rects: Cell>, // Tag popup pub tag_visible: bool, @@ -341,6 +347,7 @@ impl Default for SysInspectUX { pending_cluster_action: 0, delete_force_remove: false, cluster_confirm_form_focus: DialogFormFocus::LeftButton, + popup_button_rects: Cell::new(None), tag_visible: false, tag_key_buf: String::new(), @@ -668,10 +675,14 @@ impl SysInspectUX { Duration::from_secs(1) }; if event::poll(poll_dur)? { - if let Event::Key(e) = event::read()? - && e.kind == KeyEventKind::Press - { - self.on_key(e); + match event::read()? { + Event::Key(e) if e.kind == KeyEventKind::Press => { + self.on_key(e); + } + Event::Mouse(me) if me.kind == MouseEventKind::Down(MouseButton::Left) => { + self.on_mouse(me); + } + _ => {} } } else { if !self.offline { @@ -3420,6 +3431,94 @@ impl SysInspectUX { } } + fn on_mouse(&mut self, me: MouseEvent) { + let rects = match self.popup_button_rects.get() { + Some(r) => r, + None => return, + }; + let (cx, cy) = (me.column, me.row); + let hit = |r: Rect| cx >= r.x && cx < r.x.saturating_add(r.width) && cy == r.y; + let on_left = rects.left_button.is_some_and(hit); + let on_right = hit(rects.right_button); + + if self.error_alert_visible { + self.error_alert_visible = false; + return; + } + if self.info_alert_visible { + self.info_alert_visible = false; + return; + } + if self.help_popup_visible { + self.help_popup_visible = false; + return; + } + if self.exit_alert_visible { + if on_left { + self.exit_alert_choice = AlertResult::Quit; + } else if on_right { + self.exit_alert_choice = AlertResult::Default; + } else { + return; + } + self.exit_alert_visible = false; + if self.exit_alert_choice == AlertResult::Quit { + self.exit = true; + } + return; + } + if self.purge_alert_visible { + if !on_left && !on_right { + return; + } + if on_left { + self.purge_alert_choice = AlertResult::Purge; + let _ = self.purge_database(); + } + self.purge_alert_visible = false; + self.status_text = Line::from(Span::styled("", Style::default().fg(palette::FG))); + return; + } + if self.cluster_confirm_visible { + if self.pending_cluster_action == 3 { + if on_left { + let force = self.delete_force_remove; + self.close_cluster_confirm(); + self.do_minion_delete(force); + } else if on_right { + self.close_cluster_confirm(); + } + } else if on_left { + self.cluster_confirm_choice = AlertResult::ClusterConfirm; + self.cluster_confirm_visible = false; + match self.pending_cluster_action { + 1 => self.do_cluster_shutdown(), + 2 => self.do_cluster_reconnect(), + _ => {} + } + self.pending_cluster_action = 0; + self.status_at_minions_browser(); + } else if on_right { + self.close_cluster_confirm(); + } + return; + } + if self.master_confirm_visible { + if on_left { + self.master_confirm_choice = AlertResult::Quit; + self.master_confirm_visible = false; + match self.master_confirm_action { + 1 => self.do_master_start(), + 2 => self.do_master_restart(), + 3 => self.do_master_stop(), + _ => {} + } + } else if on_right { + self.master_confirm_visible = false; + } + } + } + fn on_key(&mut self, e: event::KeyEvent) { // Error alert is modal — always checked first if self.on_error_alert(e) { diff --git a/src/ui/wgt.rs b/src/ui/wgt.rs index 8f451862..c96de6d1 100644 --- a/src/ui/wgt.rs +++ b/src/ui/wgt.rs @@ -309,6 +309,7 @@ impl Widget for &SysInspectUX { self.minion_traits(area, buf); self.dialog_minion_logs(area, buf); self.dialog_master_logs(area, buf); + self.popup_button_rects.set(None); self.dialog_trait_tag(area, buf); self.dialog_cluster_confirm(area, buf); self.dialog_delete_progress(area, buf); From 52f14bad7dab340d457a0d1d90ea88ce96ac187c Mon Sep 17 00:00:00 2001 From: Bo Maryniuk Date: Sun, 14 Jun 2026 21:02:20 +0200 Subject: [PATCH 09/22] Add mouse-follow focus for buttons --- src/ui/mod.rs | 62 +++++++++++++++++++++++++++++++++++++++++++++++---- src/ui/wgt.rs | 2 +- 2 files changed, 59 insertions(+), 5 deletions(-) diff --git a/src/ui/mod.rs b/src/ui/mod.rs index 1cf7c8c3..2da58634 100644 --- a/src/ui/mod.rs +++ b/src/ui/mod.rs @@ -679,9 +679,11 @@ impl SysInspectUX { Event::Key(e) if e.kind == KeyEventKind::Press => { self.on_key(e); } - Event::Mouse(me) if me.kind == MouseEventKind::Down(MouseButton::Left) => { - self.on_mouse(me); - } + Event::Mouse(me) => match me.kind { + MouseEventKind::Down(MouseButton::Left) => self.on_mouse_click(me), + MouseEventKind::Moved => self.on_mouse_move(me), + _ => {} + }, _ => {} } } else { @@ -3431,7 +3433,59 @@ impl SysInspectUX { } } - fn on_mouse(&mut self, me: MouseEvent) { + fn on_mouse_move(&mut self, me: MouseEvent) { + let rects = match self.popup_button_rects.get() { + Some(r) => r, + None => return, + }; + let (cx, cy) = (me.column, me.row); + let hit = |r: Rect| cx >= r.x && cx < r.x.saturating_add(r.width) && cy == r.y; + let on_left = rects.left_button.is_some_and(hit); + let on_right = hit(rects.right_button); + + if self.error_alert_visible || self.info_alert_visible || self.help_popup_visible { + return; + } + if self.exit_alert_visible { + if on_left { + self.exit_alert_choice = AlertResult::Quit; + } else if on_right { + self.exit_alert_choice = AlertResult::Default; + } + return; + } + if self.purge_alert_visible { + if on_left { + self.purge_alert_choice = AlertResult::Purge; + } else if on_right { + self.purge_alert_choice = AlertResult::Default; + } + return; + } + if self.cluster_confirm_visible { + if self.pending_cluster_action == 3 { + if on_left { + self.cluster_confirm_form_focus = DialogFormFocus::LeftButton; + } else if on_right { + self.cluster_confirm_form_focus = DialogFormFocus::RightButton; + } + } else if on_left { + self.cluster_confirm_choice = AlertResult::ClusterConfirm; + } else if on_right { + self.cluster_confirm_choice = AlertResult::Default; + } + return; + } + if self.master_confirm_visible { + if on_left { + self.master_confirm_choice = AlertResult::Quit; + } else if on_right { + self.master_confirm_choice = AlertResult::Default; + } + } + } + + fn on_mouse_click(&mut self, me: MouseEvent) { let rects = match self.popup_button_rects.get() { Some(r) => r, None => return, diff --git a/src/ui/wgt.rs b/src/ui/wgt.rs index c96de6d1..c9417580 100644 --- a/src/ui/wgt.rs +++ b/src/ui/wgt.rs @@ -273,6 +273,7 @@ impl Widget for &SysInspectUX { { // Fill main background Block::default().style(Style::default().bg(palette::BG_1)).render(area, buf); + self.popup_button_rects.set(None); let cycles_max = self.cycles_buf.iter().map(|c| c.get_list_line(false).width()).max().unwrap_or(10); let minions_max = self.li_minions.iter().map(|m| m.get_list_line(false).width()).max().unwrap_or(8); @@ -309,7 +310,6 @@ impl Widget for &SysInspectUX { self.minion_traits(area, buf); self.dialog_minion_logs(area, buf); self.dialog_master_logs(area, buf); - self.popup_button_rects.set(None); self.dialog_trait_tag(area, buf); self.dialog_cluster_confirm(area, buf); self.dialog_delete_progress(area, buf); From 5270ec4d0eaaa6b775b222e7f0f49871ab7d3e09 Mon Sep 17 00:00:00 2001 From: Bo Maryniuk Date: Sun, 14 Jun 2026 21:05:48 +0200 Subject: [PATCH 10/22] Lintfix --- src/ui/mod.rs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/ui/mod.rs b/src/ui/mod.rs index 2da58634..27045514 100644 --- a/src/ui/mod.rs +++ b/src/ui/mod.rs @@ -216,7 +216,7 @@ pub struct SysInspectUX { pub pending_cluster_action: u8, // 0=none, 1=shutdown all, 2=reconnect all, 3=delete minion pub delete_force_remove: bool, // checkbox: also remove from host over SSH cluster_confirm_form_focus: DialogFormFocus, - pub popup_button_rects: Cell>, + pub(crate) popup_button_rects: Cell>, // Tag popup pub tag_visible: bool, From 9c584be9c3010aceabda0c5ed9caf9825cb97ee5 Mon Sep 17 00:00:00 2001 From: Bo Maryniuk Date: Sun, 14 Jun 2026 21:21:20 +0200 Subject: [PATCH 11/22] Fix master menu --- src/ui/dslbrowser.rs | 27 ++++++++++++++++++++------- src/ui/macts.rs | 2 +- src/ui/mod.rs | 2 +- 3 files changed, 22 insertions(+), 9 deletions(-) diff --git a/src/ui/dslbrowser.rs b/src/ui/dslbrowser.rs index ef01ea0f..356d8089 100644 --- a/src/ui/dslbrowser.rs +++ b/src/ui/dslbrowser.rs @@ -299,6 +299,9 @@ impl DslBrowser { fn s_bl() -> Style { Style::default().fg(palette::PROCESSING).bg(palette::POPUP_BG_BASE) } + fn s_fl() -> Style { + Style::default().fg(palette::FORM_LABEL) + } fn border_style(focus: DslFocus, current: DslFocus) -> Style { if current == focus { Style::default().fg(palette::ACCENT) } else { Style::default().fg(palette::FAINT) } @@ -380,7 +383,7 @@ impl DslBrowser { ]) .split(area); - write_clipped(buf, chunks[0], chunks[0].x, chunks[0].y, "Query: ", Self::s_bl()); + write_clipped(buf, chunks[0], chunks[0].x, chunks[0].y, "Query: ", Self::s_fl()); let qf = self.focus == DslFocus::Query; let mut qs = InputState::new(); qs.set_value(self.query.clone()); @@ -392,10 +395,10 @@ impl DslBrowser { let inp = Input::new("").prompt("").placeholder("*"); StatefulWidget::render(&inp, Rect::new(chunks[0].x + 7, chunks[0].y, chunks[0].width.saturating_sub(7), 1), buf, &mut qs); - write_clipped(buf, chunks[1], chunks[1].x, chunks[1].y, " Models:", Self::s_bl()); - write_clipped(buf, chunks[2], chunks[2].x, chunks[2].y, " Target:", Self::s_bl()); - write_clipped(buf, chunks[3], chunks[3].x, chunks[3].y, " State:", Self::s_bl()); - write_clipped(buf, chunks[4], chunks[4].x, chunks[4].y, " Context:", Self::s_bl()); + write_clipped(buf, chunks[1], chunks[1].x, chunks[1].y, " Models:", Self::s_fl()); + write_clipped(buf, chunks[2], chunks[2].x, chunks[2].y, " Target:", Self::s_fl()); + write_clipped(buf, chunks[3], chunks[3].x, chunks[3].y, " State:", Self::s_fl()); + write_clipped(buf, chunks[4], chunks[4].x, chunks[4].y, " Context:", Self::s_fl()); } fn render_lists(&self, area: Rect, box_w: u16, ctx_w: u16, buf: &mut Buffer) { @@ -493,7 +496,7 @@ impl DslBrowser { let focused = matches!(self.focus, DslFocus::ContextField(idx) if idx == i); let req = if field.required { "*" } else { " " }; let label = format!("{req}{:>width$}: ", field.key, width = max_label_w as usize); - write_clipped(buf, area, area.x + 1, y, &label, Self::s_bd()); + write_clipped(buf, area, area.x + 1, y, &label, Self::s_fl()); let inp = Input::new("").prompt("").placeholder(if field.desc.is_empty() { &field.key } else { &field.desc }); let mut is = InputState::new(); is.set_value(field.value.clone()); @@ -741,6 +744,16 @@ impl SysInspectUX { Clear.render(popup, buf); + let grad_colors = blend_2d(popup.width as usize, popup.height as usize, 10.0, &[palette::GRAY_0, palette::BG_2] as &[ratatui::style::Color]); + for row in 0..popup.height { + for col in 0..popup.width { + let idx = row as usize * popup.width as usize + col as usize; + if let Some(cell) = buf.cell_mut(Position::new(popup.x + col, popup.y + row)) { + cell.set_bg(grad_colors[idx]); + } + } + } + let model_name = self.dsl_browser.models.items.get(self.dsl_browser.models.selected().unwrap_or(0)).map(|s| s.as_str()).unwrap_or(""); let target_id = self.dsl_browser.targets.items.get(self.dsl_browser.targets.selected().unwrap_or(0)).map(|s| s.as_str()).unwrap_or(""); let state_display = self.dsl_browser.states.items.get(self.dsl_browser.states.selected().unwrap_or(0)).map(|s| s.as_str()).unwrap_or(""); @@ -785,7 +798,7 @@ impl SysInspectUX { .border_type(BorderType::Rounded) .border_style(Style::default().fg(border_color)) .padding(Padding::horizontal(2)) - .style(Style::default().bg(palette::POPUP_BG_BASE)); + .style(Style::default()); let inner = block.inner(popup); block.render(popup, buf); let title_style = TitleStyle::cyberpunk(border_color); diff --git a/src/ui/macts.rs b/src/ui/macts.rs index b9344668..fd932b37 100644 --- a/src/ui/macts.rs +++ b/src/ui/macts.rs @@ -32,7 +32,7 @@ const MENU_SECTIONS: &[MenuSection] = &[ const MASTER_MENU_SECTIONS: &[MenuSection] = &[ MenuSection { title: "Operations", - items: &[("View master logs online", "^O"), ("View local logs", "^L"), ("Register a minion", "^R"), ("Repository manager", "^G")], + items: &[("View master logs online", "^O"), ("View local logs", "^L"), ("Register a minion", "^R"), ("Artefacts Manager", "^G")], }, MenuSection { title: "System", items: &[("Start", "^T"), ("Stop", "^S"), ("Restart", "^E")] }, ]; diff --git a/src/ui/mod.rs b/src/ui/mod.rs index 27045514..7472954c 100644 --- a/src/ui/mod.rs +++ b/src/ui/mod.rs @@ -240,7 +240,7 @@ pub struct SysInspectUX { // File picker pub file_picker: filepicker::FilePicker, - // Repository manager + // Artefacts manager pub repo_manager: repomanager::RepoManager, // Connection state From 257bc34daaa7e3a54e39e28e610076e2b94db738 Mon Sep 17 00:00:00 2001 From: Bo Maryniuk Date: Sun, 14 Jun 2026 21:22:59 +0200 Subject: [PATCH 12/22] Remove background on descr text in query composer --- src/ui/dslbrowser.rs | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/ui/dslbrowser.rs b/src/ui/dslbrowser.rs index 356d8089..92a3ae01 100644 --- a/src/ui/dslbrowser.rs +++ b/src/ui/dslbrowser.rs @@ -282,13 +282,13 @@ impl DslBrowser { } fn s_fg() -> Style { - Style::default().fg(palette::FG).bg(palette::POPUP_BG_BASE) + Style::default().fg(palette::FG) } fn s_bd() -> Style { Self::s_fg().add_modifier(Modifier::BOLD) } fn s_di() -> Style { - Style::default().fg(palette::MUTED).bg(palette::POPUP_BG_BASE) + Style::default().fg(palette::MUTED) } fn s_hl() -> Style { Style::default().fg(palette::BLACK).bg(palette::HIGHLIGHT) From 7fbab37c1ad2fd15ad63659ebb59358e39814140 Mon Sep 17 00:00:00 2001 From: Bo Maryniuk Date: Sun, 14 Jun 2026 21:47:38 +0200 Subject: [PATCH 13/22] Add models tab --- src/ui/macts.rs | 2 +- src/ui/mod.rs | 101 ++++++++++++++++-------- src/ui/repomanager.rs | 175 +++++++++++++++++++++++++++++++++++++++++- 3 files changed, 244 insertions(+), 34 deletions(-) diff --git a/src/ui/macts.rs b/src/ui/macts.rs index fd932b37..9e8d55cc 100644 --- a/src/ui/macts.rs +++ b/src/ui/macts.rs @@ -32,7 +32,7 @@ const MENU_SECTIONS: &[MenuSection] = &[ const MASTER_MENU_SECTIONS: &[MenuSection] = &[ MenuSection { title: "Operations", - items: &[("View master logs online", "^O"), ("View local logs", "^L"), ("Register a minion", "^R"), ("Artefacts Manager", "^G")], + items: &[("View master logs online", "^O"), ("View local logs", "^L"), ("Register a minion", "^R"), ("Artefacts Manager", "^A")], }, MenuSection { title: "System", items: &[("Start", "^T"), ("Stop", "^S"), ("Restart", "^E")] }, ]; diff --git a/src/ui/mod.rs b/src/ui/mod.rs index 7472954c..2ec79c1e 100644 --- a/src/ui/mod.rs +++ b/src/ui/mod.rs @@ -753,7 +753,7 @@ impl SysInspectUX { match self.repo_manager.active_tab { 0 => self.process_module_add(&path), 1 => self.process_library_add(&path), - 3 => self.process_platform_add(&path), + 4 => self.process_platform_add(&path), _ => {} } } @@ -765,7 +765,7 @@ impl SysInspectUX { if self.repo_manager.needs_reload { self.repo_manager.needs_reload = false; let _ = self.load_module_index(); - if self.repo_manager.active_tab == 3 { + if self.repo_manager.active_tab == 4 { let _ = self.load_platforms(); } } @@ -2095,8 +2095,8 @@ impl SysInspectUX { } return true; } - // Profile-specific overlays (tab 2) - if self.repo_manager.active_tab == 2 { + // Profile-specific overlays (tab 3) + if self.repo_manager.active_tab == 3 { if self.repo_manager.profiles.delete_visible { let handled = self.repo_manager.profiles.handle_delete_key(e.code); if !handled && e.code == KeyCode::Enter { @@ -2153,8 +2153,8 @@ impl SysInspectUX { return true; } } - // Platform delete overlay (tab 3) - if self.repo_manager.active_tab == 3 && self.repo_manager.platforms.delete_visible { + // Platform delete overlay (tab 4) + if self.repo_manager.active_tab == 4 && self.repo_manager.platforms.delete_visible { let handled = self.repo_manager.platforms.handle_delete_key(e.code); if !handled && e.code == KeyCode::Enter { if self.repo_manager.platforms.delete_focus == platforms::DeleteFocus::YesBtn { @@ -2165,10 +2165,12 @@ impl SysInspectUX { } return true; } - let total_count = if self.repo_manager.active_tab == 3 { + let total_count = if self.repo_manager.active_tab == 4 { self.repo_manager.platforms.filtered_count(self.repo_manager.filter.value()) - } else if self.repo_manager.active_tab == 2 { + } else if self.repo_manager.active_tab == 3 { self.repo_manager.profiles.filtered_count(self.repo_manager.filter.value()) + } else if self.repo_manager.active_tab == 2 { + self.repo_filtered_model_count() } else if self.repo_manager.active_tab == 1 { self.repo_filtered_lib_count() } else if self.repo_manager.active_tab == 0 { @@ -2182,10 +2184,12 @@ impl SysInspectUX { self.repo_filtered_count() }; let max_cursor = total_count.saturating_sub(1); - let cursor_ref: &mut usize = if self.repo_manager.active_tab == 3 { + let cursor_ref: &mut usize = if self.repo_manager.active_tab == 4 { &mut self.repo_manager.platforms.cursor - } else if self.repo_manager.active_tab == 2 { + } else if self.repo_manager.active_tab == 3 { &mut self.repo_manager.profiles.cursor + } else if self.repo_manager.active_tab == 2 { + &mut self.repo_manager.model_cursor } else if self.repo_manager.active_tab == 1 { &mut self.repo_manager.lib_cursor } else { @@ -2203,42 +2207,50 @@ impl SysInspectUX { self.repo_manager.group_cursor = 0; self.repo_manager.group_cursor_row = 0; self.repo_manager.lib_cursor = 0; + self.repo_manager.model_cursor = 0; self.repo_manager.profiles.cursor = 0; self.repo_manager.platforms.cursor = 0; if self.repo_manager.active_tab == 1 { let _ = self.load_library_index(); } if self.repo_manager.active_tab == 2 { - let _ = self.load_profile_list(); + let _ = self.load_model_list(); } if self.repo_manager.active_tab == 3 { + let _ = self.load_profile_list(); + } + if self.repo_manager.active_tab == 4 { let _ = self.load_platforms(); } } KeyCode::Right => { - self.repo_manager.active_tab = (self.repo_manager.active_tab + 1).min(3); + self.repo_manager.active_tab = (self.repo_manager.active_tab + 1).min(4); self.repo_manager.group_cursor = 0; self.repo_manager.group_cursor_row = 0; self.repo_manager.lib_cursor = 0; + self.repo_manager.model_cursor = 0; self.repo_manager.profiles.cursor = 0; self.repo_manager.platforms.cursor = 0; if self.repo_manager.active_tab == 1 { let _ = self.load_library_index(); } if self.repo_manager.active_tab == 2 { - let _ = self.load_profile_list(); + let _ = self.load_model_list(); } if self.repo_manager.active_tab == 3 { + let _ = self.load_profile_list(); + } + if self.repo_manager.active_tab == 4 { let _ = self.load_platforms(); } } KeyCode::Up => { if self.repo_manager.active_tab == 0 { self.move_module_up(); - } else if self.repo_manager.active_tab == 2 { + } else if self.repo_manager.active_tab == 3 { let fv = self.repo_manager.filter.value().to_string(); self.repo_manager.profiles.handle_list_key(e.code, &mut self.repo_manager.filter_focus, &fv); - } else if self.repo_manager.active_tab == 3 { + } else if self.repo_manager.active_tab == 4 { self.repo_manager.platforms.handle_list_key(e.code); } else { *cursor_ref = cursor_ref.saturating_sub(1); @@ -2247,10 +2259,10 @@ impl SysInspectUX { KeyCode::Down => { if self.repo_manager.active_tab == 0 { self.move_module_down(); - } else if self.repo_manager.active_tab == 2 { + } else if self.repo_manager.active_tab == 3 { let fv = self.repo_manager.filter.value().to_string(); self.repo_manager.profiles.handle_list_key(e.code, &mut self.repo_manager.filter_focus, &fv); - } else if self.repo_manager.active_tab == 3 { + } else if self.repo_manager.active_tab == 4 { self.repo_manager.platforms.handle_list_key(e.code); } else { *cursor_ref = (*cursor_ref + 1).min(max_cursor); @@ -2263,10 +2275,10 @@ impl SysInspectUX { self.repo_manager.group_cursor = (self.repo_manager.group_cursor + n - 1) % n; self.repo_manager.group_cursor_row = 0; } - } else if self.repo_manager.active_tab == 2 { + } else if self.repo_manager.active_tab == 3 { let fv = self.repo_manager.filter.value().to_string(); self.repo_manager.profiles.handle_list_key(e.code, &mut self.repo_manager.filter_focus, &fv); - } else if self.repo_manager.active_tab == 3 { + } else if self.repo_manager.active_tab == 4 { self.repo_manager.platforms.handle_list_key(e.code); } else { *cursor_ref = cursor_ref.saturating_sub(page); @@ -2279,19 +2291,19 @@ impl SysInspectUX { self.repo_manager.group_cursor = (self.repo_manager.group_cursor + 1) % n; self.repo_manager.group_cursor_row = 0; } - } else if self.repo_manager.active_tab == 2 { + } else if self.repo_manager.active_tab == 3 { let fv = self.repo_manager.filter.value().to_string(); self.repo_manager.profiles.handle_list_key(e.code, &mut self.repo_manager.filter_focus, &fv); - } else if self.repo_manager.active_tab == 3 { + } else if self.repo_manager.active_tab == 4 { self.repo_manager.platforms.handle_list_key(e.code); } else { *cursor_ref = (*cursor_ref + page).min(max_cursor); } } KeyCode::Enter => { - if self.repo_manager.active_tab == 3 { + if self.repo_manager.active_tab == 4 { // Platforms have no detail view - } else if self.repo_manager.active_tab == 2 { + } else if self.repo_manager.active_tab == 3 { let name = match self.repo_manager.profiles.selected_profile_name() { Some(n) => n.to_string(), None => return true, @@ -2306,6 +2318,13 @@ impl SysInspectUX { self.error_alert_message = e; } } + } else if self.repo_manager.active_tab == 2 && !self.repo_manager.model_rows.is_empty() { + self.repo_manager.info_visible = true; + self.repo_manager.info_row = self.repo_manager.model_cursor; + self.repo_manager.info_tab = 0; + self.repo_manager.info_scroll.set(0); + self.repo_manager.info_active_tab = 2; + self.status_at_repo_manager(); } else if self.repo_manager.active_tab == 0 { if self.repo_manager.group_cursor_row == 0 { // Toggle expand/collapse on header @@ -2331,15 +2350,18 @@ impl SysInspectUX { } } KeyCode::Delete => { - if self.repo_manager.active_tab == 3 { + if self.repo_manager.active_tab == 4 { if let Some(name) = self.repo_manager.platforms.selected_name() { self.repo_manager.platforms.open_delete(name); } - } else if self.repo_manager.active_tab == 2 { + } else if self.repo_manager.active_tab == 3 { if let Some(name) = self.repo_manager.profiles.selected_profile_name() { self.repo_manager.profiles.open_delete(name.to_string()); self.status_at_profiles(); } + } else if self.repo_manager.active_tab == 2 { + self.error_alert_visible = true; + self.error_alert_message = "Model deletion is not supported yet".to_string(); } else if self.repo_manager.active_tab == 1 && !self.repo_manager.lib_rows.is_empty() { self.repo_manager.delete_mode = true; self.repo_manager.staged = self @@ -2388,18 +2410,21 @@ impl SysInspectUX { } } KeyCode::Insert | KeyCode::Char('i') if !e.modifiers.contains(KeyModifiers::CONTROL) => { - if self.repo_manager.active_tab == 3 { + if self.repo_manager.active_tab == 4 { self.file_picker.open(&std::env::current_dir().unwrap_or_default(), filepicker::PickerMode::MinionBuild); - } else if self.repo_manager.active_tab == 2 { + } else if self.repo_manager.active_tab == 3 { self.repo_manager.profiles.open_create(); self.status_at_profiles(); + } else if self.repo_manager.active_tab == 2 { + self.error_alert_visible = true; + self.error_alert_message = "Model addition is not supported yet".to_string(); } else { let mode = if self.repo_manager.active_tab == 1 { filepicker::PickerMode::LibrarySelector } else { filepicker::PickerMode::Any }; self.file_picker.open(&std::env::current_dir().unwrap_or_default(), mode); } } KeyCode::Char('l') if !e.modifiers.contains(KeyModifiers::CONTROL) => { - if self.repo_manager.active_tab == 2 { + if self.repo_manager.active_tab == 3 { self.repo_manager.profiles.open_create(); self.status_at_profiles(); } else { @@ -2474,6 +2499,11 @@ impl SysInspectUX { self.repo_manager.lib_rows.iter().filter(|r| f.is_empty() || r.name.to_lowercase().contains(&f) || r.kind.to_lowercase().contains(&f)).count() } + fn repo_filtered_model_count(&self) -> usize { + let f = self.repo_manager.filter.value().to_lowercase(); + self.repo_manager.model_rows.iter().filter(|r| f.is_empty() || r.name.to_lowercase().contains(&f) || r.id.to_lowercase().contains(&f)).count() + } + fn call_profile_rpc(&self, context: &str) -> Result { let ctx = context.to_string(); let resp = tokio::task::block_in_place(|| { @@ -2645,6 +2675,17 @@ impl SysInspectUX { } } + fn load_model_list(&mut self) -> Result<(), String> { + match self.get_models() { + Ok((rows, _failures)) => { + self.repo_manager.model_rows = rows; + self.repo_manager.model_cursor = 0; + Ok(()) + } + Err(e) => Err(format!("Failed to load models: {e}")), + } + } + fn process_module_add(&mut self, path: &std::path::Path) { if path.is_dir() { let mut staged = Self::scan_dir_for_modules(path); @@ -3086,7 +3127,7 @@ impl SysInspectUX { self.master_menu_visible = false; self.registration_form.visible = true; } - KeyCode::Char('g') if e.modifiers.contains(KeyModifiers::CONTROL) => { + KeyCode::Char('a') if e.modifiers.contains(KeyModifiers::CONTROL) => { self.master_menu_visible = false; if let Err(err) = self.load_module_index() { self.error_alert_visible = true; @@ -3919,7 +3960,7 @@ impl SysInspectUX { KeyCode::Char('r') if e.modifiers.contains(KeyModifiers::CONTROL) => { self.registration_form.visible = true; } - KeyCode::Char('g') if e.modifiers.contains(KeyModifiers::CONTROL) => { + KeyCode::Char('a') if e.modifiers.contains(KeyModifiers::CONTROL) => { if let Err(err) = self.load_module_index() { self.error_alert_visible = true; self.error_alert_message = err; diff --git a/src/ui/repomanager.rs b/src/ui/repomanager.rs index a98d3f26..ee0cd0ee 100644 --- a/src/ui/repomanager.rs +++ b/src/ui/repomanager.rs @@ -92,6 +92,11 @@ pub struct RepoManager { pub lib_cursor: usize, pub lib_scroll: Cell, + // Models + pub model_rows: Vec, + pub model_cursor: usize, + pub model_scroll: Cell, + // Profiles pub profiles: profiles::ProfilesManager, @@ -132,6 +137,9 @@ impl Default for RepoManager { lib_rows: Vec::new(), lib_cursor: 0, lib_scroll: Cell::new(0), + model_rows: Vec::new(), + model_cursor: 0, + model_scroll: Cell::new(0), profiles: profiles::ProfilesManager::default(), platforms: platforms::PlatformsManager::default(), } @@ -361,7 +369,7 @@ impl RepoManager { let inner = block.inner(canvas); block.render(canvas, buf); - let tab_names = ["Modules", "Libraries", "Profiles", "Platforms"]; + let tab_names = ["Modules", "Libraries", "Models", "Profiles", "Platforms"]; let section_name = tab_names[self.active_tab as usize]; let title_style = TitleStyle::cyberpunk(palette::PROCESSING_GLOW); @@ -404,8 +412,9 @@ impl RepoManager { match self.active_tab { 0 => self.render_modules(body, buf), 1 => self.render_libraries(body, buf), - 2 => self.profiles.render_list(body, buf, self.filter_focus, &self.filter), - 3 => self.platforms.render_list(body, buf, self.filter_focus, &self.filter), + 2 => self.render_models(body, buf), + 3 => self.profiles.render_list(body, buf, self.filter_focus, &self.filter), + 4 => self.platforms.render_list(body, buf, self.filter_focus, &self.filter), _ => {} } Self::draw_shadow(buf, canvas, dlg_w, dlg_h); @@ -874,6 +883,94 @@ impl RepoManager { } } + fn render_models(&self, inner: Rect, buf: &mut Buffer) { + if inner.height < 2 { + return; + } + let [filter_area, list_area] = Layout::default() + .direction(Direction::Vertical) + .constraints([Constraint::Length(1), Constraint::Min(0)]) + .split(inner) + .as_ref() + .try_into() + .unwrap(); + Self::render_filter_row(filter_area, buf, self.filter_focus, &self.filter); + if self.model_rows.is_empty() { + let msg = "(no models found)"; + let x = list_area.x + (list_area.width.saturating_sub(msg.len() as u16)) / 2; + let y = list_area.y + list_area.height / 2; + buf.set_string(x, y, msg, Style::default().fg(palette::MUTED)); + return; + } + let flt = self.filter.value().to_lowercase(); + let filtered: Vec<(usize, &libsysinspect::console::ConsoleModelRow)> = self + .model_rows + .iter() + .enumerate() + .filter(|(_, r)| flt.is_empty() || r.name.to_lowercase().contains(&flt) || r.id.to_lowercase().contains(&flt)) + .collect(); + let view_h = list_area.height as usize; + let total = filtered.len(); + let max_scroll = total.saturating_sub(view_h); + let mut s = self.model_scroll.get(); + let cursor = self.model_cursor.min(total.saturating_sub(1)); + if cursor < s { + s = cursor; + } + if cursor >= s + view_h { + s = cursor.saturating_sub(view_h.saturating_sub(1)); + } + s = s.min(max_scroll); + self.model_scroll.set(s); + if total == 0 { + let msg = "(no matches)"; + let x = list_area.x + (list_area.width.saturating_sub(msg.len() as u16)) / 2; + let y = list_area.y + list_area.height / 2; + buf.set_string(x, y, msg, Style::default().fg(palette::MUTED)); + return; + } + let hl = Style::default().fg(palette::BLACK).bg(palette::HIGHLIGHT); + let name_w = list_area.width.saturating_sub(32); + let ver_w: u16 = 14; + for i in 0..view_h.min(total.saturating_sub(s)) { + let fi = s + i; + let (_oi, row) = filtered[fi]; + let ry = list_area.y + i as u16; + let sel = !self.filter_focus && fi == cursor; + let row_style = if sel { hl } else { Style::default().fg(palette::FG) }; + if sel { + for cx in 0..list_area.width { + if let Some(cell) = buf.cell_mut(Position::new(list_area.x + cx, ry)) { + cell.set_bg(palette::HIGHLIGHT); + } + } + } + let name_style = if sel { row_style } else { Style::default().fg(palette::PROCESSING) }; + buf.set_string(list_area.x + 1, ry, format!(" {}", truncate_str(&row.name, name_w as usize)), name_style); + let ver_style = if sel { row_style } else { Style::default().fg(palette::PROCESSING_HEAT) }; + let ver_x = list_area.x + 1 + name_w + 1; + buf.set_string(ver_x, ry, truncate_str(&row.version, ver_w as usize), ver_style); + let descr_style = if sel { row_style } else { Style::default().fg(palette::GRAY_1) }; + let descr_x = ver_x + 1 + ver_w; + let descr_w = list_area.width.saturating_sub(1 + name_w + 1 + ver_w + 1); + let descr = format!(" {}", row.description.lines().next().unwrap_or("")); + buf.set_string(descr_x, ry, truncate_str(&descr, descr_w as usize), descr_style); + } + if total > view_h { + let bh = ((view_h as f64 / total as f64) * view_h as f64).max(1.0) as usize; + let by = ((s as f64 / total as f64) * (view_h - bh) as f64) as usize; + for i in 0..view_h { + let sx = list_area.right().saturating_sub(1); + let sy = list_area.y + i as u16; + if i >= by && i < by + bh { + buf.set_string(sx, sy, "█", Style::default().fg(palette::PROCESSING_HEAT)); + } else { + buf.set_string(sx, sy, "│", Style::default().fg(palette::MUTED)); + } + } + } + } + fn render_filter_row(area: Rect, buf: &mut Buffer, focused: bool, filter_state: &InputState) { let label_style = if focused { Style::default().fg(palette::FORM_LABEL).add_modifier(Modifier::BOLD) } else { Style::default().fg(palette::FORM_LABEL) }; @@ -948,6 +1045,7 @@ impl RepoManager { match self.info_active_tab { 0 => self.render_module_info(parent, buf), 1 => self.render_library_info(parent, buf), + 2 => self.render_model_info(parent, buf), _ => {} } } @@ -1142,6 +1240,77 @@ impl RepoManager { Self::draw_shadow(buf, canvas, w, h); } + fn render_model_info(&self, parent: Rect, buf: &mut Buffer) { + let model = match self.model_rows.get(self.info_row) { + Some(r) => r, + None => return, + }; + let w = (parent.width * 60 / 100).max(50).min(parent.width.saturating_sub(2)); + let h: u16 = 10; + let x = parent.x + (parent.width.saturating_sub(w)) / 2; + let y = parent.y + (parent.height.saturating_sub(h)) / 2; + let canvas = Rect { x, y, width: w, height: h }; + + Clear.render(canvas, buf); + let grad = blend_2d(canvas.width as usize, canvas.height as usize, 10.0, &[palette::BG_1, palette::BG_2] as &[Color]); + for ry in 0..canvas.height { + for cx in 0..canvas.width { + let idx = ry as usize * canvas.width as usize + cx as usize; + if let Some(cell) = buf.cell_mut(Position::new(canvas.x + cx, canvas.y + ry)) { + cell.set_bg(grad[idx]); + } + } + } + + let block = Block::default() + .borders(Borders::ALL) + .border_type(BorderType::Rounded) + .border_style(Style::default().fg(palette::PROCESSING_GLOW)) + .style(Style::default()); + let inner = block.inner(canvas); + block.render(canvas, buf); + + let title_style = TitleStyle::cyberpunk(palette::PROCESSING_GLOW); + title::overlay_gradient_title( + buf, + canvas, + &title_style, + &[TitleSegment { text: format!(" {} ", model.name), bg: palette::PROCESSING_BASE, fg: palette::FG, modifier: Modifier::empty() }], + ); + + let key_style = Style::default().fg(palette::PROCESSING).add_modifier(Modifier::BOLD); + let val_style = Style::default().fg(palette::FG); + + let descr = model.description.lines().next().unwrap_or(""); + let lines: [(&str, &str); 5] = [ + ("Id: ", &model.id), + ("Version: ", &model.version), + ("Entrypts:", &model.entrypoints.join(", ")), + ("States: ", &model.states.join(", ")), + ("Descr: ", descr), + ]; + for (i, (label, value)) in lines.iter().enumerate() { + let ry = inner.y + 1 + i as u16; + buf.set_string(inner.x + 3, ry, label, key_style); + buf.set_string( + inner.x + 3 + label.len() as u16, + ry, + truncate_str(value, (inner.width as usize).saturating_sub(3 + label.len() + 2)), + val_style, + ); + } + + let close_lbl = "[ Close ]"; + let close_w = close_lbl.len() as u16; + let btn_x = inner.x + (inner.width.saturating_sub(close_w)) / 2; + let btn_y = inner.y + 7; + Paragraph::new(close_lbl) + .style(Style::default().fg(palette::WHITE).bg(palette::PROCESSING_HEAT).add_modifier(Modifier::BOLD)) + .render(Rect::new(btn_x, btn_y, close_w, 1), buf); + + Self::draw_shadow(buf, canvas, w, h); + } + fn render_info_text(&self, area: Rect, buf: &mut Buffer, text: &str) { let w = (area.width.saturating_sub(3)) as usize; let lines: Vec = text.split('\n').flat_map(|l| dslbrowser::wrap_text(l, w)).collect(); From a58432cc6e8c93423b81fad4de40ffbea600a72b Mon Sep 17 00:00:00 2001 From: Bo Maryniuk Date: Sun, 14 Jun 2026 22:08:39 +0200 Subject: [PATCH 14/22] Implement config drop-ins --- libsysinspect/src/cfg/mmconf.rs | 18 +++- libsysinspect/src/cfg/mod.rs | 31 ++++++ libsysinspect/src/intp/functions.rs | 70 ++++++++++++++ libsysinspect/tests/dropins_integration.rs | 104 +++++++++++++++++++++ 4 files changed, 220 insertions(+), 3 deletions(-) create mode 100644 libsysinspect/tests/dropins_integration.rs diff --git a/libsysinspect/src/cfg/mmconf.rs b/libsysinspect/src/cfg/mmconf.rs index 4d1150da..28121291 100644 --- a/libsysinspect/src/cfg/mmconf.rs +++ b/libsysinspect/src/cfg/mmconf.rs @@ -1,4 +1,8 @@ -use crate::{cfg::APP_CONF, intp::functions::get_by_namespace, util}; +use crate::{ + cfg::APP_CONF, + intp::functions::{deep_merge, get_by_namespace}, + util, +}; use indexmap::IndexMap; use libcommon::SysinspectError; use nix::libc; @@ -575,7 +579,11 @@ impl MinionConfig { return Err(SysinspectError::ConfigError(format!("File not found: {cp}"))); } - if let Some(cfgv) = get_by_namespace(Some(from_str::(&fs::read_to_string(&p)?)?), "config.minion") { + let mut root_val = from_str::(&fs::read_to_string(&p)?)?; + for dv in crate::cfg::load_dropins(&crate::cfg::dropins_dir(&p)) { + deep_merge(&mut root_val, &dv); + } + if let Some(cfgv) = get_by_namespace(Some(root_val), "config.minion") { return Ok(from_value::(cfgv)?); } @@ -1233,7 +1241,11 @@ impl MasterConfig { return Err(SysinspectError::ConfigError(format!("File not found: {cp}"))); } - if let Some(cfgv) = get_by_namespace(Some(from_str::(&fs::read_to_string(&p)?)?), "config.master") { + let mut root_val = from_str::(&fs::read_to_string(&p)?)?; + for dv in crate::cfg::load_dropins(&crate::cfg::dropins_dir(&p)) { + deep_merge(&mut root_val, &dv); + } + if let Some(cfgv) = get_by_namespace(Some(root_val), "config.master") { return Ok(from_value::(cfgv)?); } diff --git a/libsysinspect/src/cfg/mod.rs b/libsysinspect/src/cfg/mod.rs index d1072a9f..73ec0310 100644 --- a/libsysinspect/src/cfg/mod.rs +++ b/libsysinspect/src/cfg/mod.rs @@ -9,6 +9,7 @@ mod mmconf_ut; use libcommon::SysinspectError; use mmconf::MinionConfig; use nix::unistd::Uid; +use serde_yaml::Value; use std::{env, path::PathBuf}; pub const APP_CONF: &str = "sysinspect.conf"; @@ -73,3 +74,33 @@ pub fn get_minion_config(p: Option<&str>) -> Result PathBuf { + let fname = config_path.file_stem().unwrap_or_default(); + let mut dot_d = fname.to_os_string(); + dot_d.push(".d"); + config_path.with_file_name(dot_d) +} + +/// Load and parse YAML drop-in files from a directory, sorted by filename. +pub(crate) fn load_dropins(dir: &std::path::Path) -> Vec { + let mut values = Vec::new(); + let Ok(rd) = std::fs::read_dir(dir) else { + return values; + }; + let mut files: Vec = rd + .filter_map(|e| e.ok()) + .map(|e| e.path()) + .filter(|p| p.is_file() && p.extension().and_then(|e| e.to_str()).map(|e| e == "yml" || e == "yaml").unwrap_or(false)) + .collect(); + files.sort(); + for f in &files { + if let Ok(s) = std::fs::read_to_string(f) + && let Ok(v) = serde_yaml::from_str::(&s) + { + values.push(v); + } + } + values +} diff --git a/libsysinspect/src/intp/functions.rs b/libsysinspect/src/intp/functions.rs index 02bbead5..0ccdb858 100644 --- a/libsysinspect/src/intp/functions.rs +++ b/libsysinspect/src/intp/functions.rs @@ -151,3 +151,73 @@ impl ModArgFunction { &self.fid } } + +/// Deep-merge two serde_yaml or serde_json `Value` trees. +/// +/// Scalars are replaced, mappings are recursively merged, +/// and sequences are appended. +pub(crate) fn deep_merge(base: &mut serde_yaml::Value, overlay: &serde_yaml::Value) { + use serde_yaml::Value; + + match (base, overlay) { + (Value::Mapping(b), Value::Mapping(o)) => { + for (k, v) in o { + match b.get_mut(k) { + Some(existing) => deep_merge(existing, v), + None => { + b.insert(k.clone(), v.clone()); + } + } + } + } + (Value::Sequence(b), Value::Sequence(o)) => { + b.extend(o.iter().cloned()); + } + (b, o) => { + *b = o.clone(); + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn scalar_replace() { + let mut base = serde_yaml::from_str::("port: 4200").unwrap(); + let overlay = serde_yaml::from_str::("port: 9999").unwrap(); + deep_merge(&mut base, &overlay); + assert_eq!(base["port"].as_u64().unwrap(), 9999); + } + + #[test] + fn map_deep_merge() { + let mut base = serde_yaml::from_str::("a: {x: 1, y: 2}").unwrap(); + let overlay = serde_yaml::from_str::("a: {y: 99, z: 3}").unwrap(); + deep_merge(&mut base, &overlay); + assert_eq!(base["a"]["x"].as_u64().unwrap(), 1); + assert_eq!(base["a"]["y"].as_u64().unwrap(), 99); + assert_eq!(base["a"]["z"].as_u64().unwrap(), 3); + } + + #[test] + fn sequence_append() { + let mut base = serde_yaml::from_str::("items: [a, b]").unwrap(); + let overlay = serde_yaml::from_str::("items: [c, d]").unwrap(); + deep_merge(&mut base, &overlay); + let seq = base["items"].as_sequence().unwrap(); + assert_eq!(seq.len(), 4); + assert_eq!(seq[0].as_str().unwrap(), "a"); + assert_eq!(seq[2].as_str().unwrap(), "c"); + } + + #[test] + fn new_key_added() { + let mut base = serde_yaml::from_str::("a: 1").unwrap(); + let overlay = serde_yaml::from_str::("b: 2").unwrap(); + deep_merge(&mut base, &overlay); + assert_eq!(base["a"].as_u64().unwrap(), 1); + assert_eq!(base["b"].as_u64().unwrap(), 2); + } +} diff --git a/libsysinspect/tests/dropins_integration.rs b/libsysinspect/tests/dropins_integration.rs new file mode 100644 index 00000000..4c10f56d --- /dev/null +++ b/libsysinspect/tests/dropins_integration.rs @@ -0,0 +1,104 @@ +use libsysinspect::cfg::mmconf::{MasterConfig, MinionConfig}; +use std::fs; +use tempfile::Builder; + +fn write_config(dir: &std::path::Path, contents: &str) { + fs::write(dir.join("sysinspect.conf"), contents).unwrap(); +} + +fn write_dropin(dir: &std::path::Path, name: &str, contents: &str) { + let dd = dir.join("sysinspect.d"); + fs::create_dir_all(&dd).unwrap(); + fs::write(dd.join(name), contents).unwrap(); +} + +#[test] +fn no_dropins_dir_parses_cleanly() { + let tmp = Builder::new().prefix("dropins-int-").tempdir().unwrap(); + write_config(tmp.path(), "config:\n master:\n fileserver.models: [foo]\n bind.ip: 0.0.0.0\n bind.port: 4200\n"); + let cfg = MasterConfig::new(tmp.path().join("sysinspect.conf")).unwrap(); + assert_eq!(cfg.fileserver_models().as_slice(), &["foo"]); + assert_eq!(cfg.bind_addr(), "0.0.0.0:4200"); +} + +#[test] +fn scalar_override_replaces_value() { + let tmp = Builder::new().prefix("dropins-int-").tempdir().unwrap(); + write_config(tmp.path(), "config:\n master:\n bind.ip: 0.0.0.0\n bind.port: 4200\n fileserver.models: []\n"); + write_dropin(tmp.path(), "override.yml", "config:\n master:\n bind.port: 9999\n"); + let cfg = MasterConfig::new(tmp.path().join("sysinspect.conf")).unwrap(); + assert_eq!(cfg.bind_addr(), "0.0.0.0:9999"); +} + +#[test] +fn nested_map_merge_adds_key() { + let tmp = Builder::new().prefix("dropins-int-").tempdir().unwrap(); + write_config( + tmp.path(), + "config:\n master:\n fileserver.models: []\n telemetry.location: /default/path\n telemetry.socket: /default/sock\n", + ); + write_dropin(tmp.path(), "override.yml", "config:\n master:\n telemetry.location: /overridden/path\n"); + let cfg = MasterConfig::new(tmp.path().join("sysinspect.conf")).unwrap(); + assert_eq!(cfg.telemetry_location(), std::path::PathBuf::from("/overridden/path")); + assert_eq!(cfg.telemetry_socket(), std::path::PathBuf::from("/default/sock")); +} + +#[test] +fn sequence_append_extends_list() { + let tmp = Builder::new().prefix("dropins-int-").tempdir().unwrap(); + write_config(tmp.path(), "config:\n master:\n fileserver.models: [a]\n"); + write_dropin(tmp.path(), "01-models.yml", "config:\n master:\n fileserver.models: [b]\n"); + write_dropin(tmp.path(), "02-models.yml", "config:\n master:\n fileserver.models: [c]\n"); + let cfg = MasterConfig::new(tmp.path().join("sysinspect.conf")).unwrap(); + assert_eq!(cfg.fileserver_models().as_slice(), &["a", "b", "c"]); +} + +#[test] +fn ordering_later_file_wins_conflict() { + let tmp = Builder::new().prefix("dropins-int-").tempdir().unwrap(); + write_config(tmp.path(), "config:\n master:\n bind.ip: 0.0.0.0\n bind.port: 4200\n fileserver.models: []\n"); + write_dropin(tmp.path(), "01-a.yml", "config:\n master:\n bind.port: 1111\n"); + write_dropin(tmp.path(), "02-b.yml", "config:\n master:\n bind.port: 2222\n"); + let cfg = MasterConfig::new(tmp.path().join("sysinspect.conf")).unwrap(); + assert_eq!(cfg.bind_addr(), "0.0.0.0:2222"); +} + +#[test] +fn non_yaml_files_ignored() { + let tmp = Builder::new().prefix("dropins-int-").tempdir().unwrap(); + write_config(tmp.path(), "config:\n master:\n fileserver.models: [only-this]\n"); + let dd = tmp.path().join("sysinspect.d"); + fs::create_dir_all(&dd).unwrap(); + fs::write(dd.join("notes.txt"), "garbage").unwrap(); + fs::write(dd.join("data.json"), r#"{"x": 1}"#).unwrap(); + let cfg = MasterConfig::new(tmp.path().join("sysinspect.conf")).unwrap(); + assert_eq!(cfg.fileserver_models().as_slice(), &["only-this"]); +} + +#[test] +fn empty_dropins_dir_no_effect() { + let tmp = Builder::new().prefix("dropins-int-").tempdir().unwrap(); + write_config(tmp.path(), "config:\n master:\n fileserver.models: [x]\n"); + fs::create_dir_all(tmp.path().join("sysinspect.d")).unwrap(); + let cfg = MasterConfig::new(tmp.path().join("sysinspect.conf")).unwrap(); + assert_eq!(cfg.fileserver_models().as_slice(), &["x"]); +} + +#[test] +fn new_key_added_by_dropin() { + let tmp = Builder::new().prefix("dropins-int-").tempdir().unwrap(); + write_config(tmp.path(), "config:\n master:\n fileserver.models: []\n"); + write_dropin(tmp.path(), "extra.yml", "config:\n master:\n api.enabled: true\n api.bind.port: 8080\n"); + let cfg = MasterConfig::new(tmp.path().join("sysinspect.conf")).unwrap(); + assert!(cfg.api_enabled()); + assert_eq!(cfg.api_bind_port(), 8080); +} + +#[test] +fn minion_config_dropins_work() { + let tmp = Builder::new().prefix("dropins-int-").tempdir().unwrap(); + write_config(tmp.path(), "config:\n minion:\n master.ip: 10.0.0.1\n master.port: 4200\n performance: embedded\n"); + write_dropin(tmp.path(), "override.yml", "config:\n minion:\n master.port: 9999\n"); + let cfg = MinionConfig::new(tmp.path().join("sysinspect.conf")).unwrap(); + assert_eq!(cfg.master(), "10.0.0.1:9999"); +} From 3bdb55627e3f50afc66e5a88f2aff5bdc21ba69e Mon Sep 17 00:00:00 2001 From: Bo Maryniuk Date: Mon, 15 Jun 2026 00:01:01 +0200 Subject: [PATCH 15/22] Add model management --- libsysinspect/src/cfg/mmconf.rs | 6 + libsysinspect/src/cfg/mod.rs | 2 +- libsysinspect/src/console/mod.rs | 7 + libsysinspect/src/mdescr/catalog.rs | 8 +- src/ui/dslbrowser.rs | 1 + src/ui/filepicker.rs | 11 +- src/ui/mod.rs | 160 +++++++++++++++++++++- src/ui/repomanager.rs | 203 ++++++++++++++++++++++++---- sysmaster/src/console.rs | 7 +- 9 files changed, 368 insertions(+), 37 deletions(-) diff --git a/libsysinspect/src/cfg/mmconf.rs b/libsysinspect/src/cfg/mmconf.rs index 28121291..fa20180d 100644 --- a/libsysinspect/src/cfg/mmconf.rs +++ b/libsysinspect/src/cfg/mmconf.rs @@ -1426,6 +1426,12 @@ impl MasterConfig { self.root_dir().join(CFG_FILESERVER_ROOT) } + /// Path to the master configuration file for this layout. + pub fn config_path(&self) -> PathBuf { + let root = self.root_dir(); + if root == *DEFAULT_SYSINSPECT_ROOT { root.join(APP_CONF) } else { root.join(DEFAULT_MINION_CFG_DIR).join(APP_CONF) } + } + /// Get models root on the fileserver pub fn fileserver_models_root(&self, uri_only: bool) -> PathBuf { if uri_only { diff --git a/libsysinspect/src/cfg/mod.rs b/libsysinspect/src/cfg/mod.rs index 73ec0310..3234ebaa 100644 --- a/libsysinspect/src/cfg/mod.rs +++ b/libsysinspect/src/cfg/mod.rs @@ -92,7 +92,7 @@ pub(crate) fn load_dropins(dir: &std::path::Path) -> Vec { let mut files: Vec = rd .filter_map(|e| e.ok()) .map(|e| e.path()) - .filter(|p| p.is_file() && p.extension().and_then(|e| e.to_str()).map(|e| e == "yml" || e == "yaml").unwrap_or(false)) + .filter(|p| p.is_file() && p.extension().and_then(|e| e.to_str()).map(|e| e == "yml" || e == "yaml" || e == "conf").unwrap_or(false)) .collect(); files.sort(); for f in &files { diff --git a/libsysinspect/src/console/mod.rs b/libsysinspect/src/console/mod.rs index 18390c24..177fd29b 100644 --- a/libsysinspect/src/console/mod.rs +++ b/libsysinspect/src/console/mod.rs @@ -331,6 +331,9 @@ pub struct ConsoleMinionInfoRow { pub struct ConsoleModelRow { /// Directory-name identifier used to address the model. pub id: String, + /// Whether this model is currently exported to the cluster. + #[serde(default = "default_true")] + pub enabled: bool, /// Human-readable model name from the header. pub name: String, /// Model version from the header. @@ -349,6 +352,10 @@ pub struct ConsoleModelRow { pub target_actions: Vec<(String, Vec<(String, Vec, Vec<(String, String, bool)>)>)>, } +fn default_true() -> bool { + true +} + impl ConsoleResponse { /// Construct a successful console response carrying structured payload data. pub fn ok(payload: ConsolePayload) -> Self { diff --git a/libsysinspect/src/mdescr/catalog.rs b/libsysinspect/src/mdescr/catalog.rs index 641ae4ed..aa38899f 100644 --- a/libsysinspect/src/mdescr/catalog.rs +++ b/libsysinspect/src/mdescr/catalog.rs @@ -26,10 +26,14 @@ impl ModelCatalog { /// Scan all configured model roots and attempt to load every /// discovered model independently. pub fn scan(cfg: Arc) -> Self { - let models_root = cfg.models_dir(); + Self::scan_root(cfg.clone(), &cfg.models_dir()) + } + + /// Scan an explicit models root and attempt to load every discovered model independently. + pub fn scan_root(cfg: Arc, models_root: &std::path::Path) -> Self { let mut entries: Vec = Vec::new(); - if let Ok(read_dir) = std::fs::read_dir(&models_root) { + if let Ok(read_dir) = std::fs::read_dir(models_root) { for entry in read_dir.flatten() { let path = entry.path(); if !path.is_dir() { diff --git a/src/ui/dslbrowser.rs b/src/ui/dslbrowser.rs index 92a3ae01..5aa754a1 100644 --- a/src/ui/dslbrowser.rs +++ b/src/ui/dslbrowser.rs @@ -182,6 +182,7 @@ impl DslBrowser { } pub fn load_models(&mut self, rows: Vec, failures: Vec) { + let rows: Vec = rows.into_iter().filter(|r| r.enabled).collect(); let mut ids: Vec = rows.iter().map(|r| r.id.clone()).collect(); if ids.is_empty() { ids = vec!["(no models found)".to_string()]; diff --git a/src/ui/filepicker.rs b/src/ui/filepicker.rs index d19e407f..8c0f9d60 100644 --- a/src/ui/filepicker.rs +++ b/src/ui/filepicker.rs @@ -389,7 +389,16 @@ impl FilePicker { } KeyCode::Char(' ') => { if self.mode == PickerMode::DirectoryPicker { - self.selected = Some(self.current_path.clone()); + let idx = match self.focus { + PickerFocus::Dirs => self.dir_cursor, + PickerFocus::Files => self.dirs_end + self.file_cursor, + }; + self.selected = self + .entries + .get(idx) + .filter(|entry| entry.is_dir && !entry.is_parent) + .map(|entry| entry.path.clone()) + .or_else(|| Some(self.current_path.clone())); self.visible = false; } else { let idx = match self.focus { diff --git a/src/ui/mod.rs b/src/ui/mod.rs index 2ec79c1e..5fbe85fb 100644 --- a/src/ui/mod.rs +++ b/src/ui/mod.rs @@ -753,6 +753,7 @@ impl SysInspectUX { match self.repo_manager.active_tab { 0 => self.process_module_add(&path), 1 => self.process_library_add(&path), + 2 => self.process_model_add(&path), 4 => self.process_platform_add(&path), _ => {} } @@ -2060,6 +2061,29 @@ impl SysInspectUX { if self.repo_manager.info_visible { return self.repo_manager.handle_info_key(e); } + if self.repo_manager.model_delete_visible { + let handled = self.repo_manager.handle_model_delete_key(e); + if !handled && e.code == KeyCode::Enter { + if self.repo_manager.model_delete_focus == repomanager::ModelDeleteFocus::YesBtn { + let model_id = self.repo_manager.model_delete_id.clone(); + match self.delete_model(&model_id) { + Ok(()) => { + let _ = self.load_model_list(); + self.info_alert_visible = true; + self.info_alert_title = "Model Removal".to_string(); + self.info_alert_styled = None; + self.info_alert_message = format!("Model removed: {model_id}"); + } + Err(err) => { + self.error_alert_visible = true; + self.error_alert_message = err; + } + } + } + self.repo_manager.model_delete_visible = false; + } + return true; + } if self.repo_manager.filter_focus { match e.code { KeyCode::Esc => { @@ -2268,6 +2292,18 @@ impl SysInspectUX { *cursor_ref = (*cursor_ref + 1).min(max_cursor); } } + KeyCode::Char(' ') if self.repo_manager.active_tab == 2 && !self.repo_manager.model_rows.is_empty() => { + if let Some(model) = self.repo_manager.model_rows.get(self.repo_manager.model_cursor) { + let model_id = model.id.clone(); + let enabled = !model.enabled; + if let Err(err) = self.set_model_enabled(&model_id, enabled) { + self.error_alert_visible = true; + self.error_alert_message = err; + } else { + let _ = self.load_model_list(); + } + } + } KeyCode::PageUp => { if self.repo_manager.active_tab == 0 { let n = self.repo_manager.group_order.len(); @@ -2360,8 +2396,9 @@ impl SysInspectUX { self.status_at_profiles(); } } else if self.repo_manager.active_tab == 2 { - self.error_alert_visible = true; - self.error_alert_message = "Model deletion is not supported yet".to_string(); + if let Some(model) = self.repo_manager.model_rows.get(self.repo_manager.model_cursor) { + self.repo_manager.open_model_delete(model.id.clone()); + } } else if self.repo_manager.active_tab == 1 && !self.repo_manager.lib_rows.is_empty() { self.repo_manager.delete_mode = true; self.repo_manager.staged = self @@ -2416,8 +2453,8 @@ impl SysInspectUX { self.repo_manager.profiles.open_create(); self.status_at_profiles(); } else if self.repo_manager.active_tab == 2 { - self.error_alert_visible = true; - self.error_alert_message = "Model addition is not supported yet".to_string(); + let start_dir = std::env::current_dir().unwrap_or_default(); + self.file_picker.open(&start_dir, filepicker::PickerMode::DirectoryPicker); } else { let mode = if self.repo_manager.active_tab == 1 { filepicker::PickerMode::LibrarySelector } else { filepicker::PickerMode::Any }; self.file_picker.open(&std::env::current_dir().unwrap_or_default(), mode); @@ -2686,6 +2723,121 @@ impl SysInspectUX { } } + fn model_dropin_path(&self) -> PathBuf { + let cfg_path = self.cfg.config_path(); + let stem = cfg_path.file_stem().unwrap_or_default().to_string_lossy().to_string(); + cfg_path.with_file_name(format!("{stem}.d")).join("99-models.conf") + } + + fn enabled_model_ids(&self) -> Vec { + let mut ids: Vec = self.repo_manager.model_rows.iter().filter(|row| row.enabled).map(|row| row.id.clone()).collect(); + ids.sort(); + ids.dedup(); + ids + } + + fn write_enabled_models_dropin(&self, mut ids: Vec) -> Result<(), String> { + ids.sort(); + ids.dedup(); + let dropin = self.model_dropin_path(); + if let Some(parent) = dropin.parent() { + std::fs::create_dir_all(parent).map_err(|e| format!("Unable to create drop-in directory: {e}"))?; + } + let mut body = String::from("config:\n master:\n fileserver.models:"); + if ids.is_empty() { + body.push_str(" []\n"); + } else { + body.push('\n'); + for id in ids { + body.push_str(&format!(" - {id}\n")); + } + } + std::fs::write(&dropin, body).map_err(|e| format!("Unable to write models drop-in: {e}")) + } + + fn set_model_enabled(&mut self, model_id: &str, enabled: bool) -> Result<(), String> { + let mut ids = self.enabled_model_ids(); + if enabled { + if !ids.iter().any(|id| id == model_id) { + ids.push(model_id.to_string()); + } + } else { + ids.retain(|id| id != model_id); + } + self.write_enabled_models_dropin(ids)?; + for row in &mut self.repo_manager.model_rows { + if row.id == model_id { + row.enabled = enabled; + } + } + Ok(()) + } + + fn process_model_add(&mut self, path: &std::path::Path) { + if !path.is_dir() { + self.error_alert_visible = true; + self.error_alert_message = "Select a model directory".to_string(); + return; + } + if !path.join("model.cfg").exists() { + self.error_alert_visible = true; + self.error_alert_message = "Selected directory does not contain model.cfg".to_string(); + return; + } + let model_id = path.file_name().unwrap_or_default().to_string_lossy().to_string(); + if model_id.is_empty() { + self.error_alert_visible = true; + self.error_alert_message = "Unable to determine model id from directory name".to_string(); + return; + } + let dst_root = self.cfg.fileserver_models_root(false); + let dst = dst_root.join(&model_id); + if dst.exists() { + self.error_alert_visible = true; + self.error_alert_message = format!("Model already exists: {model_id}"); + return; + } + match Self::copy_dir_recursive(path, &dst).and_then(|_| self.set_model_enabled(&model_id, true)) { + Ok(()) => { + let _ = self.load_model_list(); + self.info_alert_visible = true; + self.info_alert_title = "Model Import".to_string(); + self.info_alert_styled = None; + self.info_alert_message = format!("Model added: {model_id}"); + } + Err(err) => { + let _ = std::fs::remove_dir_all(&dst); + self.error_alert_visible = true; + self.error_alert_message = err; + } + } + } + + fn delete_model(&mut self, model_id: &str) -> Result<(), String> { + let path = self.cfg.fileserver_models_root(false).join(model_id); + if !path.exists() { + return Err(format!("Model does not exist: {model_id}")); + } + std::fs::remove_dir_all(&path).map_err(|e| format!("Unable to remove model {model_id}: {e}"))?; + self.set_model_enabled(model_id, false) + } + + fn copy_dir_recursive(src: &std::path::Path, dst: &std::path::Path) -> Result<(), String> { + std::fs::create_dir_all(dst).map_err(|e| format!("Unable to create destination {}: {e}", dst.display()))?; + let entries = std::fs::read_dir(src).map_err(|e| format!("Unable to read {}: {e}", src.display()))?; + for entry in entries { + let entry = entry.map_err(|e| format!("Unable to read directory entry in {}: {e}", src.display()))?; + let src_path = entry.path(); + let dst_path = dst.join(entry.file_name()); + if src_path.is_dir() { + Self::copy_dir_recursive(&src_path, &dst_path)?; + } else { + std::fs::copy(&src_path, &dst_path).map_err(|e| format!("Unable to copy {} to {}: {e}", src_path.display(), dst_path.display()))?; + } + } + Ok(()) + } + fn process_module_add(&mut self, path: &std::path::Path) { if path.is_dir() { let mut staged = Self::scan_dir_for_modules(path); diff --git a/src/ui/repomanager.rs b/src/ui/repomanager.rs index ee0cd0ee..5ac13f8b 100644 --- a/src/ui/repomanager.rs +++ b/src/ui/repomanager.rs @@ -47,6 +47,12 @@ pub enum StagingMode { ProfileLibraryAdd, } +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum ModelDeleteFocus { + YesBtn, + NoBtn, +} + #[derive(Debug)] pub struct RepoManager { pub visible: bool, @@ -96,6 +102,9 @@ pub struct RepoManager { pub model_rows: Vec, pub model_cursor: usize, pub model_scroll: Cell, + pub model_delete_visible: bool, + pub model_delete_id: String, + pub model_delete_focus: ModelDeleteFocus, // Profiles pub profiles: profiles::ProfilesManager, @@ -140,6 +149,9 @@ impl Default for RepoManager { model_rows: Vec::new(), model_cursor: 0, model_scroll: Cell::new(0), + model_delete_visible: false, + model_delete_id: String::new(), + model_delete_focus: ModelDeleteFocus::NoBtn, profiles: profiles::ProfilesManager::default(), platforms: platforms::PlatformsManager::default(), } @@ -325,6 +337,9 @@ impl RepoManager { if self.info_visible { self.render_info(parent, buf); } + if self.model_delete_visible { + self.render_model_delete(parent, buf); + } if self.staging { self.render_staging(parent, buf); } @@ -930,14 +945,21 @@ impl RepoManager { return; } let hl = Style::default().fg(palette::BLACK).bg(palette::HIGHLIGHT); - let name_w = list_area.width.saturating_sub(32); + let state_w: u16 = 4; + let name_w = list_area.width.saturating_sub(36); let ver_w: u16 = 14; for i in 0..view_h.min(total.saturating_sub(s)) { let fi = s + i; let (_oi, row) = filtered[fi]; let ry = list_area.y + i as u16; let sel = !self.filter_focus && fi == cursor; - let row_style = if sel { hl } else { Style::default().fg(palette::FG) }; + let row_style = if sel { + hl + } else if row.enabled { + Style::default().fg(palette::FG) + } else { + Style::default().fg(palette::MUTED) + }; if sel { for cx in 0..list_area.width { if let Some(cell) = buf.cell_mut(Position::new(list_area.x + cx, ry)) { @@ -945,14 +967,40 @@ impl RepoManager { } } } - let name_style = if sel { row_style } else { Style::default().fg(palette::PROCESSING) }; - buf.set_string(list_area.x + 1, ry, format!(" {}", truncate_str(&row.name, name_w as usize)), name_style); - let ver_style = if sel { row_style } else { Style::default().fg(palette::PROCESSING_HEAT) }; - let ver_x = list_area.x + 1 + name_w + 1; + let check_style = if sel { + row_style + } else if row.enabled { + Style::default().fg(palette::SUCCESS_PEAK) + } else { + Style::default().fg(palette::MUTED) + }; + buf.set_string(list_area.x + 1, ry, if row.enabled { "▣" } else { "□" }, check_style); + let name_style = if sel { + row_style + } else if row.enabled { + Style::default().fg(palette::PROCESSING) + } else { + Style::default().fg(palette::MUTED) + }; + buf.set_string(list_area.x + 1 + state_w + 1, ry, truncate_str(&row.name, name_w as usize), name_style); + let ver_style = if sel { + row_style + } else if row.enabled { + Style::default().fg(palette::PROCESSING_HEAT) + } else { + Style::default().fg(palette::MUTED) + }; + let ver_x = list_area.x + 1 + state_w + 1 + name_w + 1; buf.set_string(ver_x, ry, truncate_str(&row.version, ver_w as usize), ver_style); - let descr_style = if sel { row_style } else { Style::default().fg(palette::GRAY_1) }; + let descr_style = if sel { + row_style + } else if row.enabled { + Style::default().fg(palette::GRAY_1) + } else { + Style::default().fg(palette::MUTED) + }; let descr_x = ver_x + 1 + ver_w; - let descr_w = list_area.width.saturating_sub(1 + name_w + 1 + ver_w + 1); + let descr_w = list_area.width.saturating_sub(1 + state_w + 1 + name_w + 1 + ver_w + 1); let descr = format!(" {}", row.description.lines().next().unwrap_or("")); buf.set_string(descr_x, ry, truncate_str(&descr, descr_w as usize), descr_style); } @@ -1041,6 +1089,32 @@ impl RepoManager { true } + pub fn open_model_delete(&mut self, model_id: String) { + self.model_delete_id = model_id; + self.model_delete_focus = ModelDeleteFocus::NoBtn; + self.model_delete_visible = true; + } + + pub fn handle_model_delete_key(&mut self, key: crossterm::event::KeyEvent) -> bool { + match key.code { + crossterm::event::KeyCode::Esc => { + self.model_delete_visible = false; + } + crossterm::event::KeyCode::Tab + | crossterm::event::KeyCode::BackTab + | crossterm::event::KeyCode::Left + | crossterm::event::KeyCode::Right => { + self.model_delete_focus = match self.model_delete_focus { + ModelDeleteFocus::YesBtn => ModelDeleteFocus::NoBtn, + ModelDeleteFocus::NoBtn => ModelDeleteFocus::YesBtn, + }; + } + crossterm::event::KeyCode::Enter => return false, + _ => {} + } + true + } + fn render_info(&self, parent: Rect, buf: &mut Buffer) { match self.info_active_tab { 0 => self.render_module_info(parent, buf), @@ -1245,8 +1319,8 @@ impl RepoManager { Some(r) => r, None => return, }; - let w = (parent.width * 60 / 100).max(50).min(parent.width.saturating_sub(2)); - let h: u16 = 10; + let w = (parent.width * 75 / 100).max(70).min(parent.width.saturating_sub(2)); + let h: u16 = 18; let x = parent.x + (parent.width.saturating_sub(w)) / 2; let y = parent.y + (parent.height.saturating_sub(h)) / 2; let canvas = Rect { x, y, width: w, height: h }; @@ -1278,32 +1352,43 @@ impl RepoManager { &[TitleSegment { text: format!(" {} ", model.name), bg: palette::PROCESSING_BASE, fg: palette::FG, modifier: Modifier::empty() }], ); - let key_style = Style::default().fg(palette::PROCESSING).add_modifier(Modifier::BOLD); + let key_style = Style::default().fg(palette::FORM_LABEL); let val_style = Style::default().fg(palette::FG); - - let descr = model.description.lines().next().unwrap_or(""); - let lines: [(&str, &str); 5] = [ - ("Id: ", &model.id), - ("Version: ", &model.version), - ("Entrypts:", &model.entrypoints.join(", ")), - ("States: ", &model.states.join(", ")), - ("Descr: ", descr), + let enabled_style = if model.enabled { Style::default().fg(palette::SUCCESS_PEAK) } else { Style::default().fg(palette::MUTED) }; + + let lines: [(&str, String); 5] = [ + ("Id:", model.id.clone()), + ("Enabled:", if model.enabled { "yes".to_string() } else { "no".to_string() }), + ("Version:", model.version.clone()), + ("Entrypts:", model.entrypoints.join(", ")), + ("States:", model.states.join(", ")), ]; for (i, (label, value)) in lines.iter().enumerate() { let ry = inner.y + 1 + i as u16; - buf.set_string(inner.x + 3, ry, label, key_style); - buf.set_string( - inner.x + 3 + label.len() as u16, - ry, - truncate_str(value, (inner.width as usize).saturating_sub(3 + label.len() + 2)), - val_style, - ); + buf.set_string(inner.x + 3, ry, format!("{label:<8}"), key_style); + let style = if *label == "Enabled:" { enabled_style } else { val_style }; + buf.set_string(inner.x + 12, ry, truncate_str(value, (inner.width as usize).saturating_sub(14)), style); } + let desc_area = Rect { x: inner.x + 2, y: inner.y + 7, width: inner.width.saturating_sub(3), height: inner.height.saturating_sub(10) }; + dashed_title( + Rect { x: desc_area.x, y: desc_area.y, width: desc_area.width, height: 1 }, + buf, + " Description ", + palette::PROCESSING, + palette::PRIMARY, + palette::PROCESSING_DIMMED, + ); + self.render_info_text( + Rect { x: desc_area.x, y: desc_area.y + 2, width: desc_area.width, height: desc_area.height.saturating_sub(2) }, + buf, + &model.description, + ); + let close_lbl = "[ Close ]"; let close_w = close_lbl.len() as u16; let btn_x = inner.x + (inner.width.saturating_sub(close_w)) / 2; - let btn_y = inner.y + 7; + let btn_y = inner.bottom().saturating_sub(2); Paragraph::new(close_lbl) .style(Style::default().fg(palette::WHITE).bg(palette::PROCESSING_HEAT).add_modifier(Modifier::BOLD)) .render(Rect::new(btn_x, btn_y, close_w, 1), buf); @@ -1311,6 +1396,70 @@ impl RepoManager { Self::draw_shadow(buf, canvas, w, h); } + fn render_model_delete(&self, parent: Rect, buf: &mut Buffer) { + let w = (parent.width / 2).clamp(42, 64); + let h: u16 = 6; + let x = parent.x + (parent.width.saturating_sub(w)) / 2; + let y = parent.y + (parent.height.saturating_sub(h)) / 2; + let canvas = Rect { x, y, width: w, height: h }; + + Clear.render(canvas, buf); + + let grad = blend_2d(canvas.width as usize, canvas.height as usize, 10.0, &[palette::BG_1, palette::BG_0] as &[Color]); + for ry in 0..canvas.height { + for cx in 0..canvas.width { + let idx = ry as usize * canvas.width as usize + cx as usize; + if let Some(cell) = buf.cell_mut(Position::new(canvas.x + cx, canvas.y + ry)) { + cell.set_bg(grad[idx]); + } + } + } + + let block = Block::default() + .borders(Borders::ALL) + .border_type(BorderType::Rounded) + .border_style(Style::default().fg(palette::PROCESSING_GLOW)) + .style(Style::default()); + let inner = block.inner(canvas); + block.render(canvas, buf); + + let title_style = TitleStyle::cyberpunk(palette::PROCESSING_GLOW); + title::overlay_gradient_title( + buf, + canvas, + &title_style, + &[TitleSegment { + text: format!(" Delete {} ", self.model_delete_id), + bg: palette::ERROR_BASE, + fg: palette::FG, + modifier: Modifier::empty(), + }], + ); + + let msg = format!("Delete model \"{}\"?", self.model_delete_id); + let msg_x = inner.x + (inner.width.saturating_sub(msg.len() as u16)) / 2; + buf.set_string(msg_x, inner.y + 1, &msg, Style::default().fg(palette::FG)); + + let btn_y = inner.y + 3; + let yes_lbl = "[ Yes ]"; + let no_lbl = "[ No ]"; + let yes_w: u16 = 10; + let no_w: u16 = 10; + let gap: u16 = 3; + let total_btn_w = yes_w + gap + no_w; + let btn_x = inner.x + (inner.width.saturating_sub(total_btn_w)) / 2; + + let sel_btn = Style::default().fg(palette::WHITE).bg(palette::PROCESSING_HEAT).add_modifier(Modifier::BOLD); + let unsel_btn = Style::default().fg(palette::FG).bg(palette::BG_2).add_modifier(Modifier::BOLD); + + let yes_style = if self.model_delete_focus == ModelDeleteFocus::YesBtn { sel_btn } else { unsel_btn }; + let no_style = if self.model_delete_focus == ModelDeleteFocus::NoBtn { sel_btn } else { unsel_btn }; + buf.set_string(btn_x, btn_y, yes_lbl, yes_style); + buf.set_string(btn_x + yes_w + gap, btn_y, no_lbl, no_style); + + Self::draw_shadow(buf, canvas, w, h); + } + fn render_info_text(&self, area: Rect, buf: &mut Buffer, text: &str) { let w = (area.width.saturating_sub(3)) as usize; let lines: Vec = text.split('\n').flat_map(|l| dslbrowser::wrap_text(l, w)).collect(); diff --git a/sysmaster/src/console.rs b/sysmaster/src/console.rs index 6e26010e..076e7ec7 100644 --- a/sysmaster/src/console.rs +++ b/sysmaster/src/console.rs @@ -250,10 +250,12 @@ impl SysMaster { /// Build model-discovery rows from the master's fileserver models directory. async fn models_data(&mut self) -> Result<(Vec, Vec), SysinspectError> { + let fresh_cfg = libsysinspect::cfg::mmconf::MasterConfig::new(self.cfg.config_path()).unwrap_or_else(|_| self.cfg.clone()); + let enabled_models: std::collections::BTreeSet = fresh_cfg.fileserver_models().iter().cloned().collect(); let mut minion_cfg = MinionConfig::default(); - let root = self.cfg.fileserver_root().to_str().unwrap_or("/etc/sysinspect").to_string(); + let root = fresh_cfg.fileserver_root().to_str().unwrap_or("/etc/sysinspect").to_string(); minion_cfg.set_root_dir(&root); - let catalog = ModelCatalog::scan(std::sync::Arc::new(minion_cfg)); + let catalog = ModelCatalog::scan_root(std::sync::Arc::new(minion_cfg), &fresh_cfg.fileserver_models_root(false)); let failures = catalog .failures() .into_iter() @@ -305,6 +307,7 @@ impl SysMaster { ConsoleModelRow { id: m.metadata.id.clone(), + enabled: enabled_models.contains(&m.metadata.id), name: m.metadata.name.clone(), version: m.metadata.version.clone(), description: m.metadata.description.clone(), From 0ea08c1e4ad087303a0f5f2debe5bcb0a8317139 Mon Sep 17 00:00:00 2001 From: Bo Maryniuk Date: Mon, 15 Jun 2026 00:05:06 +0200 Subject: [PATCH 16/22] Hot-reload master config --- libsysproto/src/query.rs | 3 +++ src/ui/mod.rs | 16 +++++++++++++--- sysmaster/src/console.rs | 12 ++++++++++++ sysmaster/src/master.rs | 11 ++++++++--- 4 files changed, 36 insertions(+), 6 deletions(-) diff --git a/libsysproto/src/query.rs b/libsysproto/src/query.rs index eb4d2bf8..cb01e9e0 100644 --- a/libsysproto/src/query.rs +++ b/libsysproto/src/query.rs @@ -50,6 +50,9 @@ pub mod commands { // List available models on the master pub const CLUSTER_MODELS: &str = "cluster/models"; + // Reload master configuration from disk + pub const CLUSTER_CONFIG_RELOAD: &str = "cluster/config/reload"; + // SSH-start one offline hopstart-backed minion pub const CLUSTER_MINION_HOPSTART: &str = "cluster/minion/hopstart"; diff --git a/src/ui/mod.rs b/src/ui/mod.rs index 5fbe85fb..b84d2ef6 100644 --- a/src/ui/mod.rs +++ b/src/ui/mod.rs @@ -21,9 +21,9 @@ use libsysinspect::{ use libsysproto::query::{ SCHEME_COMMAND, commands::{ - CLUSTER_LIBRARY_INDEX, CLUSTER_MASTER_LOGS, CLUSTER_MINION_HOPSTART, CLUSTER_MINION_INFO, CLUSTER_MINION_LOGS, CLUSTER_MINION_RECONNECT, - CLUSTER_MINION_SHUTDOWN, CLUSTER_MODELS, CLUSTER_MODULE_INDEX, CLUSTER_ONLINE_MINIONS, CLUSTER_PROFILE, CLUSTER_RECONNECT, - CLUSTER_REMOVE_MINION, CLUSTER_SHUTDOWN, CLUSTER_TRAITS_UPDATE, + CLUSTER_CONFIG_RELOAD, CLUSTER_LIBRARY_INDEX, CLUSTER_MASTER_LOGS, CLUSTER_MINION_HOPSTART, CLUSTER_MINION_INFO, CLUSTER_MINION_LOGS, + CLUSTER_MINION_RECONNECT, CLUSTER_MINION_SHUTDOWN, CLUSTER_MODELS, CLUSTER_MODULE_INDEX, CLUSTER_ONLINE_MINIONS, CLUSTER_PROFILE, + CLUSTER_RECONNECT, CLUSTER_REMOVE_MINION, CLUSTER_SHUTDOWN, CLUSTER_TRAITS_UPDATE, }, }; use ratatui::{ @@ -2755,6 +2755,15 @@ impl SysInspectUX { std::fs::write(&dropin, body).map_err(|e| format!("Unable to write models drop-in: {e}")) } + fn reload_master_config(&self) -> Result<(), String> { + let resp = tokio::task::block_in_place(|| { + tokio::runtime::Handle::current() + .block_on(async { call_master_console(&self.cfg, &format!("{SCHEME_COMMAND}{CLUSTER_CONFIG_RELOAD}"), "*", None, None, None).await }) + }) + .map_err(|e| format!("Failed to reload master config: {e}"))?; + if resp.ok { Ok(()) } else { Err(if resp.error.is_empty() { "Master config reload failed".to_string() } else { resp.error }) } + } + fn set_model_enabled(&mut self, model_id: &str, enabled: bool) -> Result<(), String> { let mut ids = self.enabled_model_ids(); if enabled { @@ -2765,6 +2774,7 @@ impl SysInspectUX { ids.retain(|id| id != model_id); } self.write_enabled_models_dropin(ids)?; + self.reload_master_config()?; for row in &mut self.repo_manager.model_rows { if row.id == model_id { row.enabled = enabled; diff --git a/sysmaster/src/console.rs b/sysmaster/src/console.rs index 076e7ec7..eb0626e2 100644 --- a/sysmaster/src/console.rs +++ b/sysmaster/src/console.rs @@ -896,6 +896,18 @@ impl SysMaster { }; } + if query.model.eq(&format!("{SCHEME_COMMAND}{CLUSTER_CONFIG_RELOAD}")) { + return match master.lock().await.reload_config() { + Ok(()) => ConsoleResponse::ok(ConsolePayload::Ack { + action: "reloaded_master_config".to_string(), + target: query.model, + count: 0, + items: vec![], + }), + Err(err) => ConsoleResponse::err(format!("Unable to reload master config: {err}")), + }; + } + if query.model.eq(&format!("{SCHEME_COMMAND}{CLUSTER_PROFILE}")) { let (response, msgs) = match ProfileConsoleRequest::from_context(&query.context) { Ok(request) => { diff --git a/sysmaster/src/master.rs b/sysmaster/src/master.rs index da09872d..e47df7e3 100644 --- a/sysmaster/src/master.rs +++ b/sysmaster/src/master.rs @@ -39,9 +39,9 @@ use libsysproto::{ query::{ SCHEME_COMMAND, commands::{ - CLUSTER_CMDB_UPSERT, CLUSTER_HOPSTART, CLUSTER_LIBRARY_INDEX, CLUSTER_MASTER_LOGS, CLUSTER_MINION_HOPSTART, CLUSTER_MINION_INFO, - CLUSTER_MINION_LOGS, CLUSTER_MINION_RECONNECT, CLUSTER_MINION_SHUTDOWN, CLUSTER_MODELS, CLUSTER_MODULE_INDEX, CLUSTER_ONLINE_MINIONS, - CLUSTER_PROFILE, CLUSTER_REMOVE_MINION, CLUSTER_ROTATE, CLUSTER_TRAITS_UPDATE, CLUSTER_TRANSPORT_STATUS, + CLUSTER_CMDB_UPSERT, CLUSTER_CONFIG_RELOAD, CLUSTER_HOPSTART, CLUSTER_LIBRARY_INDEX, CLUSTER_MASTER_LOGS, CLUSTER_MINION_HOPSTART, + CLUSTER_MINION_INFO, CLUSTER_MINION_LOGS, CLUSTER_MINION_RECONNECT, CLUSTER_MINION_SHUTDOWN, CLUSTER_MODELS, CLUSTER_MODULE_INDEX, + CLUSTER_ONLINE_MINIONS, CLUSTER_PROFILE, CLUSTER_REMOVE_MINION, CLUSTER_ROTATE, CLUSTER_TRAITS_UPDATE, CLUSTER_TRANSPORT_STATUS, }, }, replay::{ReplayIdentity, replay_identity_for_master_command_cycle, replay_identity_from_minion_message}, @@ -325,6 +325,11 @@ impl SysMaster { &self.cfg } + pub fn reload_config(&mut self) -> Result<(), SysinspectError> { + self.cfg = MasterConfig::new(self.cfg.config_path())?; + Ok(()) + } + async fn backfill_cmdb(&mut self) -> Result<(), SysinspectError> { let ids = self.mkr.registered_ids(); let cmdb_update = self.cfg.cmdb_update(); From 7b7997d94b85d79e512497d22d5dcb0fa0abda23 Mon Sep 17 00:00:00 2001 From: Bo Maryniuk Date: Mon, 15 Jun 2026 00:50:18 +0200 Subject: [PATCH 17/22] Fix musl builds --- Makefile | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/Makefile b/Makefile index 8f0b4e9c..1644050c 100644 --- a/Makefile +++ b/Makefile @@ -166,25 +166,25 @@ endif musl-x86_64-modules-dist-dev: $(call check_present,x86_64-linux-musl-gcc) - cargo build -v --workspace $(MUSL_WORKSPACE_EXCLUDES) --target x86_64-unknown-linux-musl + @sh scripts/run-musl-cargo.sh x86_64-unknown-linux-musl x86_64-linux-musl-gcc build -v --workspace $(MUSL_WORKSPACE_EXCLUDES) --target x86_64-unknown-linux-musl $(call stage_profile_modules,debug,x86_64-unknown-linux-musl) $(call stage_modules_dist_from,debug,x86_64-unknown-linux-musl,$(MUSL_MODULE_PACKAGE_SPECS),$(call musl_modules_dist_dir,x86_64,debug)) musl-x86_64-modules-dist: $(call check_present,x86_64-linux-musl-gcc) - cargo build --release --workspace $(MUSL_WORKSPACE_EXCLUDES) --target x86_64-unknown-linux-musl + @sh scripts/run-musl-cargo.sh x86_64-unknown-linux-musl x86_64-linux-musl-gcc build --release --workspace $(MUSL_WORKSPACE_EXCLUDES) --target x86_64-unknown-linux-musl $(call stage_profile_modules,release,x86_64-unknown-linux-musl) $(call stage_modules_dist_from,release,x86_64-unknown-linux-musl,$(MUSL_MODULE_PACKAGE_SPECS),$(call musl_modules_dist_dir,x86_64,release)) musl-aarch64-modules-dist-dev: $(call check_present,aarch64-linux-musl-gcc) - cargo build -v --workspace $(MUSL_WORKSPACE_EXCLUDES) --target aarch64-unknown-linux-musl + @sh scripts/run-musl-cargo.sh aarch64-unknown-linux-musl aarch64-linux-musl-gcc build -v --workspace $(MUSL_WORKSPACE_EXCLUDES) --target aarch64-unknown-linux-musl $(call stage_profile_modules,debug,aarch64-unknown-linux-musl) $(call stage_modules_dist_from,debug,aarch64-unknown-linux-musl,$(MUSL_MODULE_PACKAGE_SPECS),$(call musl_modules_dist_dir,aarch64,debug)) musl-aarch64-modules-dist: $(call check_present,aarch64-linux-musl-gcc) - cargo build --release --workspace $(MUSL_WORKSPACE_EXCLUDES) --target aarch64-unknown-linux-musl + @sh scripts/run-musl-cargo.sh aarch64-unknown-linux-musl aarch64-linux-musl-gcc build --release --workspace $(MUSL_WORKSPACE_EXCLUDES) --target aarch64-unknown-linux-musl $(call stage_profile_modules,release,aarch64-unknown-linux-musl) $(call stage_modules_dist_from,release,aarch64-unknown-linux-musl,$(MUSL_MODULE_PACKAGE_SPECS),$(call musl_modules_dist_dir,aarch64,release)) From bdb9c4b6d81fbfe8b5f6d091102102b10df5a452 Mon Sep 17 00:00:00 2001 From: Bo Maryniuk Date: Mon, 15 Jun 2026 00:50:39 +0200 Subject: [PATCH 18/22] Fix contract in os.facts and make a better logging to that matter --- libsysinspect/src/intp/actproc/modfinder.rs | 94 ++++++++++++---- modules/os/facts/src/main.rs | 6 +- modules/os/facts/src/mod_doc.yaml | 2 +- src/ui/mod.rs | 116 ++++++++++++++++++-- src/ui/repomanager.rs | 2 + sysmaster/src/console.rs | 7 +- sysmaster/src/master.rs | 5 +- 7 files changed, 192 insertions(+), 40 deletions(-) diff --git a/libsysinspect/src/intp/actproc/modfinder.rs b/libsysinspect/src/intp/actproc/modfinder.rs index b7e4d7b3..5358cfd4 100644 --- a/libsysinspect/src/intp/actproc/modfinder.rs +++ b/libsysinspect/src/intp/actproc/modfinder.rs @@ -455,20 +455,47 @@ impl ModCall { } }; - match serde_json::from_str::(&cleaned) { - Ok(r) => { - let mut data = r.clone(); - data.add_data("run-uid", json!(spec.uid)); - data.add_data("run-gid", json!(spec.gid)); - - Ok(Some(ActionResponse::new(self.eid.to_owned(), self.aid.to_owned(), self.state.to_owned(), data, self.eval_constraints(&r)))) - } + let v = match serde_json::from_str::(&cleaned) { + Ok(v) => v, Err(e) => { log::debug!("STDOUT (raw): {raw_out}"); - log::debug!("STDOUT (cleaned): {cleaned}"); - Err(SysinspectError::ModuleError(format!("JSON error: {e}"))) + return Err(SysinspectError::ModuleError(format!( + "Module '{}' returned invalid JSON: {e}", + self.module.display() + ))); + } + }; + if let Some(obj) = v.as_object() { + let mut missing: Vec<&str> = Vec::new(); + if !obj.contains_key("retcode") { + missing.push("retcode"); + } + if !obj.contains_key("message") { + missing.push("message"); + } + if !missing.is_empty() { + log::debug!("STDOUT (raw): {raw_out}"); + return Err(SysinspectError::ModuleError(format!( + "Module '{}' contract violation: required keys absent — {}", + self.module.display(), + missing.join(", ") + ))); } } + let r = match serde_json::from_value::(v) { + Ok(r) => r, + Err(e) => { + log::debug!("STDOUT (raw): {raw_out}"); + return Err(SysinspectError::ModuleError(format!( + "Module '{}' response format error: {e}", + self.module.display() + ))); + } + }; + let mut data = r.clone(); + data.add_data("run-uid", json!(spec.uid)); + data.add_data("run-gid", json!(spec.gid)); + Ok(Some(ActionResponse::new(self.eid.to_owned(), self.aid.to_owned(), self.state.to_owned(), data, self.eval_constraints(&r)))) } else { log::debug!("Spawning module with default privileges"); let mut p = Command::new(&self.module) @@ -502,20 +529,47 @@ impl ModCall { } }; - match serde_json::from_str::(&cleaned) { - Ok(r) => { - let mut data = r.clone(); - data.add_data("run-uid", json!(muid)); - data.add_data("run-gid", json!(mgid)); - - Ok(Some(ActionResponse::new(self.eid.to_owned(), self.aid.to_owned(), self.state.to_owned(), data, self.eval_constraints(&r)))) - } + let v = match serde_json::from_str::(&cleaned) { + Ok(v) => v, Err(e) => { log::debug!("STDOUT (raw): {raw_out}"); - log::debug!("STDOUT (cleaned): {cleaned}"); - Err(SysinspectError::ModuleError(format!("JSON error: {e}"))) + return Err(SysinspectError::ModuleError(format!( + "Module '{}' returned invalid JSON: {e}", + self.module.display() + ))); + } + }; + if let Some(obj) = v.as_object() { + let mut missing: Vec<&str> = Vec::new(); + if !obj.contains_key("retcode") { + missing.push("retcode"); + } + if !obj.contains_key("message") { + missing.push("message"); + } + if !missing.is_empty() { + log::debug!("STDOUT (raw): {raw_out}"); + return Err(SysinspectError::ModuleError(format!( + "Module '{}' contract violation: required keys absent — {}", + self.module.display(), + missing.join(", ") + ))); } } + let r = match serde_json::from_value::(v) { + Ok(r) => r, + Err(e) => { + log::debug!("STDOUT (raw): {raw_out}"); + return Err(SysinspectError::ModuleError(format!( + "Module '{}' response format error: {e}", + self.module.display() + ))); + } + }; + let mut data = r.clone(); + data.add_data("run-uid", json!(muid)); + data.add_data("run-gid", json!(mgid)); + Ok(Some(ActionResponse::new(self.eid.to_owned(), self.aid.to_owned(), self.state.to_owned(), data, self.eval_constraints(&r)))) } } diff --git a/modules/os/facts/src/main.rs b/modules/os/facts/src/main.rs index f05c44f1..87cd558d 100644 --- a/modules/os/facts/src/main.rs +++ b/modules/os/facts/src/main.rs @@ -35,7 +35,7 @@ fn bytes_contains(haystack: &[u8], needle: &[u8]) -> bool { fn gather_facts(out: &mut [u8; 8192]) -> usize { let mut p = 0usize; - p += wb(out, p, b"{\"retcode\":0,\"data\":{"); + p += wb(out, p, b"{\"retcode\":0,\"message\":\"System facts gathered\",\"data\":{"); p += wkv(out, p, b"os", current_os().as_bytes()); p += w1(out, p, b','); p += wkv(out, p, b"arch", current_arch().as_bytes()); @@ -70,7 +70,7 @@ fn gather_facts(out: &mut [u8; 8192]) -> usize { } fn list_keys(out: &mut [u8; 8192]) -> usize { - let keys = b"{\"retcode\":0,\"data\":[\"os\",\"arch\",\"hostname\",\"kernel\",\"uptime_seconds\",\"memory_total_kb\",\"memory_free_kb\",\"swap_total_kb\",\"swap_free_kb\",\"cpu_model\",\"cpu_cores\",\"load_1m\",\"load_5m\"]}\n"; + let keys = b"{\"retcode\":0,\"message\":\"Available fact keys listed\",\"data\":[\"os\",\"arch\",\"hostname\",\"kernel\",\"uptime_seconds\",\"memory_total_kb\",\"memory_free_kb\",\"swap_total_kb\",\"swap_free_kb\",\"cpu_model\",\"cpu_cores\",\"load_1m\",\"load_5m\"]}\n"; let mut p = 0usize; p += wb(out, p, keys); p @@ -79,7 +79,7 @@ fn list_keys(out: &mut [u8; 8192]) -> usize { fn get_fact(input: &[u8], out: &mut [u8; 8192]) -> usize { let key = find_arg(input, b"key"); let mut p = 0usize; - p += wb(out, p, b"{\"retcode\":0,\"data\":{"); + p += wb(out, p, b"{\"retcode\":0,\"message\":\"Fact retrieved\",\"data\":{"); match key { Some(b"os") => p += wkv(out, p, b"os", current_os().as_bytes()), Some(b"arch") => p += wkv(out, p, b"arch", current_arch().as_bytes()), diff --git a/modules/os/facts/src/mod_doc.yaml b/modules/os/facts/src/mod_doc.yaml index 5a4dbd2c..0f1a2b33 100644 --- a/modules/os/facts/src/mod_doc.yaml +++ b/modules/os/facts/src/mod_doc.yaml @@ -1,5 +1,5 @@ name: "os.facts" -version: "0.1.0" +version: "0.1.1" author: "Bo Maryniuk" description: | Gather system facts: OS, kernel, architecture, hostname, memory, CPU, diff --git a/src/ui/mod.rs b/src/ui/mod.rs index b84d2ef6..0622f4e0 100644 --- a/src/ui/mod.rs +++ b/src/ui/mod.rs @@ -14,8 +14,9 @@ use libeventreg::{ use libmodcore::modinit::ModInterface; use libmodpak::{SysInspectModPak, mpk::ModPakMetadata}; use libsysinspect::{ - cfg::mmconf::MasterConfig, + cfg::mmconf::{MasterConfig, MinionConfig}, console::{ConsoleMinionInfoRow, ConsoleModelRow, ConsoleModuleRow, ConsoleOnlineMinionRow, ConsolePayload}, + mdescr::catalog::ModelCatalog, traits::os_display_name, }; use libsysproto::query::{ @@ -2222,6 +2223,14 @@ impl SysInspectUX { let page = 10usize; match e.code { KeyCode::Esc => { + if self.repo_manager.models_dirty { + if let Err(err) = self.reload_master_config() { + self.error_alert_visible = true; + self.error_alert_message = err; + return true; + } + self.repo_manager.models_dirty = false; + } self.repo_manager.exit_staging(); self.repo_manager.visible = false; self.status_at_cycles(); @@ -2237,7 +2246,7 @@ impl SysInspectUX { if self.repo_manager.active_tab == 1 { let _ = self.load_library_index(); } - if self.repo_manager.active_tab == 2 { + if self.repo_manager.active_tab == 2 && !self.repo_manager.models_dirty { let _ = self.load_model_list(); } if self.repo_manager.active_tab == 3 { @@ -2258,7 +2267,7 @@ impl SysInspectUX { if self.repo_manager.active_tab == 1 { let _ = self.load_library_index(); } - if self.repo_manager.active_tab == 2 { + if self.repo_manager.active_tab == 2 && !self.repo_manager.models_dirty { let _ = self.load_model_list(); } if self.repo_manager.active_tab == 3 { @@ -2755,6 +2764,73 @@ impl SysInspectUX { std::fs::write(&dropin, body).map_err(|e| format!("Unable to write models drop-in: {e}")) } + fn refresh_local_model_rows(&mut self, enabled_ids: &[String]) -> Result<(), String> { + let mut minion_cfg = MinionConfig::default(); + let root = self.cfg.fileserver_root().to_str().unwrap_or("/etc/sysinspect").to_string(); + minion_cfg.set_root_dir(&root); + let enabled: std::collections::BTreeSet = enabled_ids.iter().cloned().collect(); + let catalog = ModelCatalog::scan_root(Arc::new(minion_cfg), &self.cfg.fileserver_models_root(false)); + let rows: Vec = catalog + .successes() + .into_iter() + .map(|m| { + let mut entrypoints: Vec = Vec::new(); + #[allow(clippy::type_complexity)] + let mut target_actions: Vec<(String, Vec<(String, Vec, Vec<(String, String, bool)>)>)> = Vec::new(); + + for ep in &m.entrypoints { + match ep { + libsysinspect::mdescr::browse_types::BrowsedEntrypoint::CheckbookLabel { label, entity_ids, .. } => { + entrypoints.push(label.clone()); + #[allow(clippy::type_complexity)] + let actions: Vec<(String, Vec, Vec<(String, String, bool)>)> = m + .actions + .iter() + .filter(|a| a.binds_to.iter().any(|eid| entity_ids.contains(eid))) + .map(|a| { + let states: Vec = a.states.iter().map(|s| s.state.clone()).collect(); + let ctx_vars: Vec<(String, String, bool)> = a.states.iter().flat_map(|s| s.context_vars.clone()).collect(); + (a.description.clone(), states, ctx_vars) + }) + .collect(); + target_actions.push((label.clone(), actions)); + } + libsysinspect::mdescr::browse_types::BrowsedEntrypoint::Entity { id, .. } => { + entrypoints.push(id.clone()); + #[allow(clippy::type_complexity)] + let actions: Vec<(String, Vec, Vec<(String, String, bool)>)> = m + .actions + .iter() + .filter(|a| a.binds_to.contains(id)) + .map(|a| { + let states: Vec = a.states.iter().map(|s| s.state.clone()).collect(); + let ctx_vars: Vec<(String, String, bool)> = a.states.iter().flat_map(|s| s.context_vars.clone()).collect(); + (a.description.clone(), states, ctx_vars) + }) + .collect(); + target_actions.push((id.clone(), actions)); + } + } + } + + ConsoleModelRow { + id: m.metadata.id.clone(), + enabled: enabled.contains(&m.metadata.id), + name: m.metadata.name.clone(), + version: m.metadata.version.clone(), + description: m.metadata.description.clone(), + entrypoints, + states: m.states.clone(), + target_actions, + } + }) + .collect(); + let cursor = self.repo_manager.model_cursor.min(rows.len().saturating_sub(1)); + self.repo_manager.model_rows = rows; + self.repo_manager.model_cursor = cursor; + Ok(()) + } + fn reload_master_config(&self) -> Result<(), String> { let resp = tokio::task::block_in_place(|| { tokio::runtime::Handle::current() @@ -2774,13 +2850,23 @@ impl SysInspectUX { ids.retain(|id| id != model_id); } self.write_enabled_models_dropin(ids)?; - self.reload_master_config()?; - for row in &mut self.repo_manager.model_rows { - if row.id == model_id { - row.enabled = enabled; + self.refresh_local_model_rows(&self.enabled_model_ids_with(model_id, enabled))?; + self.repo_manager.models_dirty = true; + Ok(()) + } + + fn enabled_model_ids_with(&self, model_id: &str, enabled: bool) -> Vec { + let mut ids = self.enabled_model_ids(); + if enabled { + if !ids.iter().any(|id| id == model_id) { + ids.push(model_id.to_string()); } + } else { + ids.retain(|id| id != model_id); } - Ok(()) + ids.sort(); + ids.dedup(); + ids } fn process_model_add(&mut self, path: &std::path::Path) { @@ -2807,9 +2893,13 @@ impl SysInspectUX { self.error_alert_message = format!("Model already exists: {model_id}"); return; } - match Self::copy_dir_recursive(path, &dst).and_then(|_| self.set_model_enabled(&model_id, true)) { + let enabled_ids = self.enabled_model_ids_with(&model_id, true); + match Self::copy_dir_recursive(path, &dst) + .and_then(|_| self.write_enabled_models_dropin(enabled_ids.clone())) + .and_then(|_| self.refresh_local_model_rows(&enabled_ids)) + { Ok(()) => { - let _ = self.load_model_list(); + self.repo_manager.models_dirty = true; self.info_alert_visible = true; self.info_alert_title = "Model Import".to_string(); self.info_alert_styled = None; @@ -2829,7 +2919,11 @@ impl SysInspectUX { return Err(format!("Model does not exist: {model_id}")); } std::fs::remove_dir_all(&path).map_err(|e| format!("Unable to remove model {model_id}: {e}"))?; - self.set_model_enabled(model_id, false) + let enabled_ids = self.enabled_model_ids_with(model_id, false); + self.write_enabled_models_dropin(enabled_ids.clone())?; + self.refresh_local_model_rows(&enabled_ids)?; + self.repo_manager.models_dirty = true; + Ok(()) } fn copy_dir_recursive(src: &std::path::Path, dst: &std::path::Path) -> Result<(), String> { diff --git a/src/ui/repomanager.rs b/src/ui/repomanager.rs index 5ac13f8b..924799e3 100644 --- a/src/ui/repomanager.rs +++ b/src/ui/repomanager.rs @@ -102,6 +102,7 @@ pub struct RepoManager { pub model_rows: Vec, pub model_cursor: usize, pub model_scroll: Cell, + pub models_dirty: bool, pub model_delete_visible: bool, pub model_delete_id: String, pub model_delete_focus: ModelDeleteFocus, @@ -149,6 +150,7 @@ impl Default for RepoManager { model_rows: Vec::new(), model_cursor: 0, model_scroll: Cell::new(0), + models_dirty: false, model_delete_visible: false, model_delete_id: String::new(), model_delete_focus: ModelDeleteFocus::NoBtn, diff --git a/sysmaster/src/console.rs b/sysmaster/src/console.rs index eb0626e2..528bc85a 100644 --- a/sysmaster/src/console.rs +++ b/sysmaster/src/console.rs @@ -250,12 +250,11 @@ impl SysMaster { /// Build model-discovery rows from the master's fileserver models directory. async fn models_data(&mut self) -> Result<(Vec, Vec), SysinspectError> { - let fresh_cfg = libsysinspect::cfg::mmconf::MasterConfig::new(self.cfg.config_path()).unwrap_or_else(|_| self.cfg.clone()); - let enabled_models: std::collections::BTreeSet = fresh_cfg.fileserver_models().iter().cloned().collect(); + let enabled_models: std::collections::BTreeSet = self.cfg.fileserver_models().iter().cloned().collect(); let mut minion_cfg = MinionConfig::default(); - let root = fresh_cfg.fileserver_root().to_str().unwrap_or("/etc/sysinspect").to_string(); + let root = self.cfg.fileserver_root().to_str().unwrap_or("/etc/sysinspect").to_string(); minion_cfg.set_root_dir(&root); - let catalog = ModelCatalog::scan_root(std::sync::Arc::new(minion_cfg), &fresh_cfg.fileserver_models_root(false)); + let catalog = ModelCatalog::scan_root(std::sync::Arc::new(minion_cfg), &self.cfg.fileserver_models_root(false)); let failures = catalog .failures() .into_iter() diff --git a/sysmaster/src/master.rs b/sysmaster/src/master.rs index e47df7e3..e43aa18b 100644 --- a/sysmaster/src/master.rs +++ b/sysmaster/src/master.rs @@ -326,7 +326,10 @@ impl SysMaster { } pub fn reload_config(&mut self) -> Result<(), SysinspectError> { - self.cfg = MasterConfig::new(self.cfg.config_path())?; + let path = self.cfg.config_path(); + log::info!("Reloading master configuration from {}", path.display()); + self.cfg = MasterConfig::new(path)?; + log::info!("Master configuration reloaded successfully"); Ok(()) } From 348e50be1b39699c3a901e8527ef4d55556f2ad4 Mon Sep 17 00:00:00 2001 From: Bo Maryniuk Date: Mon, 15 Jun 2026 00:51:04 +0200 Subject: [PATCH 19/22] Add not so simple helloworld demo model with constraints and checkbook --- examples/demos/infoping/model.cfg | 315 ++++++++++++++++++++++++++++++ 1 file changed, 315 insertions(+) create mode 100644 examples/demos/infoping/model.cfg diff --git a/examples/demos/infoping/model.cfg b/examples/demos/infoping/model.cfg new file mode 100644 index 00000000..c91c8c11 --- /dev/null +++ b/examples/demos/infoping/model.cfg @@ -0,0 +1,315 @@ +name: Infrastructure Health Ping +version: "0.1" +description: | + + ═══════════════════════════════════════════════════ + INFRAPING -- Infrastructure Health Inspector + ═══════════════════════════════════════════════════ + + A comprehensive autonomous infrastructure health check + model designed for disconnected-capable environments. + + --- What it does -------------------------------------- + + This model performs a full-stack health audit of any + minion without requiring continuous master connectivity. + It discovers system identity, hardware resources, + network topology, internet reachability, and critical + service status -- all driven by declarative constraints + rather than imperative task lists. + + --- Execution flow ------------------------------------ + + Phase 1 System Identity + Collects kernel fingerprint via uname and gathers 13 + structured operating-system facts: hostname, OS + release, kernel version, CPU model and core count, + total and free memory, uptime, and 1/5-minute load + averages. All facts are produced as machine-readable + structured data, not raw text. + + Phase 2 Network Audit + Enumerates every active network interface with MAC + addresses and IPv4/IPv6 assignments, reads the kernel + routing table to locate the default gateway, and + performs a real ICMP ping against a configurable + internet endpoint (default 1.1.1.1). Latency, packet + loss, and TTL are collected per probe. + + Phase 3 Service Guard + Verifies that critical system daemons -- SSH server + and cron scheduler by default -- are actively running. + Each check produces structured telemetry including the + detected service manager, process IDs, and exit codes. + + --- Constraint-driven validation ---------------------- + + Every collected fact is routed through eight declarative + constraints that compare observed reality against + expected claims. No imperative scripting is used: the + constraint engine judges success or failure from the + model definition alone. Failed constraints produce + routable events that can trigger follow-up models, + alerts, or operator notifications. + + --- Key design properties ----------------------------- + + * Disconnected-safe -- facts are collected and judged + locally; results are journaled for later replay. + * Execution semantics -- local completion is explicitly + separated from delivery completion. + * Structured output -- all module results are structured + JSON, not free-form text, enabling downstream automation. + * Declarative -- the model describes what correct state + looks like; the runtime figures out how to verify it. + * Extensible -- additional entities, actions, and + constraints can be added without restructuring the model. + + --- Modules used -------------------------------------- + + sys.run Kernel identity fingerprint + os.facts Structured system fact inventory (13 keys) + sys.net Interface enumeration, routing table, + ICMP ping + sys.service Daemon health verification (cross-platform) + + --- Example trace ------------------------------------- + + When executed against a Linux minion the model produces + a trace such as: + + INFOPING uname Linux alien 5.19.0-50-generic... + INFOPING facts 13 facts collected + INFOPING if-list eth0 52:54:00:36:8D:71 + INFOPING route-table default via 192.168.1.1 + INFOPING ping-check 1.1.1.1: sent=2 recv=2 + loss=0% rtt=12.3ms + INFOPING check-sshd running (pid 1234) + INFOPING check-cron running (pid 5678) + INFOPING Constraints 8/8 passed OK + + The constraint engine then compares each structured + fact against the model's declared claims and emits + routable events for any mismatch. + + --- Use cases ----------------------------------------- + + * Pre-deployment host readiness verification + * Continuous compliance auditing in air-gapped sites + * Fleet-wide health snapshot collection via periodic + scheduled checkbook execution + * Troubleshooting triage that answers "is the host + healthy?" before an operator even logs in + +maintainer: SysInspect Developers + +checkbook: + full-scan: + - hardware-profile + - network-profile + - service-profile + +relations: + hardware-profile: + $: + requires: + - system-info + network-profile: + $: + requires: + - net-info + service-profile: + $: + requires: + - svc-info + +entities: + system-info: + descr: Host identity, kernel, CPU, memory, uptime, load averages + claims: + $: + - identity: + kernel-expect: Linux + - resources: + load-warn: "2.0" + mem-free-min-mb: "500" + + net-info: + descr: Network interfaces, routing table, internet connectivity + claims: + $: + - connectivity: + target: 1.1.1.1 + ping-count: 2 + + svc-info: + descr: Critical system daemon health + claims: + $: + - daemons: + sshd: sshd + cron: cron + +actions: + uname: + descr: Full kernel identity fingerprint + module: sys.run + bind: + - system-info + state: + $: + args: + cmd: uname -a + + facts: + descr: Comprehensive system facts inventory + module: os.facts + bind: + - system-info + state: + $: + opts: + - gather + + if-list: + descr: Enumerate active network interfaces + module: sys.net + bind: + - net-info + state: + $: + opts: + - if-up + + route-table: + descr: Read kernel routing table + module: sys.net + bind: + - net-info + state: + $: + opts: + - route-table + + ping-check: + descr: Verify internet reachability via ICMP + module: sys.net + bind: + - net-info + state: + $: + opts: + - ping + args: + host: "claim(connectivity.target)" + count: "claim(connectivity.ping-count)" + + check-sshd: + descr: Verify SSH daemon status + module: sys.service + bind: + - svc-info + state: + $: + opts: + - check + args: + name: "claim(daemons.sshd)" + + check-cron: + descr: Verify cron daemon status + module: sys.service + bind: + - svc-info + state: + $: + opts: + - check + args: + name: "claim(daemons.cron)" + +constraints: + kernel-linux: + descr: Operating system is Linux + entities: + - system-info + all: + $: + - fact: stdout + contains: Linux + + cpu-detected: + descr: CPU model information collected + entities: + - system-info + all: + $: + - fact: cpu_model + matches: ".+" + + memory-detected: + descr: Memory information collected + entities: + - system-info + all: + $: + - fact: memory_total_kb + more: 0 + + network-interface-up: + descr: At least one network interface is active + entities: + - net-info + all: + $: + - fact: if-up + matches: ".+" + + gateway-defined: + descr: Default route gateway is configured + entities: + - net-info + all: + $: + - fact: route-table + matches: "gateway" + + internet-reachable: + descr: Internet connectivity confirmed + entities: + - net-info + all: + $: + - fact: received + more: 0 + + sshd-running: + descr: SSH daemon is operational + entities: + - svc-info + all: + $: + - fact: running + equals: true + + cron-running: + descr: Cron daemon is operational + entities: + - svc-info + all: + $: + - fact: running + equals: true + +events: + $|$|$|$: + handlers: + - console-logger + - outcome-logger + + console-logger: + concise: false + prefix: "INFOPING" + + outcome-logger: + prefix: "INFOPING Constraints" From 616881f47010b3c205f0b153d3a718a45acf0b3a Mon Sep 17 00:00:00 2001 From: Bo Maryniuk Date: Mon, 15 Jun 2026 01:06:03 +0200 Subject: [PATCH 20/22] Add checkbooks to the ui --- libsysinspect/src/console/mod.rs | 3 ++ src/ui/dslbrowser.rs | 90 +++++++++++++++++++++++--------- src/ui/mod.rs | 6 ++- sysmaster/src/console.rs | 4 ++ 4 files changed, 77 insertions(+), 26 deletions(-) diff --git a/libsysinspect/src/console/mod.rs b/libsysinspect/src/console/mod.rs index 177fd29b..aad1094f 100644 --- a/libsysinspect/src/console/mod.rs +++ b/libsysinspect/src/console/mod.rs @@ -343,6 +343,9 @@ pub struct ConsoleModelRow { /// Entrypoint ids (checkbook labels + direct entity ids). #[serde(default)] pub entrypoints: Vec, + /// Entrypoint kind for each entry in `entrypoints`: "checkbook" or "entity". + #[serde(default)] + pub entrypoint_kinds: Vec, /// Declared state names across all actions. #[serde(default)] pub states: Vec, diff --git a/src/ui/dslbrowser.rs b/src/ui/dslbrowser.rs index 5aa754a1..f54e6514 100644 --- a/src/ui/dslbrowser.rs +++ b/src/ui/dslbrowser.rs @@ -24,6 +24,7 @@ pub enum DslFocus { Query, Minions, Models, + Checkbook, Target, State, ContextField(usize), @@ -37,6 +38,7 @@ impl DslFocus { DslFocus::Query, DslFocus::Minions, DslFocus::Models, + DslFocus::Checkbook, DslFocus::Target, DslFocus::State, DslFocus::ContextField(0), @@ -114,7 +116,6 @@ pub struct DslBrowser { pub query: String, pub query_state: InputState, pub models: ListBox, - pub targets: ListBox, pub states: ListBox, pub minions: ListBox, pub context_fields: Vec, @@ -129,6 +130,8 @@ pub struct DslBrowser { catalog_diagnostics: Vec, model_data: Vec, all_minions: Vec, + pub checkbook_labels: ListBox, + pub target_entities: ListBox, } impl DslBrowser { @@ -142,7 +145,6 @@ impl DslBrowser { s }, models: ListBox::new(vec!["(press 'c' to load)".to_string()], 0), - targets: ListBox::new(vec!["—".to_string()], 0), states: ListBox::new(vec!["$".to_string()], 0), minions: ListBox::new((1..=100).map(|i| format!("minion-{i:03}.example.net")).collect(), 0), context_fields: Vec::new(), @@ -157,6 +159,8 @@ impl DslBrowser { catalog_diagnostics: Vec::new(), model_data: Vec::new(), all_minions: Vec::new(), + checkbook_labels: ListBox::new(vec!["—".to_string()], 0), + target_entities: ListBox::new(vec!["—".to_string()], 0), } } @@ -202,22 +206,38 @@ impl DslBrowser { fn update_targets_and_states(&mut self) { self.context_active = false; if let Some(row) = self.resolved_model() { - let mut targets = row.entrypoints.clone(); - if targets.is_empty() { - targets = vec!["(none)".to_string()]; + let mut checkbook: Vec = Vec::new(); + let mut entities: Vec = Vec::new(); + for (i, entrypoint) in row.entrypoints.iter().enumerate() { + let kind = row.entrypoint_kinds.get(i).map(|s| s.as_str()).unwrap_or("entity"); + if kind == "checkbook" { + checkbook.push(entrypoint.clone()); + } else { + entities.push(entrypoint.clone()); + } + } + if checkbook.is_empty() { + checkbook = vec!["(none)".to_string()]; } else { - targets.insert(0, "(select)".to_string()); + checkbook.insert(0, "(select)".to_string()); } - self.targets = ListBox::new(targets, 0); + if entities.is_empty() { + entities = vec!["(none)".to_string()]; + } else { + entities.insert(0, "(select)".to_string()); + } + self.checkbook_labels = ListBox::new(checkbook, 0); + self.target_entities = ListBox::new(entities, 0); self.update_states_for_target(); } else { - self.targets = ListBox::new(vec!["—".to_string()], 0); + self.checkbook_labels = ListBox::new(vec!["—".to_string()], 0); + self.target_entities = ListBox::new(vec!["—".to_string()], 0); self.states = ListBox::new(vec!["$".to_string()], 0); } } fn update_states_for_target(&mut self) { - let target_id = self.targets.items.get(self.targets.selected().unwrap_or(0)).cloned(); + let target_id = self.target_entities.items.get(self.target_entities.selected().unwrap_or(0)).cloned(); let entry = self.resolved_model().and_then(|row| { target_id.as_deref().and_then(|tid| row.target_actions.iter().find(|(id, _)| id == tid).map(|(_, actions)| actions.clone())) }); @@ -249,7 +269,7 @@ impl DslBrowser { } fn ctxfields_update(&mut self) { - let target_id = self.targets.items.get(self.targets.selected().unwrap_or(0)).cloned(); + let target_id = self.target_entities.items.get(self.target_entities.selected().unwrap_or(0)).cloned(); let entry = self.resolved_model().and_then(|row| { target_id.as_deref().and_then(|tid| row.target_actions.iter().find(|(id, _)| id == tid).map(|(_, actions)| actions.clone())) }); @@ -311,8 +331,8 @@ impl DslBrowser { fn column_widths(area: Rect) -> (u16, u16) { let ctx_req = 28u16; let remaining = area.width.saturating_sub(ctx_req); - let box_w = (remaining / 4).max(16); - let ctx_w = area.width.saturating_sub(box_w * 4); + let box_w = (remaining / 5).max(12); + let ctx_w = area.width.saturating_sub(box_w * 5); (box_w, ctx_w) } @@ -355,7 +375,7 @@ impl DslBrowser { } fn build_target_description_unchecked(&self) -> Option { - let target_id = self.targets.items.get(self.targets.selected().unwrap_or(0)).map(|s| s.as_str())?; + let target_id = self.target_entities.items.get(self.target_entities.selected().unwrap_or(0)).map(|s| s.as_str())?; let state_display = self.states.items.get(self.states.selected().unwrap_or(0)).map(|s| s.as_str()).unwrap_or("$"); let state_real = if state_display == "(default)" { "$" } else { state_display }; let row = self.resolved_model()?; @@ -380,6 +400,7 @@ impl DslBrowser { Constraint::Length(box_w), Constraint::Length(box_w), Constraint::Length(box_w), + Constraint::Length(box_w), Constraint::Length(ctx_w), ]) .split(area); @@ -397,9 +418,10 @@ impl DslBrowser { StatefulWidget::render(&inp, Rect::new(chunks[0].x + 7, chunks[0].y, chunks[0].width.saturating_sub(7), 1), buf, &mut qs); write_clipped(buf, chunks[1], chunks[1].x, chunks[1].y, " Models:", Self::s_fl()); - write_clipped(buf, chunks[2], chunks[2].x, chunks[2].y, " Target:", Self::s_fl()); - write_clipped(buf, chunks[3], chunks[3].x, chunks[3].y, " State:", Self::s_fl()); - write_clipped(buf, chunks[4], chunks[4].x, chunks[4].y, " Context:", Self::s_fl()); + write_clipped(buf, chunks[2], chunks[2].x, chunks[2].y, " Checkbook:", Self::s_fl()); + write_clipped(buf, chunks[3], chunks[3].x, chunks[3].y, " Target:", Self::s_fl()); + write_clipped(buf, chunks[4], chunks[4].x, chunks[4].y, " State:", Self::s_fl()); + write_clipped(buf, chunks[5], chunks[5].x, chunks[5].y, " Context:", Self::s_fl()); } fn render_lists(&self, area: Rect, box_w: u16, ctx_w: u16, buf: &mut Buffer) { @@ -410,15 +432,17 @@ impl DslBrowser { Constraint::Length(box_w), Constraint::Length(box_w), Constraint::Length(box_w), + Constraint::Length(box_w), Constraint::Length(ctx_w), ]) .split(area); self.render_list_box(&self.minions, &chunks[0], DslFocus::Minions, buf); self.render_list_box(&self.models, &chunks[1], DslFocus::Models, buf); - self.render_list_box(&self.targets, &chunks[2], DslFocus::Target, buf); - self.render_list_box(&self.states, &chunks[3], DslFocus::State, buf); - self.render_context_inline(chunks[4], buf); + self.render_list_box(&self.checkbook_labels, &chunks[2], DslFocus::Checkbook, buf); + self.render_list_box(&self.target_entities, &chunks[3], DslFocus::Target, buf); + self.render_list_box(&self.states, &chunks[4], DslFocus::State, buf); + self.render_context_inline(chunks[5], buf); } fn render_list_box(&self, lb: &ListBox, area: &Rect, target: DslFocus, buf: &mut Buffer) { @@ -612,8 +636,12 @@ impl DslBrowser { self.update_targets_and_states(); true } + DslFocus::Checkbook => { + handle_list_nav(code, &mut self.checkbook_labels); + true + } DslFocus::Target => { - handle_list_nav(code, &mut self.targets); + handle_list_nav(code, &mut self.target_entities); self.update_states_for_target(); true } @@ -675,8 +703,20 @@ impl DslBrowser { fn build_query(&self) -> Option { let model = self.models.items.get(self.models.selected().unwrap_or(0))?; - let target = self.targets.items.get(self.targets.selected().unwrap_or(0))?; - if model == "(select)" || model == "(no models found)" || target == "(select)" || target == "(none)" || target == "—" { + if model == "(select)" || model == "(no models found)" { + return None; + } + if let Some(cb_sel) = self.checkbook_labels.selected() + && cb_sel > 0 + { + let label = self.checkbook_labels.items.get(cb_sel)?; + if label == "(select)" || label == "(none)" || label == "—" { + return None; + } + return Some(format!("{model}:{label}")); + } + let target = self.target_entities.items.get(self.target_entities.selected().unwrap_or(0))?; + if target == "(select)" || target == "(none)" || target == "—" { return None; } let state_display = self.states.items.get(self.states.selected().unwrap_or(0)).map(|s| s.as_str()).unwrap_or("$"); @@ -701,7 +741,7 @@ impl DslBrowser { if let Some(row) = self.resolved_model() { parts.push(format!("Model: {}\n{}", row.id, row.description)); } - let target_id = self.targets.items.get(self.targets.selected().unwrap_or(0)).map(|s| s.as_str()).unwrap_or(""); + let target_id = self.target_entities.items.get(self.target_entities.selected().unwrap_or(0)).map(|s| s.as_str()).unwrap_or(""); if target_id != "(select)" && target_id != "(none)" && target_id != "—" @@ -756,7 +796,7 @@ impl SysInspectUX { } let model_name = self.dsl_browser.models.items.get(self.dsl_browser.models.selected().unwrap_or(0)).map(|s| s.as_str()).unwrap_or(""); - let target_id = self.dsl_browser.targets.items.get(self.dsl_browser.targets.selected().unwrap_or(0)).map(|s| s.as_str()).unwrap_or(""); + let target_id = self.dsl_browser.target_entities.items.get(self.dsl_browser.target_entities.selected().unwrap_or(0)).map(|s| s.as_str()).unwrap_or(""); let state_display = self.dsl_browser.states.items.get(self.dsl_browser.states.selected().unwrap_or(0)).map(|s| s.as_str()).unwrap_or(""); let has_model = !model_name.is_empty() && model_name != "(select)" && model_name != "(no models found)"; @@ -867,7 +907,7 @@ impl SysInspectUX { } let model_name = self.dsl_browser.models.items.get(self.dsl_browser.models.selected().unwrap_or(0)).map(|s| s.as_str()).unwrap_or("?"); - let target_id = self.dsl_browser.targets.items.get(self.dsl_browser.targets.selected().unwrap_or(0)).map(|s| s.as_str()).unwrap_or(""); + let target_id = self.dsl_browser.target_entities.items.get(self.dsl_browser.target_entities.selected().unwrap_or(0)).map(|s| s.as_str()).unwrap_or(""); let has_target = target_id != "(select)" && target_id != "(none)" && target_id != "—" && !target_id.is_empty(); let block = Block::default() diff --git a/src/ui/mod.rs b/src/ui/mod.rs index 0622f4e0..eedac94c 100644 --- a/src/ui/mod.rs +++ b/src/ui/mod.rs @@ -2775,6 +2775,7 @@ impl SysInspectUX { .into_iter() .map(|m| { let mut entrypoints: Vec = Vec::new(); + let mut entrypoint_kinds: Vec = Vec::new(); #[allow(clippy::type_complexity)] let mut target_actions: Vec<(String, Vec<(String, Vec, Vec<(String, String, bool)>)>)> = Vec::new(); @@ -2782,6 +2783,7 @@ impl SysInspectUX { match ep { libsysinspect::mdescr::browse_types::BrowsedEntrypoint::CheckbookLabel { label, entity_ids, .. } => { entrypoints.push(label.clone()); + entrypoint_kinds.push("checkbook".to_string()); #[allow(clippy::type_complexity)] let actions: Vec<(String, Vec, Vec<(String, String, bool)>)> = m .actions @@ -2797,6 +2799,7 @@ impl SysInspectUX { } libsysinspect::mdescr::browse_types::BrowsedEntrypoint::Entity { id, .. } => { entrypoints.push(id.clone()); + entrypoint_kinds.push("entity".to_string()); #[allow(clippy::type_complexity)] let actions: Vec<(String, Vec, Vec<(String, String, bool)>)> = m .actions @@ -2820,6 +2823,7 @@ impl SysInspectUX { version: m.metadata.version.clone(), description: m.metadata.description.clone(), entrypoints, + entrypoint_kinds, states: m.states.clone(), target_actions, } @@ -3976,7 +3980,7 @@ impl SysInspectUX { } else { let model = self.dsl_browser.models.items.get(self.dsl_browser.models.selected().unwrap_or(0)).map(|s| s.as_str()).unwrap_or(""); let _target = - self.dsl_browser.targets.items.get(self.dsl_browser.targets.selected().unwrap_or(0)).map(|s| s.as_str()).unwrap_or(""); + self.dsl_browser.target_entities.items.get(self.dsl_browser.target_entities.selected().unwrap_or(0)).map(|s| s.as_str()).unwrap_or(""); let missing_keys = std::mem::take(&mut self.dsl_browser.error_required_key); if !missing_keys.is_empty() { self.error_alert_visible = true; diff --git a/sysmaster/src/console.rs b/sysmaster/src/console.rs index 528bc85a..c6bb1ae1 100644 --- a/sysmaster/src/console.rs +++ b/sysmaster/src/console.rs @@ -266,6 +266,7 @@ impl SysMaster { .into_iter() .map(|m| { let mut entrypoints: Vec = Vec::new(); + let mut entrypoint_kinds: Vec = Vec::new(); #[allow(clippy::type_complexity)] let mut target_actions: Vec<(String, Vec<(String, Vec, Vec<(String, String, bool)>)>)> = Vec::new(); @@ -273,6 +274,7 @@ impl SysMaster { match ep { libsysinspect::mdescr::browse_types::BrowsedEntrypoint::CheckbookLabel { label, entity_ids, .. } => { entrypoints.push(label.clone()); + entrypoint_kinds.push("checkbook".to_string()); #[allow(clippy::type_complexity)] let actions: Vec<(String, Vec, Vec<(String, String, bool)>)> = m .actions @@ -288,6 +290,7 @@ impl SysMaster { } libsysinspect::mdescr::browse_types::BrowsedEntrypoint::Entity { id, .. } => { entrypoints.push(id.clone()); + entrypoint_kinds.push("entity".to_string()); #[allow(clippy::type_complexity)] let actions: Vec<(String, Vec, Vec<(String, String, bool)>)> = m .actions @@ -311,6 +314,7 @@ impl SysMaster { version: m.metadata.version.clone(), description: m.metadata.description.clone(), entrypoints, + entrypoint_kinds, states: m.states.clone(), target_actions, } From 8e56f843d94badc951965f4dff49fd142e3a07cb Mon Sep 17 00:00:00 2001 From: Bo Maryniuk Date: Mon, 15 Jun 2026 01:36:03 +0200 Subject: [PATCH 21/22] Fix DSL browser selection conditions --- src/ui/dslbrowser.rs | 280 +++++++++++++++++++++++++++++++++++++++---- src/ui/palette.rs | 3 + src/ui/wgt.rs | 7 +- 3 files changed, 264 insertions(+), 26 deletions(-) diff --git a/src/ui/dslbrowser.rs b/src/ui/dslbrowser.rs index f54e6514..ca17c10f 100644 --- a/src/ui/dslbrowser.rs +++ b/src/ui/dslbrowser.rs @@ -228,6 +228,7 @@ impl DslBrowser { } self.checkbook_labels = ListBox::new(checkbook, 0); self.target_entities = ListBox::new(entities, 0); + self.states = ListBox::new(vec!["$".to_string()], 0); self.update_states_for_target(); } else { self.checkbook_labels = ListBox::new(vec!["—".to_string()], 0); @@ -323,16 +324,26 @@ impl DslBrowser { fn s_fl() -> Style { Style::default().fg(palette::FORM_LABEL) } + fn s_fl_dim() -> Style { + Style::default().fg(palette::FORM_LABEL_DIMMED) + } fn border_style(focus: DslFocus, current: DslFocus) -> Style { + Self::border_style_with_context(focus, current, false) + } + + fn border_style_with_context(focus: DslFocus, current: DslFocus, dimmed: bool) -> Style { + if dimmed { + return Style::default().fg(palette::MUTED); + } if current == focus { Style::default().fg(palette::ACCENT) } else { Style::default().fg(palette::FAINT) } } fn column_widths(area: Rect) -> (u16, u16) { let ctx_req = 28u16; let remaining = area.width.saturating_sub(ctx_req); - let box_w = (remaining / 5).max(12); - let ctx_w = area.width.saturating_sub(box_w * 5); + let box_w = (remaining / 4).max(14); + let ctx_w = area.width.saturating_sub(box_w * 4); (box_w, ctx_w) } @@ -359,7 +370,8 @@ impl DslBrowser { .split(area); let (box_w, ctx_w) = Self::column_widths(area); - self.render_top(rows[0], box_w, ctx_w, buf); + let cb_active = !self.checkbook_labels_is_placeholder(); + self.render_top(rows[0], box_w, ctx_w, cb_active, buf); self.render_lists(rows[1], box_w, ctx_w, buf); self.render_description(rows[2], &visible, has_more_desc, buf); self.render_bottom(rows[3], buf); @@ -392,7 +404,7 @@ impl DslBrowser { } } - fn render_top(&self, area: Rect, box_w: u16, ctx_w: u16, buf: &mut Buffer) { + fn render_top(&self, area: Rect, box_w: u16, ctx_w: u16, cb_active: bool, buf: &mut Buffer) { let chunks = Layout::default() .direction(Direction::Horizontal) .constraints([ @@ -400,7 +412,6 @@ impl DslBrowser { Constraint::Length(box_w), Constraint::Length(box_w), Constraint::Length(box_w), - Constraint::Length(box_w), Constraint::Length(ctx_w), ]) .split(area); @@ -419,9 +430,9 @@ impl DslBrowser { write_clipped(buf, chunks[1], chunks[1].x, chunks[1].y, " Models:", Self::s_fl()); write_clipped(buf, chunks[2], chunks[2].x, chunks[2].y, " Checkbook:", Self::s_fl()); - write_clipped(buf, chunks[3], chunks[3].x, chunks[3].y, " Target:", Self::s_fl()); - write_clipped(buf, chunks[4], chunks[4].x, chunks[4].y, " State:", Self::s_fl()); - write_clipped(buf, chunks[5], chunks[5].x, chunks[5].y, " Context:", Self::s_fl()); + let st_label = if cb_active { Self::s_fl_dim() } else { Self::s_fl() }; + write_clipped(buf, chunks[3], chunks[3].x, chunks[3].y, " State:", st_label); + write_clipped(buf, chunks[4], chunks[4].x, chunks[4].y, " Context:", Self::s_fl()); } fn render_lists(&self, area: Rect, box_w: u16, ctx_w: u16, buf: &mut Buffer) { @@ -432,17 +443,194 @@ impl DslBrowser { Constraint::Length(box_w), Constraint::Length(box_w), Constraint::Length(box_w), - Constraint::Length(box_w), Constraint::Length(ctx_w), ]) .split(area); self.render_list_box(&self.minions, &chunks[0], DslFocus::Minions, buf); self.render_list_box(&self.models, &chunks[1], DslFocus::Models, buf); - self.render_list_box(&self.checkbook_labels, &chunks[2], DslFocus::Checkbook, buf); - self.render_list_box(&self.target_entities, &chunks[3], DslFocus::Target, buf); - self.render_list_box(&self.states, &chunks[4], DslFocus::State, buf); - self.render_context_inline(chunks[5], buf); + self.render_checkbook_target_combo(&chunks[2], buf); + + let state_dimmed = !self.checkbook_labels_is_placeholder(); + self.render_list_box_dim(&self.states, &chunks[3], DslFocus::State, state_dimmed, buf); + + self.render_context_inline(chunks[4], buf); + } + + fn render_list_box_dim(&self, lb: &ListBox, area: &Rect, target: DslFocus, dimmed: bool, buf: &mut Buffer) { + let is_minions = matches!(target, DslFocus::Minions); + let block = Block::default() + .borders(Borders::ALL) + .border_type(BorderType::Rounded) + .border_style(Self::border_style_with_context(self.focus, target, dimmed)); + let inner = block.inner(*area); + block.render(*area, buf); + + let list_h = inner.height as usize; + let total = lb.items.len(); + if let Some(sel) = lb.state.selected() { + let mut off = lb.scroll.get(); + if sel < off { + off = sel; + } else if sel >= off.saturating_add(list_h) { + off = sel.saturating_sub(list_h.saturating_sub(1)); + } + lb.scroll.set(off.min(total.saturating_sub(list_h))); + } + let offset = lb.scroll.get().min(total.saturating_sub(list_h)); + + let visible: Vec = lb.items.iter().skip(offset).take(list_h).map(|s| ListItem::new(s.as_str())).collect(); + let focused = self.focus == target; + let is_placeholder = lb.items.get(lb.state.selected().unwrap_or(0)).is_some_and(|s| s == "(select)"); + let hl = if dimmed { + Style::default().fg(palette::MUTED).bg(palette::GRAY_0) + } else if is_placeholder && !focused { + Style::default().fg(palette::MUTED).bg(palette::GRAY_0) + } else if is_minions { + if focused { Style::default().fg(palette::SECONDARY) } else { Style::default() } + } else if focused { + Self::s_hl() + } else { + Self::s_hl_dim() + }; + let mut list = List::new(visible).highlight_style(hl); + if dimmed { + list = list.style(Style::default().fg(palette::MUTED)); + } + let mut ls = ListState::default(); + if focused { + if let Some(sel) = lb.state.selected() { + ls.select(Some(sel.saturating_sub(offset))); + } + } else if !is_minions && !dimmed && let Some(sel) = lb.state.selected() { + ls.select(Some(sel.saturating_sub(offset))); + } + let list_area = Rect::new(inner.x, inner.y, inner.width.saturating_sub(2), inner.height); + StatefulWidget::render(list, list_area, buf, &mut ls); + + if inner.width >= 2 && list_h > 0 { + let sb_x = inner.right().saturating_sub(1); + let mut sb_state = ScrollbarState::new(total).position(offset); + Scrollbar::default() + .orientation(ScrollbarOrientation::VerticalRight) + .begin_symbol(None) + .end_symbol(None) + .track_symbol(Some("\u{28FF}")) + .thumb_symbol("█") + .track_style(Style::default().bg(palette::BG_2)) + .thumb_style(Style::default().fg(palette::GRAY_1)) + .render(Rect::new(sb_x, inner.y, 1, inner.height), buf, &mut sb_state); + } + } + + fn render_checkbook_target_combo(&self, area: &Rect, buf: &mut Buffer) { + if area.height < 4 { + return; + } + let label_h = 1u16; + let available = area.height.saturating_sub(label_h); + let cb_h = available / 2; + let target_h = available.saturating_sub(cb_h); + + let parts = Layout::default() + .direction(Direction::Vertical) + .constraints([Constraint::Length(cb_h), Constraint::Length(label_h), Constraint::Length(target_h)]) + .split(*area); + + let cb_focus = self.focus == DslFocus::Checkbook; + let tgt_focus = self.focus == DslFocus::Target; + let cb_active = !self.checkbook_labels_is_placeholder(); + let tgt_active = !self.target_entities_is_placeholder(); + let cb_blocked = tgt_active; + let tgt_blocked = cb_active; + + // Checkbook box + let cb_border = if cb_blocked { + Style::default().fg(palette::MUTED) + } else { + Self::border_style(self.focus, DslFocus::Checkbook) + }; + let cb_block = Block::default().borders(Borders::ALL).border_type(BorderType::Rounded).border_style(cb_border); + let cb_inner = cb_block.inner(parts[0]); + cb_block.render(parts[0], buf); + self.render_list_items(&self.checkbook_labels, &cb_inner, cb_focus, cb_blocked, buf); + + // "Target:" label + let tgt_label = if cb_active { Self::s_fl_dim() } else { Self::s_fl() }; + write_clipped(buf, parts[1], parts[1].x, parts[1].y, " Target:", tgt_label); + + // Target box + let tgt_border = if tgt_blocked { + Style::default().fg(palette::MUTED) + } else { + Self::border_style(self.focus, DslFocus::Target) + }; + let tgt_block = Block::default().borders(Borders::ALL).border_type(BorderType::Rounded).border_style(tgt_border); + let tgt_inner = tgt_block.inner(parts[2]); + tgt_block.render(parts[2], buf); + self.render_list_items(&self.target_entities, &tgt_inner, tgt_focus, tgt_blocked, buf); + } + + fn checkbook_labels_is_placeholder(&self) -> bool { + self.checkbook_labels.items.get(self.checkbook_labels.selected().unwrap_or(0)) + .is_some_and(|s| s == "(select)" || s == "(none)" || s == "—") + } + + fn target_entities_is_placeholder(&self) -> bool { + self.target_entities.items.get(self.target_entities.selected().unwrap_or(0)) + .is_some_and(|s| s == "(select)" || s == "(none)" || s == "—") + } + + fn render_list_items(&self, lb: &ListBox, area: &Rect, focused: bool, dimmed: bool, buf: &mut Buffer) { + let list_h = area.height as usize; + let total = lb.items.len(); + if let Some(sel) = lb.state.selected() { + let mut off = lb.scroll.get(); + if sel < off { + off = sel; + } else if sel >= off.saturating_add(list_h) { + off = sel.saturating_sub(list_h.saturating_sub(1)); + } + lb.scroll.set(off.min(total.saturating_sub(list_h))); + } + let offset = lb.scroll.get().min(total.saturating_sub(list_h)); + + let visible: Vec = lb.items.iter().skip(offset).take(list_h).map(|s| ListItem::new(s.as_str())).collect(); + let is_placeholder = lb.items.get(lb.state.selected().unwrap_or(0)).is_some_and(|s| s == "(select)"); + let hl = if dimmed { + Style::default().fg(palette::MUTED).bg(palette::GRAY_0) + } else if is_placeholder && !focused { + Style::default().fg(palette::MUTED).bg(palette::GRAY_0) + } else if focused { + Self::s_hl() + } else { + Self::s_hl_dim() + }; + let mut list = List::new(visible).highlight_style(hl); + if dimmed { + list = list.style(Style::default().fg(palette::MUTED)); + } + let mut ls = ListState::default(); + if focused || (!focused && !dimmed) { + if let Some(sel) = lb.state.selected() { + ls.select(Some(sel.saturating_sub(offset))); + } + } + StatefulWidget::render(list, *area, buf, &mut ls); + + if area.width >= 2 && list_h > 0 { + let sb_x = area.right().saturating_sub(1); + let mut sb_state = ScrollbarState::new(total).position(offset); + Scrollbar::default() + .orientation(ScrollbarOrientation::VerticalRight) + .begin_symbol(None) + .end_symbol(None) + .track_symbol(Some("\u{28FF}")) + .thumb_symbol("█") + .track_style(Style::default().bg(palette::BG_2)) + .thumb_style(Style::default().fg(palette::GRAY_1)) + .render(Rect::new(sb_x, area.y, 1, area.height), buf, &mut sb_state); + } } fn render_list_box(&self, lb: &ListBox, area: &Rect, target: DslFocus, buf: &mut Buffer) { @@ -584,19 +772,11 @@ impl DslBrowser { return true; } KeyCode::Tab => { - let mut next = self.focus.next(); - if matches!(next, DslFocus::ContextField(_)) && !self.context_active { - next = DslFocus::Call; - } - self.focus = next; + self.focus = self.next_focus(); return true; } KeyCode::BackTab => { - let mut prev = self.focus.prev(); - if matches!(prev, DslFocus::ContextField(_)) && !self.context_active { - prev = DslFocus::State; - } - self.focus = prev; + self.focus = self.prev_focus(); return true; } KeyCode::Char('d') if !matches!(self.focus, DslFocus::Query | DslFocus::ContextField(_)) && self.resolved_model().is_some() => { @@ -701,6 +881,42 @@ impl DslBrowser { self.model_data_index().and_then(|i| self.model_data.get(i)) } + fn next_focus(&self) -> DslFocus { + let mut f = self.focus.next(); + if !self.checkbook_labels_is_placeholder() { + while matches!(f, DslFocus::Target | DslFocus::State) { + f = f.next(); + } + } + if !self.target_entities_is_placeholder() { + while matches!(f, DslFocus::Checkbook) { + f = f.next(); + } + } + if matches!(f, DslFocus::ContextField(_)) && !self.context_active { + f = DslFocus::Call; + } + f + } + + fn prev_focus(&self) -> DslFocus { + let mut f = self.focus.prev(); + if !self.checkbook_labels_is_placeholder() { + while matches!(f, DslFocus::Target | DslFocus::State) { + f = f.prev(); + } + } + if !self.target_entities_is_placeholder() { + while matches!(f, DslFocus::Checkbook) { + f = f.prev(); + } + } + if matches!(f, DslFocus::ContextField(_)) && !self.context_active { + f = DslFocus::State; + } + f + } + fn build_query(&self) -> Option { let model = self.models.items.get(self.models.selected().unwrap_or(0))?; if model == "(select)" || model == "(no models found)" { @@ -796,10 +1012,18 @@ impl SysInspectUX { } let model_name = self.dsl_browser.models.items.get(self.dsl_browser.models.selected().unwrap_or(0)).map(|s| s.as_str()).unwrap_or(""); + let cb_label = self + .dsl_browser + .checkbook_labels + .items + .get(self.dsl_browser.checkbook_labels.selected().unwrap_or(0)) + .map(|s| s.as_str()) + .unwrap_or(""); let target_id = self.dsl_browser.target_entities.items.get(self.dsl_browser.target_entities.selected().unwrap_or(0)).map(|s| s.as_str()).unwrap_or(""); let state_display = self.dsl_browser.states.items.get(self.dsl_browser.states.selected().unwrap_or(0)).map(|s| s.as_str()).unwrap_or(""); let has_model = !model_name.is_empty() && model_name != "(select)" && model_name != "(no models found)"; + let has_checkbook = !cb_label.is_empty() && cb_label != "(select)" && cb_label != "(none)" && cb_label != "—"; let has_target = !target_id.is_empty() && target_id != "(select)" && target_id != "(none)" && target_id != "—"; let has_state = !state_display.is_empty() && state_display != "(select)" && state_display != "(default)" && state_display != "$"; @@ -826,8 +1050,16 @@ impl SysInspectUX { modifier: Modifier::empty(), }); } + if has_checkbook { + title_segments.push(TitleSegment { + text: format!(" {cb_label} "), + bg: peak_bg, + fg: palette::BG_2, + modifier: Modifier::empty(), + }); + } if has_target { - title_segments.push(TitleSegment { text: format!(" {target_id} "), bg: peak_bg, fg: palette::BG_2, modifier: Modifier::empty() }); + title_segments.push(TitleSegment { text: format!(" {target_id} "), bg: proc_bg, fg: palette::BG_3, modifier: Modifier::empty() }); } if has_state { title_segments.push(TitleSegment { text: format!(" {state_display} "), bg: proc_bg, fg: palette::BG_3, modifier: Modifier::empty() }); diff --git a/src/ui/palette.rs b/src/ui/palette.rs index a2019c2c..77d1255d 100644 --- a/src/ui/palette.rs +++ b/src/ui/palette.rs @@ -120,6 +120,9 @@ pub const SUCCESS: Color = Color::Indexed(49); /// Form labels / structured input captions. pub const FORM_LABEL: Color = SUCCESS_PEAK; +/// Form labels when the associated control is disabled. +pub const FORM_LABEL_DIMMED: Color = SUCCESS_BASE; + /// Work in progress / active processing. pub const PROCESSING: Color = Color::Indexed(171); diff --git a/src/ui/wgt.rs b/src/ui/wgt.rs index c9417580..55034fd8 100644 --- a/src/ui/wgt.rs +++ b/src/ui/wgt.rs @@ -12,6 +12,7 @@ use ratatui::{ Block, BorderType, Borders, Cell, List, ListItem, ListState, Paragraph, Row, Scrollbar, ScrollbarState, StatefulWidget, Table, Widget, }, }; +use ratatui_glamour::color::lerp_color; impl SysInspectUX { /// Render information box where data from the selected event is displayed @@ -25,6 +26,8 @@ impl SysInspectUX { let rule_fill = Style::default().fg(palette::PROCESSING_BASE); let rule_title = Style::default().fg(palette::PROCESSING).add_modifier(Modifier::BOLD); + let grad_start = palette::PRIMARY; + let grad_end = palette::PROCESSING_DIMMED; let evt = match self.get_selected_event() { Some(eli) => eli, @@ -53,7 +56,7 @@ impl SysInspectUX { .as_ref() .try_into() .unwrap(); - render_rule_line(gen_title_area, buf, "General", rule_title, rule_fill); + render_rule_line(gen_title_area, buf, "General", rule_title, grad_start, grad_end); Widget::render(Table::new(info_rows, &[Constraint::Length(15), Constraint::Min(0)]).column_spacing(1), gen_table_area, buf); let [_, det_title_area, det_content_area]: [Rect; 3] = Layout::default() @@ -91,7 +94,7 @@ impl SysInspectUX { .as_ref() .try_into() .unwrap(); - render_rule_line(gen_title_area, buf, "General", rule_title, rule_fill); + render_rule_line(gen_title_area, buf, "General", rule_title, grad_start, grad_end); Widget::render(Table::new(info_rows, &[Constraint::Length(15), Constraint::Min(0)]).column_spacing(1), gen_table_area, buf); } } From f2ad5ff743006fec93175a002b5661bc5613e1d8 Mon Sep 17 00:00:00 2001 From: Bo Maryniuk Date: Mon, 15 Jun 2026 01:43:02 +0200 Subject: [PATCH 22/22] Cleanup and lintfixes --- libsysinspect/src/intp/actproc/modfinder.rs | 20 ++----- src/ui/dslbrowser.rs | 61 +++++++-------------- src/ui/mod.rs | 9 ++- src/ui/wgt.rs | 12 ++-- 4 files changed, 40 insertions(+), 62 deletions(-) diff --git a/libsysinspect/src/intp/actproc/modfinder.rs b/libsysinspect/src/intp/actproc/modfinder.rs index 5358cfd4..6008dc4a 100644 --- a/libsysinspect/src/intp/actproc/modfinder.rs +++ b/libsysinspect/src/intp/actproc/modfinder.rs @@ -459,10 +459,7 @@ impl ModCall { Ok(v) => v, Err(e) => { log::debug!("STDOUT (raw): {raw_out}"); - return Err(SysinspectError::ModuleError(format!( - "Module '{}' returned invalid JSON: {e}", - self.module.display() - ))); + return Err(SysinspectError::ModuleError(format!("Module '{}' returned invalid JSON: {e}", self.module.display()))); } }; if let Some(obj) = v.as_object() { @@ -486,10 +483,7 @@ impl ModCall { Ok(r) => r, Err(e) => { log::debug!("STDOUT (raw): {raw_out}"); - return Err(SysinspectError::ModuleError(format!( - "Module '{}' response format error: {e}", - self.module.display() - ))); + return Err(SysinspectError::ModuleError(format!("Module '{}' response format error: {e}", self.module.display()))); } }; let mut data = r.clone(); @@ -533,10 +527,7 @@ impl ModCall { Ok(v) => v, Err(e) => { log::debug!("STDOUT (raw): {raw_out}"); - return Err(SysinspectError::ModuleError(format!( - "Module '{}' returned invalid JSON: {e}", - self.module.display() - ))); + return Err(SysinspectError::ModuleError(format!("Module '{}' returned invalid JSON: {e}", self.module.display()))); } }; if let Some(obj) = v.as_object() { @@ -560,10 +551,7 @@ impl ModCall { Ok(r) => r, Err(e) => { log::debug!("STDOUT (raw): {raw_out}"); - return Err(SysinspectError::ModuleError(format!( - "Module '{}' response format error: {e}", - self.module.display() - ))); + return Err(SysinspectError::ModuleError(format!("Module '{}' response format error: {e}", self.module.display()))); } }; let mut data = r.clone(); diff --git a/src/ui/dslbrowser.rs b/src/ui/dslbrowser.rs index ca17c10f..bccebc82 100644 --- a/src/ui/dslbrowser.rs +++ b/src/ui/dslbrowser.rs @@ -482,9 +482,7 @@ impl DslBrowser { let visible: Vec = lb.items.iter().skip(offset).take(list_h).map(|s| ListItem::new(s.as_str())).collect(); let focused = self.focus == target; let is_placeholder = lb.items.get(lb.state.selected().unwrap_or(0)).is_some_and(|s| s == "(select)"); - let hl = if dimmed { - Style::default().fg(palette::MUTED).bg(palette::GRAY_0) - } else if is_placeholder && !focused { + let hl = if dimmed || (is_placeholder && !focused) { Style::default().fg(palette::MUTED).bg(palette::GRAY_0) } else if is_minions { if focused { Style::default().fg(palette::SECONDARY) } else { Style::default() } @@ -502,7 +500,10 @@ impl DslBrowser { if let Some(sel) = lb.state.selected() { ls.select(Some(sel.saturating_sub(offset))); } - } else if !is_minions && !dimmed && let Some(sel) = lb.state.selected() { + } else if !is_minions + && !dimmed + && let Some(sel) = lb.state.selected() + { ls.select(Some(sel.saturating_sub(offset))); } let list_area = Rect::new(inner.x, inner.y, inner.width.saturating_sub(2), inner.height); @@ -545,11 +546,7 @@ impl DslBrowser { let tgt_blocked = cb_active; // Checkbook box - let cb_border = if cb_blocked { - Style::default().fg(palette::MUTED) - } else { - Self::border_style(self.focus, DslFocus::Checkbook) - }; + let cb_border = if cb_blocked { Style::default().fg(palette::MUTED) } else { Self::border_style(self.focus, DslFocus::Checkbook) }; let cb_block = Block::default().borders(Borders::ALL).border_type(BorderType::Rounded).border_style(cb_border); let cb_inner = cb_block.inner(parts[0]); cb_block.render(parts[0], buf); @@ -560,11 +557,7 @@ impl DslBrowser { write_clipped(buf, parts[1], parts[1].x, parts[1].y, " Target:", tgt_label); // Target box - let tgt_border = if tgt_blocked { - Style::default().fg(palette::MUTED) - } else { - Self::border_style(self.focus, DslFocus::Target) - }; + let tgt_border = if tgt_blocked { Style::default().fg(palette::MUTED) } else { Self::border_style(self.focus, DslFocus::Target) }; let tgt_block = Block::default().borders(Borders::ALL).border_type(BorderType::Rounded).border_style(tgt_border); let tgt_inner = tgt_block.inner(parts[2]); tgt_block.render(parts[2], buf); @@ -572,13 +565,11 @@ impl DslBrowser { } fn checkbook_labels_is_placeholder(&self) -> bool { - self.checkbook_labels.items.get(self.checkbook_labels.selected().unwrap_or(0)) - .is_some_and(|s| s == "(select)" || s == "(none)" || s == "—") + self.checkbook_labels.items.get(self.checkbook_labels.selected().unwrap_or(0)).is_some_and(|s| s == "(select)" || s == "(none)" || s == "—") } fn target_entities_is_placeholder(&self) -> bool { - self.target_entities.items.get(self.target_entities.selected().unwrap_or(0)) - .is_some_and(|s| s == "(select)" || s == "(none)" || s == "—") + self.target_entities.items.get(self.target_entities.selected().unwrap_or(0)).is_some_and(|s| s == "(select)" || s == "(none)" || s == "—") } fn render_list_items(&self, lb: &ListBox, area: &Rect, focused: bool, dimmed: bool, buf: &mut Buffer) { @@ -597,9 +588,7 @@ impl DslBrowser { let visible: Vec = lb.items.iter().skip(offset).take(list_h).map(|s| ListItem::new(s.as_str())).collect(); let is_placeholder = lb.items.get(lb.state.selected().unwrap_or(0)).is_some_and(|s| s == "(select)"); - let hl = if dimmed { - Style::default().fg(palette::MUTED).bg(palette::GRAY_0) - } else if is_placeholder && !focused { + let hl = if dimmed || (is_placeholder && !focused) { Style::default().fg(palette::MUTED).bg(palette::GRAY_0) } else if focused { Self::s_hl() @@ -611,10 +600,10 @@ impl DslBrowser { list = list.style(Style::default().fg(palette::MUTED)); } let mut ls = ListState::default(); - if focused || (!focused && !dimmed) { - if let Some(sel) = lb.state.selected() { - ls.select(Some(sel.saturating_sub(offset))); - } + if (focused || !dimmed) + && let Some(sel) = lb.state.selected() + { + ls.select(Some(sel.saturating_sub(offset))); } StatefulWidget::render(list, *area, buf, &mut ls); @@ -1012,14 +1001,10 @@ impl SysInspectUX { } let model_name = self.dsl_browser.models.items.get(self.dsl_browser.models.selected().unwrap_or(0)).map(|s| s.as_str()).unwrap_or(""); - let cb_label = self - .dsl_browser - .checkbook_labels - .items - .get(self.dsl_browser.checkbook_labels.selected().unwrap_or(0)) - .map(|s| s.as_str()) - .unwrap_or(""); - let target_id = self.dsl_browser.target_entities.items.get(self.dsl_browser.target_entities.selected().unwrap_or(0)).map(|s| s.as_str()).unwrap_or(""); + let cb_label = + self.dsl_browser.checkbook_labels.items.get(self.dsl_browser.checkbook_labels.selected().unwrap_or(0)).map(|s| s.as_str()).unwrap_or(""); + let target_id = + self.dsl_browser.target_entities.items.get(self.dsl_browser.target_entities.selected().unwrap_or(0)).map(|s| s.as_str()).unwrap_or(""); let state_display = self.dsl_browser.states.items.get(self.dsl_browser.states.selected().unwrap_or(0)).map(|s| s.as_str()).unwrap_or(""); let has_model = !model_name.is_empty() && model_name != "(select)" && model_name != "(no models found)"; @@ -1051,12 +1036,7 @@ impl SysInspectUX { }); } if has_checkbook { - title_segments.push(TitleSegment { - text: format!(" {cb_label} "), - bg: peak_bg, - fg: palette::BG_2, - modifier: Modifier::empty(), - }); + title_segments.push(TitleSegment { text: format!(" {cb_label} "), bg: peak_bg, fg: palette::BG_2, modifier: Modifier::empty() }); } if has_target { title_segments.push(TitleSegment { text: format!(" {target_id} "), bg: proc_bg, fg: palette::BG_3, modifier: Modifier::empty() }); @@ -1139,7 +1119,8 @@ impl SysInspectUX { } let model_name = self.dsl_browser.models.items.get(self.dsl_browser.models.selected().unwrap_or(0)).map(|s| s.as_str()).unwrap_or("?"); - let target_id = self.dsl_browser.target_entities.items.get(self.dsl_browser.target_entities.selected().unwrap_or(0)).map(|s| s.as_str()).unwrap_or(""); + let target_id = + self.dsl_browser.target_entities.items.get(self.dsl_browser.target_entities.selected().unwrap_or(0)).map(|s| s.as_str()).unwrap_or(""); let has_target = target_id != "(select)" && target_id != "(none)" && target_id != "—" && !target_id.is_empty(); let block = Block::default() diff --git a/src/ui/mod.rs b/src/ui/mod.rs index eedac94c..ddacf0d9 100644 --- a/src/ui/mod.rs +++ b/src/ui/mod.rs @@ -3979,8 +3979,13 @@ impl SysInspectUX { }); } else { let model = self.dsl_browser.models.items.get(self.dsl_browser.models.selected().unwrap_or(0)).map(|s| s.as_str()).unwrap_or(""); - let _target = - self.dsl_browser.target_entities.items.get(self.dsl_browser.target_entities.selected().unwrap_or(0)).map(|s| s.as_str()).unwrap_or(""); + let _target = self + .dsl_browser + .target_entities + .items + .get(self.dsl_browser.target_entities.selected().unwrap_or(0)) + .map(|s| s.as_str()) + .unwrap_or(""); let missing_keys = std::mem::take(&mut self.dsl_browser.error_required_key); if !missing_keys.is_empty() { self.error_alert_visible = true; diff --git a/src/ui/wgt.rs b/src/ui/wgt.rs index 55034fd8..6e6e82d5 100644 --- a/src/ui/wgt.rs +++ b/src/ui/wgt.rs @@ -24,7 +24,6 @@ impl SysInspectUX { Widget::render(&block, rect, buf); let inner = block.inner(rect); - let rule_fill = Style::default().fg(palette::PROCESSING_BASE); let rule_title = Style::default().fg(palette::PROCESSING).add_modifier(Modifier::BOLD); let grad_start = palette::PRIMARY; let grad_end = palette::PROCESSING_DIMMED; @@ -66,7 +65,7 @@ impl SysInspectUX { .as_ref() .try_into() .unwrap(); - render_rule_line(det_title_area, buf, "Details", rule_title, rule_fill); + render_rule_line(det_title_area, buf, "Details", rule_title, grad_start, grad_end); let ex_nfo_parts = Layout::default().direction(Direction::Horizontal).constraints([Constraint::Min(0), Constraint::Length(1)]).split(det_content_area); @@ -254,7 +253,9 @@ impl SysInspectUX { /// Render a decorated rule line: ` Title ////////////////////////////////` /// with one leading space and dash fill to end of area, minus one trailing space. -pub(crate) fn render_rule_line(area: Rect, buf: &mut Buffer, title: &str, title_style: Style, fill_style: Style) { +pub(crate) fn render_rule_line( + area: Rect, buf: &mut Buffer, title: &str, title_style: Style, grad_start: ratatui::style::Color, grad_end: ratatui::style::Color, +) { if area.width < 6 { return; } @@ -264,8 +265,11 @@ pub(crate) fn render_rule_line(area: Rect, buf: &mut Buffer, title: &str, title_ let fill_start = area.x.saturating_add(label_w); let fill_end = area.right().saturating_sub(1); + let fill_len = (fill_end.saturating_sub(fill_start)).max(1) as f64; for x in fill_start..fill_end.min(fill_start.saturating_add(area.width)) { - buf.set_string(x, area.y, "/", fill_style); + let t = (x.saturating_sub(fill_start)) as f64 / fill_len; + let color = lerp_color(grad_start, grad_end, t as f32); + buf.set_string(x, area.y, "/", Style::default().fg(color)); } }