From 98f9ee11d7c12cbef6c8832906a33d8b41932334 Mon Sep 17 00:00:00 2001 From: Bo Maryniuk Date: Mon, 15 Jun 2026 23:50:05 +0200 Subject: [PATCH 01/15] Add first iteration scaffold of upgrade workflow --- libsysinspect/src/console/mod.rs | 40 +++++ libsysproto/src/query.rs | 12 ++ src/clifmt.rs | 17 +++ src/ui/alert.rs | 38 +++++ src/ui/macts.rs | 12 +- src/ui/mod.rs | 228 ++++++++++++++++++++++++++-- src/ui/online.rs | 18 ++- src/ui/wgt.rs | 1 + sysmaster/src/console.rs | 251 ++++++++++++++++++++++++++++--- sysmaster/src/master.rs | 3 + sysmaster/src/registry/mreg.rs | 131 ++++++++++++++++ sysminion/src/main.rs | 2 +- sysminion/src/minion.rs | 136 ++++++++++++++--- 13 files changed, 820 insertions(+), 69 deletions(-) diff --git a/libsysinspect/src/console/mod.rs b/libsysinspect/src/console/mod.rs index 0ab466cb..3fced3d0 100644 --- a/libsysinspect/src/console/mod.rs +++ b/libsysinspect/src/console/mod.rs @@ -119,6 +119,17 @@ pub struct ConsoleMinionProcessSignalRequest { pub signal: i32, } +/// Request parameters for a minion self-upgrade via the master fileserver. +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct ConsoleMinionUpgradeSelfRequest { + /// Relative subpath of the new binary under the master fileserver root. + pub subpath: String, + /// Expected SHA-256 checksum of the new binary. + pub checksum: String, + /// Version label of the new binary. + pub version: String, +} + fn default_top_process_limit() -> usize { 24 } @@ -336,6 +347,29 @@ pub enum ConsolePayload { #[serde(default)] failures: Vec, }, + /// Current cluster upgrade counts derived from master-side Sled markers. + UpgradeStatus { + /// Number of minions still marked as requiring upgrade/sync. + required: usize, + /// Number of marked minions last seen as unreachable during upgrade. + unreachable: usize, + }, + /// Result summary for one cluster upgrade run. + UpgradeSummary { + /// Number of offline minions upgraded successfully over SSH. + updated: usize, + /// Number of online minions that received a self-upgrade command. + dispatched: usize, + /// Number of minions skipped because they were unavailable or unmanaged. + skipped: usize, + /// Number of upgrade attempts that failed unexpectedly. + failed: usize, + /// Number of minions that were offline and had no SSH fallback. + offline: usize, + /// Human-readable details for skipped or failed minions. + #[serde(default)] + items: Vec, + }, } /// One online-minion summary row returned by the master. @@ -363,6 +397,12 @@ pub struct ConsoleOnlineMinionRow { /// Whether the current runtime version is older than the matching repository version. #[serde(default)] pub outdated: bool, + /// Whether this minion is currently marked in the master's upgrade queue. + #[serde(default)] + pub upgrade_required: bool, + /// Whether the latest upgrade attempt marked this minion unreachable. + #[serde(default)] + pub upgrade_unreachable: bool, /// OS distribution reported by minion traits, if present. #[serde(default)] pub os_distribution: String, diff --git a/libsysproto/src/query.rs b/libsysproto/src/query.rs index eaeb7d18..97acbd84 100644 --- a/libsysproto/src/query.rs +++ b/libsysproto/src/query.rs @@ -68,6 +68,18 @@ pub mod commands { // Send a Unix signal to one process on one specific minion pub const CLUSTER_MINION_PROCESS_SIGNAL: &str = "cluster/minion/process/signal"; + // Mark all selected minions as requiring a cluster upgrade/sync + pub const CLUSTER_MARK_UPGRADE_REQUIRED: &str = "cluster/upgrade/mark"; + + // Run cluster upgrade/sync across marked minions + pub const CLUSTER_UPGRADE_MINIONS: &str = "cluster/upgrade/run"; + + // Read current cluster upgrade status counts + pub const CLUSTER_UPGRADE_STATUS: &str = "cluster/upgrade/status"; + + // Ask a minion to download, verify, and replace its own binary + pub const CLUSTER_MINION_UPGRADE_SELF: &str = "cluster/minion/upgrade/self"; + // Force all online minions to reconnect (cluster-wide broadcast) pub const CLUSTER_RECONNECT: &str = "cluster/reconnect"; diff --git a/src/clifmt.rs b/src/clifmt.rs index aca0e31e..aa009184 100644 --- a/src/clifmt.rs +++ b/src/clifmt.rs @@ -416,5 +416,22 @@ pub fn render_console_payload(payload: &ConsolePayload) -> String { ConsolePayload::MasterLogs { snapshot: _ } => String::new(), ConsolePayload::MasterModuleIndex { .. } => String::new(), ConsolePayload::MasterLibraryIndex { .. } => String::new(), + ConsolePayload::UpgradeStatus { required, unreachable } => { + format!("Cluster upgrade status: required={required}, unreachable={unreachable}") + } + ConsolePayload::UpgradeSummary { updated, dispatched, skipped, failed, offline, items } => { + let mut out = vec![ + format!("SSH upgraded: {updated}"), + format!("Dispatched to online: {dispatched}"), + format!("Skipped: {skipped}"), + format!("Failed: {failed}"), + format!("Offline: {offline}"), + ]; + if !items.is_empty() { + out.push(String::new()); + out.extend(items.clone()); + } + out.join("\n") + } } } diff --git a/src/ui/alert.rs b/src/ui/alert.rs index 7f8b71bc..87f63527 100644 --- a/src/ui/alert.rs +++ b/src/ui/alert.rs @@ -321,6 +321,44 @@ impl SysInspectUX { Self::draw_popup_shadow(buf, canvas, height); } + pub fn dialog_cluster_upgrade_progress(&self, parent: Rect, buf: &mut Buffer) { + if !self.cluster_upgrade_progress.visible { + return; + } + + let text = Line::from(vec![Span::styled( + format!("{} {}", self.cluster_upgrade_progress.spinner.view(), self.cluster_upgrade_progress.message), + Style::default().fg(palette::FG), + )]); + let width = (UnicodeWidthStr::width(self.cluster_upgrade_progress.message.as_str()) as u16 + 12).max(48); + 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::WARNING_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) { if !self.master_confirm_visible { return; diff --git a/src/ui/macts.rs b/src/ui/macts.rs index 53f8d0cb..46ce9e0a 100644 --- a/src/ui/macts.rs +++ b/src/ui/macts.rs @@ -32,7 +32,13 @@ 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", "^A")], + items: &[ + ("View master logs online", "^O"), + ("View local logs", "^L"), + ("Register a minion", "^R"), + ("Artefacts Manager", "^A"), + ("Cluster upgrade", "^U"), + ], }, MenuSection { title: "System", items: &[("Start", "^T"), ("Stop", "^S"), ("Restart", "^E")] }, ]; @@ -222,7 +228,7 @@ impl SysInspectUX { let max_item_w = max_label_w + 20; let title_style = TitleStyle::cyberpunk(palette::PROCESSING_GLOW); - let is_system = self.master_menu_sel >= 4; + let is_system = self.master_menu_sel >= 5; let sub_title = if is_system { " System " } else { " Operations " }; let segments = vec![ TitleSegment { text: " Master ".into(), bg: palette::PROCESSING_GLOW, fg: palette::FG, modifier: Modifier::empty() }, @@ -230,7 +236,7 @@ impl SysInspectUX { ]; let local_logs_available = self.cfg.logfile_std().exists() || self.cfg.logfile_err().exists(); - let disabled = [!local_logs_available, false, false, false, false, false, false]; + let disabled = [!local_logs_available, false, false, false, false, false, false, false]; render_menu_popup(parent, buf, MASTER_MENU_SECTIONS, self.master_menu_sel, &segments, &title_style, max_item_w, &disabled); } diff --git a/src/ui/mod.rs b/src/ui/mod.rs index d74bc574..78c7e06d 100644 --- a/src/ui/mod.rs +++ b/src/ui/mod.rs @@ -22,9 +22,10 @@ use libsysinspect::{ use libsysproto::query::{ SCHEME_COMMAND, commands::{ - CLUSTER_CONFIG_RELOAD, CLUSTER_LIBRARY_INDEX, CLUSTER_MASTER_LOGS, CLUSTER_MINION_HOPSTART, CLUSTER_MINION_INFO, CLUSTER_MINION_LOGS, - CLUSTER_MINION_PROCESS_SIGNAL, CLUSTER_MINION_RECONNECT, CLUSTER_MINION_SHUTDOWN, CLUSTER_MINION_TOP, 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_MARK_UPGRADE_REQUIRED, CLUSTER_MASTER_LOGS, CLUSTER_MINION_HOPSTART, + CLUSTER_MINION_INFO, CLUSTER_MINION_LOGS, CLUSTER_MINION_PROCESS_SIGNAL, CLUSTER_MINION_RECONNECT, CLUSTER_MINION_SHUTDOWN, + CLUSTER_MINION_TOP, CLUSTER_MODELS, CLUSTER_MODULE_INDEX, CLUSTER_ONLINE_MINIONS, CLUSTER_PROFILE, CLUSTER_RECONNECT, CLUSTER_REMOVE_MINION, + CLUSTER_SHUTDOWN, CLUSTER_TRAITS_UPDATE, CLUSTER_UPGRADE_MINIONS, CLUSTER_UPGRADE_STATUS, }, }; use ratatui::{ @@ -129,6 +130,25 @@ impl Default for DeleteProgressState { } } +#[derive(Debug)] +pub struct ClusterUpgradeProgressState { + pub visible: bool, + pub message: String, + pub spinner: spinner::Model, + pub last_tick: Instant, +} + +impl Default for ClusterUpgradeProgressState { + fn default() -> Self { + let mut spinner_model = spinner::Model::new(); + spinner_model.spinner = spinner::Spinner::mini_dot(); + spinner_model.style = Style::default().fg(palette::WARNING_PEAK); + Self { visible: false, message: String::new(), spinner: spinner_model, last_tick: Instant::now() } + } +} + +type ClusterUpgradeTaskResult = Result<(usize, usize, usize, usize, usize, Vec), String>; + #[derive(Debug)] pub struct SysInspectUX { exit: bool, @@ -261,6 +281,10 @@ pub struct SysInspectUX { pub delete_task: Option>>, pub delete_success_message: String, pub delete_success_styled: Option>, + pub cluster_upgrade_progress: ClusterUpgradeProgressState, + pub cluster_upgrade_task: Option>, + pub cluster_upgrade_required_count: usize, + pub cluster_upgrade_unreachable_count: usize, // Exit-after-popup state (for setup config-written notice) pub pending_exit: bool, @@ -379,6 +403,10 @@ impl Default for SysInspectUX { delete_task: None, delete_success_message: String::new(), delete_success_styled: None, + cluster_upgrade_progress: ClusterUpgradeProgressState::default(), + cluster_upgrade_task: None, + cluster_upgrade_required_count: 0, + cluster_upgrade_unreachable_count: 0, pending_exit: false, pending_exit_message: None, @@ -416,11 +444,13 @@ impl SysInspectUX { pub fn run_loop(mut self, term: &mut DefaultTerminal) -> io::Result<()> { self.cycles_buf = self.get_cycles().unwrap_or_default(); + self.refresh_cluster_upgrade_status(); self.run_normal_loop(term) } fn run_connected(mut self, term: &mut DefaultTerminal) -> io::Result<()> { self.cycles_buf = self.get_cycles().unwrap_or_default(); + self.refresh_cluster_upgrade_status(); self.run_normal_loop(term) } @@ -655,18 +685,46 @@ impl SysInspectUX { frame.render_widget(self, main_area); + let badge_text = if self.cluster_upgrade_required_count > 0 { + Some(if self.cluster_upgrade_unreachable_count > 0 { " ⚠️ Cluster upgrade incomplete " } else { " 🚨 Cluster upgrade required " }) + } else { + None + }; + if self.offline { let offline_w: u16 = 14; - let [main_status, offline_area]: [Rect; 2] = Layout::default() - .direction(Direction::Horizontal) - .constraints([Constraint::Min(0), Constraint::Length(offline_w)].as_ref()) - .split(status_area) - .as_ref() - .try_into() - .unwrap(); + let (main_status, badge_area, offline_area) = if let Some(text) = badge_text { + let badge_w = UnicodeWidthStr::width(text) as u16; + let [main_status, badge_area, offline_area]: [Rect; 3] = Layout::default() + .direction(Direction::Horizontal) + .constraints([Constraint::Min(0), Constraint::Length(badge_w), Constraint::Length(offline_w)].as_ref()) + .split(status_area) + .as_ref() + .try_into() + .unwrap(); + (main_status, Some((badge_area, text)), offline_area) + } else { + let [main_status, offline_area]: [Rect; 2] = Layout::default() + .direction(Direction::Horizontal) + .constraints([Constraint::Min(0), Constraint::Length(offline_w)].as_ref()) + .split(status_area) + .as_ref() + .try_into() + .unwrap(); + (main_status, None, offline_area) + }; let status_paragraph = Paragraph::new(self.status_text.clone()).style(Style::default().fg(self::palette::GRAY_1).bg(self::palette::BG_1)); frame.render_widget(status_paragraph, main_status); + if let Some((badge_area, text)) = badge_area { + let badge_style = if self.cluster_upgrade_unreachable_count > 0 { + Style::default().fg(palette::WARNING_PEAK).bg(palette::BG_1).add_modifier(Modifier::BOLD) + } else { + Style::default().fg(palette::ERROR_PEAK).bg(palette::BG_1).add_modifier(Modifier::BOLD) + }; + frame + .render_widget(Paragraph::new(Line::from(Span::styled(text, badge_style))).style(Style::default().bg(palette::BG_1)), badge_area); + } let offline_paragraph = Paragraph::new(Line::from(vec![Span::styled( " Offline \u{2716} ", @@ -675,8 +733,30 @@ impl SysInspectUX { .style(Style::default().bg(palette::BG_1)); frame.render_widget(offline_paragraph, offline_area); } else { - let status_paragraph = Paragraph::new(self.status_text.clone()).style(Style::default().fg(self::palette::GRAY_1).bg(self::palette::BG_1)); - frame.render_widget(status_paragraph, status_area); + if let Some(text) = badge_text { + let badge_w = UnicodeWidthStr::width(text) as u16; + let [main_status, badge_area]: [Rect; 2] = Layout::default() + .direction(Direction::Horizontal) + .constraints([Constraint::Min(0), Constraint::Length(badge_w)].as_ref()) + .split(status_area) + .as_ref() + .try_into() + .unwrap(); + let status_paragraph = + Paragraph::new(self.status_text.clone()).style(Style::default().fg(self::palette::GRAY_1).bg(self::palette::BG_1)); + frame.render_widget(status_paragraph, main_status); + let badge_style = if self.cluster_upgrade_unreachable_count > 0 { + Style::default().fg(palette::WARNING_PEAK).bg(palette::BG_1).add_modifier(Modifier::BOLD) + } else { + Style::default().fg(palette::ERROR_PEAK).bg(palette::BG_1).add_modifier(Modifier::BOLD) + }; + frame + .render_widget(Paragraph::new(Line::from(Span::styled(text, badge_style))).style(Style::default().bg(palette::BG_1)), badge_area); + } else { + let status_paragraph = + Paragraph::new(self.status_text.clone()).style(Style::default().fg(self::palette::GRAY_1).bg(self::palette::BG_1)); + frame.render_widget(status_paragraph, status_area); + } } } @@ -685,6 +765,7 @@ impl SysInspectUX { let poll_dur = if self.repo_manager.progress.lock().unwrap().is_some() || self.registration_progress.lock().unwrap().visible || self.delete_progress.visible + || self.cluster_upgrade_progress.visible { Duration::from_millis(50) } else { @@ -712,6 +793,7 @@ impl SysInspectUX { } } if !self.offline { + self.refresh_cluster_upgrade_status(); if self.minions_visible { self.refresh_minions(); } @@ -738,6 +820,13 @@ impl SysInspectUX { self.delete_progress.spinner.update(tick); self.delete_progress.last_tick = Instant::now(); } + if self.cluster_upgrade_progress.visible + && self.cluster_upgrade_progress.last_tick.elapsed() >= self.cluster_upgrade_progress.spinner.spinner.fps + { + let tick = self.cluster_upgrade_progress.spinner.tick(); + self.cluster_upgrade_progress.spinner.update(tick); + self.cluster_upgrade_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() @@ -768,6 +857,36 @@ impl SysInspectUX { } } } + if self.cluster_upgrade_progress.visible + && self.cluster_upgrade_task.as_ref().is_some_and(|task| task.is_finished()) + && let Some(task) = self.cluster_upgrade_task.take() + { + let result = tokio::task::block_in_place(|| tokio::runtime::Handle::current().block_on(task)); + self.cluster_upgrade_progress.visible = false; + self.cluster_upgrade_progress.message.clear(); + self.restore_status(); + self.refresh_cluster_upgrade_status(); + self.refresh_minions(); + match result { + Ok(Ok((updated, dispatched, skipped, failed, offline, items))) => { + self.info_alert_visible = true; + self.info_alert_title = "Cluster Upgrade".to_string(); + self.info_alert_styled = None; + self.info_alert_message = format!( + "SSH upgraded: {updated}\nDispatched to online: {dispatched}\nSkipped: {skipped}\nFailed: {failed}\nOffline: {offline}{}", + if items.is_empty() { String::new() } else { format!("\n\n{}", items.join("\n")) } + ); + } + 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 cluster upgrade task: {err}"); + } + } + } // Process file picker result for repo manager if self.repo_manager.visible && let Some(path) = self.file_picker.selected.take() @@ -791,6 +910,7 @@ impl SysInspectUX { if self.repo_manager.active_tab == 4 { let _ = self.load_platforms(); } + self.mark_cluster_upgrade_required(); } } else { // Track that a reload is needed when progress finishes @@ -1423,6 +1543,7 @@ impl SysInspectUX { || self.registration_form.visible || self.registration_progress.lock().unwrap().visible || self.delete_progress.visible + || self.cluster_upgrade_progress.visible } fn sync_main_focus_for_overlays(&mut self) { @@ -1464,6 +1585,47 @@ impl SysInspectUX { } } + fn refresh_cluster_upgrade_status(&mut self) { + if let Ok((required, unreachable)) = self.fetch_cluster_upgrade_status() { + self.cluster_upgrade_required_count = required; + self.cluster_upgrade_unreachable_count = unreachable; + } + } + + fn mark_cluster_upgrade_required(&mut self) { + let _ = tokio::task::block_in_place(|| { + tokio::runtime::Handle::current().block_on(async { + call_master_console(&self.cfg, &format!("{SCHEME_COMMAND}{CLUSTER_MARK_UPGRADE_REQUIRED}"), "*", None, None, None).await + }) + }); + self.refresh_cluster_upgrade_status(); + if self.minions_visible { + self.refresh_minions(); + } + } + + fn start_cluster_upgrade(&mut self) { + self.cluster_upgrade_progress.visible = true; + self.cluster_upgrade_progress.message = "Applying repository updates across the cluster...".to_string(); + self.cluster_upgrade_progress.last_tick = Instant::now(); + self.status_text = Line::from(vec![ + Span::styled(" Esc ", Style::default().fg(palette::FG)), + Span::styled("wait for completion", Style::default().fg(palette::FAINT)), + ]); + let cfg = self.cfg.clone(); + self.cluster_upgrade_task = Some(tokio::spawn(async move { + call_master_console(&cfg, &format!("{SCHEME_COMMAND}{CLUSTER_UPGRADE_MINIONS}"), "*", None, None, None) + .await + .map_err(|err| err.to_string()) + .and_then(|resp| match resp.payload { + ConsolePayload::UpgradeSummary { updated, dispatched, skipped, failed, offline, items } => { + Ok((updated, dispatched, skipped, failed, offline, items)) + } + _ => Err("Unexpected console payload for cluster upgrade".to_string()), + }) + })); + } + fn load_selected_minion_info(&mut self) { if self.minion_traits_modified { return; @@ -2885,6 +3047,7 @@ impl SysInspectUX { let ctx = serde_json::json!({"op": "new", "name": name}).to_string(); self.call_profile_rpc(&ctx)?; self.load_profile_list()?; + self.mark_cluster_upgrade_required(); Ok(()) } @@ -2892,18 +3055,21 @@ impl SysInspectUX { let ctx = serde_json::json!({"op": "delete", "name": name}).to_string(); self.call_profile_rpc(&ctx)?; self.load_profile_list()?; + self.mark_cluster_upgrade_required(); Ok(()) } fn do_profile_add_matches(&mut self, name: &str, matches: Vec, library: bool) -> Result<(), String> { let ctx = serde_json::json!({"op": "add", "name": name, "matches": matches, "library": library}).to_string(); self.call_profile_rpc(&ctx)?; + self.mark_cluster_upgrade_required(); Ok(()) } fn do_profile_remove_match(&mut self, name: &str, selector: &str, library: bool) -> Result<(), String> { let ctx = serde_json::json!({"op": "remove", "name": name, "matches": [selector], "library": library}).to_string(); self.call_profile_rpc(&ctx)?; + self.mark_cluster_upgrade_required(); Ok(()) } @@ -3141,6 +3307,7 @@ impl SysInspectUX { self.write_enabled_models_dropin(ids)?; self.refresh_local_model_rows(&self.enabled_model_ids_with(model_id, enabled))?; self.repo_manager.models_dirty = true; + self.mark_cluster_upgrade_required(); Ok(()) } @@ -3189,6 +3356,7 @@ impl SysInspectUX { { Ok(()) => { self.repo_manager.models_dirty = true; + self.mark_cluster_upgrade_required(); self.info_alert_visible = true; self.info_alert_title = "Model Import".to_string(); self.info_alert_styled = None; @@ -3212,6 +3380,7 @@ impl SysInspectUX { self.write_enabled_models_dropin(enabled_ids.clone())?; self.refresh_local_model_rows(&enabled_ids)?; self.repo_manager.models_dirty = true; + self.mark_cluster_upgrade_required(); Ok(()) } @@ -3382,6 +3551,7 @@ impl SysInspectUX { self.error_alert_message = format!("Cannot remove modules: {e}"); } else { let _ = self.load_module_index(); + self.mark_cluster_upgrade_required(); } } @@ -3405,6 +3575,7 @@ impl SysInspectUX { } } let _ = self.load_module_index(); + self.mark_cluster_upgrade_required(); } fn process_library_add(&mut self, path: &std::path::Path) { @@ -3423,6 +3594,7 @@ impl SysInspectUX { self.error_alert_message = format!("Cannot add library: {e}"); } else { self.load_library_index().ok(); + self.mark_cluster_upgrade_required(); } } else { // Single file: wrap in temp dir, use add_library @@ -3436,6 +3608,7 @@ impl SysInspectUX { self.error_alert_message = format!("Cannot add library file: {e}"); } else { self.load_library_index().ok(); + self.mark_cluster_upgrade_required(); } let _ = std::fs::remove_dir_all(&tmp); } @@ -3457,6 +3630,8 @@ impl SysInspectUX { } else if let Err(e) = self.load_platforms() { self.error_alert_visible = true; self.error_alert_message = format!("Failed to reload platforms: {e}"); + } else { + self.mark_cluster_upgrade_required(); } } Err(e) => { @@ -3479,6 +3654,7 @@ impl SysInspectUX { } } let _ = self.load_platforms(); + self.mark_cluster_upgrade_required(); } fn read_spec_version_descr(spec: &std::path::Path) -> (Option, String) { @@ -3682,6 +3858,10 @@ impl SysInspectUX { self.status_at_repo_manager(); } } + KeyCode::Char('u') if e.modifiers.contains(KeyModifiers::CONTROL) => { + self.master_menu_visible = false; + self.start_cluster_upgrade(); + } KeyCode::Char('t') if e.modifiers.contains(KeyModifiers::CONTROL) => { self.master_menu_visible = false; self.master_confirm_visible = true; @@ -3748,16 +3928,19 @@ impl SysInspectUX { } } 4 => { + self.start_cluster_upgrade(); + } + 5 => { self.master_confirm_visible = true; self.master_confirm_choice = AlertResult::Default; self.master_confirm_action = 1; } - 5 => { + 6 => { self.master_confirm_visible = true; self.master_confirm_choice = AlertResult::Default; self.master_confirm_action = 3; } - 6 => { + 7 => { self.master_confirm_visible = true; self.master_confirm_choice = AlertResult::Default; self.master_confirm_action = 2; @@ -4174,6 +4357,10 @@ impl SysInspectUX { return; } + if self.cluster_upgrade_progress.visible { + return; + } + // Master operations menu is modal if self.on_master_menu(e) { return; @@ -4745,6 +4932,19 @@ impl SysInspectUX { }) } + pub fn fetch_cluster_upgrade_status(&self) -> Result<(usize, usize), SysinspectError> { + tokio::task::block_in_place(|| { + tokio::runtime::Handle::current().block_on(async { + call_master_console(&self.cfg, &format!("{SCHEME_COMMAND}{CLUSTER_UPGRADE_STATUS}"), "*", None, None, None).await.map(|resp| { + match resp.payload { + ConsolePayload::UpgradeStatus { required, unreachable } => (required, unreachable), + _ => (0, 0), + } + }) + }) + }) + } + /// Query the master console for available models. pub fn get_models(&self) -> Result<(Vec, Vec), SysinspectError> { tokio::task::block_in_place(|| { diff --git a/src/ui/online.rs b/src/ui/online.rs index ef53ef7a..c03c43ce 100644 --- a/src/ui/online.rs +++ b/src/ui/online.rs @@ -19,6 +19,7 @@ use ratatui_cheese::{ tree::{TreeGroup, TreeItem}, }; use serde_json::Value; +use unicode_width::UnicodeWidthStr; impl SysInspectUX { /// Shorten a display string by preserving leading/trailing `edge` chars and replacing the middle with `...`. @@ -192,7 +193,8 @@ impl SysInspectUX { return; } - let ip_data: Vec = filtered.iter().map(|r| Self::_fmt_ip(&r.ip)).collect(); + let ip_data: Vec = + filtered.iter().map(|r| if r.upgrade_unreachable { format!("📦 {}", Self::_fmt_ip(&r.ip)) } else { Self::_fmt_ip(&r.ip) }).collect(); let host_data: Vec = filtered.iter().map(|r| Self::_trunc_ellipsis(&Self::online_host(r), max_w as usize)).collect(); let ver_data: Vec = filtered.iter().map(|r| Self::_trunc_ellipsis(&Self::_fmt_version(r), max_w as usize)).collect(); let id_data: Vec = filtered.iter().map(|r| Self::shorten_mid(&r.minion_id, 4)).collect(); @@ -207,13 +209,13 @@ impl SysInspectUX { let osv_data: Vec = filtered.iter().map(|r| Self::_trunc_ellipsis(&r.os_version, max_w as usize)).collect(); let ker_data: Vec = filtered.iter().map(|r| r.kernel.clone()).collect(); - let ip_w = ip_data.iter().map(|s| s.chars().count() as u16).max().unwrap_or(2).max(2); - let host_w = host_data.iter().map(|s| s.chars().count() as u16).max().unwrap_or(4).max(4); - let ver_w = ver_data.iter().map(|s| s.chars().count() as u16).max().unwrap_or(7).min(max_w); - let id_w = id_data.iter().map(|s| s.chars().count() as u16).max().unwrap_or(2).max(2); - let os_w = os_data.iter().map(|s| s.chars().count() as u16).max().unwrap_or(2).max(2); - let osv_w = osv_data.iter().map(|s| s.chars().count() as u16).max().unwrap_or(2).max(2); - let ker_w = ker_data.iter().map(|s| s.chars().count() as u16).max().unwrap_or(2).max(2); + let ip_w = ip_data.iter().map(|s| UnicodeWidthStr::width(s.as_str()) as u16).max().unwrap_or(2).max(2); + let host_w = host_data.iter().map(|s| UnicodeWidthStr::width(s.as_str()) as u16).max().unwrap_or(4).max(4); + let ver_w = ver_data.iter().map(|s| UnicodeWidthStr::width(s.as_str()) as u16).max().unwrap_or(7).min(max_w); + let id_w = id_data.iter().map(|s| UnicodeWidthStr::width(s.as_str()) as u16).max().unwrap_or(2).max(2); + let os_w = os_data.iter().map(|s| UnicodeWidthStr::width(s.as_str()) as u16).max().unwrap_or(2).max(2); + let osv_w = osv_data.iter().map(|s| UnicodeWidthStr::width(s.as_str()) as u16).max().unwrap_or(2).max(2); + let ker_w = ker_data.iter().map(|s| UnicodeWidthStr::width(s.as_str()) as u16).max().unwrap_or(2).max(2); let base_w: Vec = vec![ip_w, host_w, ver_w, id_w, os_w, osv_w, ker_w]; let mut cols: Vec = base_w.into_iter().map(Constraint::Length).collect(); diff --git a/src/ui/wgt.rs b/src/ui/wgt.rs index df0dfcdc..b43cc42a 100644 --- a/src/ui/wgt.rs +++ b/src/ui/wgt.rs @@ -321,6 +321,7 @@ impl Widget for &SysInspectUX { self.dialog_trait_tag(area, buf); self.dialog_cluster_confirm(area, buf); self.dialog_delete_progress(area, buf); + self.dialog_cluster_upgrade_progress(area, buf); self.dialog_master_confirm(area, buf); self.master_actions_menu(area, buf); self.repo_manager.render(area, buf); diff --git a/sysmaster/src/console.rs b/sysmaster/src/console.rs index d775e3c5..3abdcf46 100644 --- a/sysmaster/src/console.rs +++ b/sysmaster/src/console.rs @@ -8,20 +8,22 @@ use super::*; use crate::hopstart::{HopStartTarget, HopStarter, shell_quote}; -use libmodpak::{SysInspectModPak, compare_versions, mpk::ModPakRepoIndex}; +use libmodpak::{SysInspectModPak, mpk::ModPakRepoIndex}; use libsysinspect::{ cfg::mmconf::MinionConfig, console::{ ConsoleEnvelope, ConsoleLibraryRow, ConsoleMasterLogSnapshot, ConsoleMinionInfoRow, ConsoleMinionLogRequest, ConsoleMinionLogSnapshot, - ConsoleMinionProcessSignalRequest, ConsoleMinionTopRequest, ConsoleMinionTopSnapshot, ConsoleModelRow, ConsoleModuleArgument, - ConsoleModuleRow, ConsoleOnlineMinionRow, ConsolePayload, ConsoleQuery, ConsoleResponse, ConsoleSealed, ConsoleTransportStatusRow, - MinionCommandReply, authorised_console_client, load_master_private_key, + ConsoleMinionProcessSignalRequest, ConsoleMinionTopRequest, ConsoleMinionTopSnapshot, ConsoleMinionUpgradeSelfRequest, ConsoleModelRow, + ConsoleModuleArgument, ConsoleModuleRow, ConsoleOnlineMinionRow, ConsolePayload, ConsoleQuery, ConsoleResponse, ConsoleSealed, + ConsoleTransportStatusRow, MinionCommandReply, authorised_console_client, load_master_private_key, }, context::get_context, mdescr::catalog::ModelCatalog, traits::TraitSource, }; -use libsysproto::query::commands::CLUSTER_MINION_TOP; +use libsysproto::query::commands::{ + CLUSTER_MARK_UPGRADE_REQUIRED, CLUSTER_MINION_TOP, CLUSTER_MINION_UPGRADE_SELF, CLUSTER_UPGRADE_MINIONS, CLUSTER_UPGRADE_STATUS, +}; use tokio::net::{TcpStream, tcp::OwnedReadHalf}; use tokio::sync::oneshot; use tokio::time; @@ -227,6 +229,18 @@ impl RotationDispatchSummary { } impl SysMaster { + fn repo_minion_builds(&self) -> std::collections::BTreeMap<(String, String), libmodpak::MinionBuildRecord> { + SysInspectModPak::new(self.cfg.get_mod_repo_root()) + .ok() + .map(|repo| { + repo.minion_builds().into_iter().fold(std::collections::BTreeMap::new(), |mut rows, row| { + rows.insert((row.platform().to_string(), row.arch().to_string()), row); + rows + }) + }) + .unwrap_or_default() + } + /// Serialize a console response into a plain JSON line for direct socket writes. /// /// This helper is used for pre-encryption validation errors and other cases @@ -252,28 +266,31 @@ impl SysMaster { /// applied here. async fn online_minions_data(&mut self, query: &str, traits: &str, mid: &str) -> Result, SysinspectError> { Ok({ - let repo_versions = SysInspectModPak::new(self.cfg.get_mod_repo_root()) - .ok() - .map(|repo| { - repo.minion_builds().into_iter().fold(std::collections::BTreeMap::new(), |mut rows, row| { - rows.insert((row.platform().to_string(), row.arch().to_string()), row.version().to_string()); - rows - }) - }) - .unwrap_or_default(); + let repo_checksums = self + .repo_minion_builds() + .into_iter() + .map(|(key, row)| (key, row.checksum().to_string())) + .collect::>(); + let repo_versions = self + .repo_minion_builds() + .into_iter() + .map(|(key, row)| (key, row.version().to_string())) + .collect::>(); let selected = self.selected_minions(query, traits, mid).await?; let mut session = self.session.lock().await; { let mut rows = Vec::with_capacity(selected.len()); for minion in selected { let cmdb = self.mreg.lock().await.get_cmdb(minion.id()).unwrap_or_default(); + let upgrade_marker = self.mreg.lock().await.get_upgrade_marker(minion.id()).unwrap_or_default(); let (fqdn, hostname, ip) = Self::preferred_host(&minion, cmdb.as_ref()); let current_version = minion.get_traits().get("minion.version").and_then(|v| v.as_str()).unwrap_or_default().to_string(); + let current_sha = minion.get_traits().get("minion.binary.sha256").and_then(|v| v.as_str()).unwrap_or_default().to_string(); let os_dist = minion.get_traits().get("system.os.distribution").and_then(|v| v.as_str()).unwrap_or_default().to_string(); - let target_version = repo_versions - .get(&(os_dist.clone(), minion.get_traits().get("system.arch").and_then(|v| v.as_str()).unwrap_or_default().to_string())) - .cloned() - .unwrap_or_default(); + let arch = minion.get_traits().get("system.arch").and_then(|v| v.as_str()).unwrap_or_default().to_string(); + let target_sha = repo_checksums.get(&(os_dist.clone(), arch.clone())).cloned().unwrap_or_default(); + let target_version = repo_versions.get(&(os_dist.clone(), arch)).cloned().unwrap_or_default(); + let outdated = !target_sha.is_empty() && current_sha != target_sha; let os_name = minion.get_traits().get("system.os.name").and_then(|v| v.as_str()).unwrap_or_default().to_string(); let os_version = minion.get_traits().get("system.os.version").and_then(|v| v.as_str()).unwrap_or_default().to_string(); let kernel = minion.get_traits().get("system.kernel").and_then(|v| v.as_str()).unwrap_or_default().to_string(); @@ -283,9 +300,9 @@ impl SysMaster { ip, minion_id: minion.id().to_string(), alive: session.alive(minion.id()), - outdated: !current_version.is_empty() - && !target_version.is_empty() - && compare_versions(¤t_version, &target_version).is_lt(), + outdated, + upgrade_required: outdated || upgrade_marker.is_some(), + upgrade_unreachable: upgrade_marker.as_ref().is_some_and(|marker| marker.unreachable), version: current_version, target_version, os_distribution: os_dist, @@ -299,6 +316,177 @@ impl SysMaster { }) } + async fn mark_upgrade_required_console_response(&mut self) -> Result { + let repo_builds = self.repo_minion_builds(); + let ids = self.mreg.lock().await.get_registered_ids()?; + let mut marked = 0usize; + let mut cleared = 0usize; + for mid in ids { + let Some(minion) = self.mreg.lock().await.get(&mid)? else { + continue; + }; + let platform = minion.get_traits().get("system.os.distribution").and_then(|v| v.as_str()).unwrap_or_default().to_string(); + let arch = minion.get_traits().get("system.arch").and_then(|v| v.as_str()).unwrap_or_default().to_string(); + let current_sha = minion.get_traits().get("minion.binary.sha256").and_then(|v| v.as_str()).unwrap_or_default().to_string(); + if let Some(build) = repo_builds.get(&(platform, arch)) { + if !current_sha.is_empty() && current_sha == build.checksum() { + self.mreg.lock().await.clear_upgrade_required(&mid)?; + cleared += 1; + } else { + self.mreg.lock().await.mark_upgrade_required(&mid, build.version(), build.checksum())?; + marked += 1; + } + } else { + self.mreg.lock().await.clear_upgrade_required(&mid)?; + cleared += 1; + } + } + Ok(ConsoleResponse::ok(ConsolePayload::Ack { + action: "reconcile_cluster_upgrade".to_string(), + target: "cluster".to_string(), + count: marked, + items: vec![format!("{cleared} minions up-to-date or unsupported")], + })) + } + + async fn cluster_upgrade_status_console_response(&mut self) -> Result { + let (required, unreachable) = self.mreg.lock().await.upgrade_status_counts()?; + Ok(ConsoleResponse::ok(ConsolePayload::UpgradeStatus { required, unreachable })) + } + + async fn upgrade_minion_over_ssh( + cmdb: &crate::registry::rec::MinionCmdbRecord, artifact: &libmodpak::MinionBuildRecord, + ) -> Result<(), SysinspectError> { + let host = cmdb.host().ok_or_else(|| SysinspectError::InvalidQuery("Missing host in CMDB".to_string()))?; + let user = cmdb.user().ok_or_else(|| SysinspectError::InvalidQuery("Missing user in CMDB".to_string()))?; + let root = cmdb.root().ok_or_else(|| SysinspectError::InvalidQuery("Missing root in CMDB".to_string()))?; + let bin = cmdb.bin().ok_or_else(|| SysinspectError::InvalidQuery("Missing bin path in CMDB".to_string()))?; + let config = cmdb.config().ok_or_else(|| SysinspectError::InvalidQuery("Missing config path in CMDB".to_string()))?; + let stage_dir = format!("{root}/tmp"); + let stage_bin = format!("{stage_dir}/sysminion.upgrade"); + let ssh_target = format!("{user}@{host}"); + + let mkdir_status = tokio::process::Command::new("ssh").arg(&ssh_target).arg(format!("mkdir -p {}", shell_quote(&stage_dir))).status().await?; + if !mkdir_status.success() { + return Err(SysinspectError::MasterGeneralError(format!("Unable to prepare remote stage directory on {host}"))); + } + + let scp_status = tokio::process::Command::new("scp").arg(artifact.path()).arg(format!("{ssh_target}:{stage_bin}")).status().await?; + if !scp_status.success() { + return Err(SysinspectError::MasterGeneralError(format!("Unable to upload sysminion build to {host}"))); + } + + let remote_cmd = format!( + "sh -lc '{} -c {} --stop >/dev/null 2>&1 || true; install -m 0755 {} {}; {} -c {} --daemon'", + shell_quote(bin), + shell_quote(config), + shell_quote(&stage_bin), + shell_quote(bin), + shell_quote(bin), + shell_quote(config) + ); + let status = tokio::process::Command::new("ssh").arg(&ssh_target).arg(remote_cmd).status().await?; + if !status.success() { + return Err(SysinspectError::MasterGeneralError(format!("Remote sysminion upgrade failed for {host}"))); + } + + Ok(()) + } + + async fn run_cluster_upgrade_console_response( + master: Arc>, bcast: &broadcast::Sender, cfg: &MasterConfig, + ) -> Result { + let mut updated = 0usize; + let mut dispatched = 0usize; + let mut offline = 0usize; + let mut skipped = 0usize; + let mut failed = 0usize; + let mut items = Vec::new(); + let mut messages = Vec::new(); + + let ids = master.lock().await.mreg.lock().await.get_upgrade_required_ids()?; + let repo_builds = master.lock().await.repo_minion_builds(); + let fileserver_root = cfg.fileserver_root().to_path_buf(); + for mid in ids { + let (minion, cmdb, alive) = { + let guard = master.lock().await; + let minion = guard.mreg.lock().await.get(&mid)?; + let cmdb = guard.mreg.lock().await.get_cmdb(&mid)?; + let alive = guard.session.lock().await.alive(&mid); + (minion, cmdb, alive) + }; + let Some(minion) = minion else { + skipped += 1; + items.push(format!("{mid}: registry entry missing")); + continue; + }; + + let platform = minion.get_traits().get("system.os.distribution").and_then(|v| v.as_str()).unwrap_or_default().to_string(); + let arch = minion.get_traits().get("system.arch").and_then(|v| v.as_str()).unwrap_or_default().to_string(); + let Some(build) = repo_builds.get(&(platform, arch)) else { + skipped += 1; + items.push(format!("{mid}: matching sysminion build not found")); + continue; + }; + + if alive { + let subpath = build.path().strip_prefix(&fileserver_root).map(|p| p.to_string_lossy().to_string()).unwrap_or_default(); + if subpath.is_empty() { + failed += 1; + items.push(format!("{mid}: unable to determine download subpath")); + continue; + } + let context = serde_json::to_string(&ConsoleMinionUpgradeSelfRequest { + subpath, + checksum: build.checksum().to_string(), + version: build.version().to_string(), + }) + .map_err(|err| SysinspectError::SerializationError(format!("Failed to encode upgrade request: {err}")))?; + let msg = { + let mut guard = master.lock().await; + guard.msg_query_data(&format!("{SCHEME_COMMAND}{CLUSTER_MINION_UPGRADE_SELF}"), "", "", &mid, &context).await + }; + match msg { + Some(msg) => { + messages.push(msg); + dispatched += 1; + } + None => { + failed += 1; + items.push(format!("{mid}: unable to construct upgrade message")); + } + } + continue; + } + + if let Some(cmdb) = cmdb.as_ref() + && cmdb.backend() == Some("hopstart") + { + match Self::upgrade_minion_over_ssh(cmdb, build).await { + Ok(()) => { + master.lock().await.mreg.lock().await.clear_upgrade_required(&mid)?; + updated += 1; + } + Err(err) => { + master.lock().await.mreg.lock().await.mark_upgrade_unreachable(&mid)?; + failed += 1; + items.push(format!("{mid}: {err}")); + } + } + continue; + } + + offline += 1; + items.push(format!("{mid}: minion is offline")); + } + + if !messages.is_empty() { + Self::broadcast_console_messages(Arc::clone(&master), bcast, cfg, messages, true).await; + } + + Ok(ConsoleResponse::ok(ConsolePayload::UpgradeSummary { updated, dispatched, skipped, failed, offline, items })) + } + /// Build model-discovery rows from the master's fileserver models directory. async fn models_data(&mut self) -> Result<(Vec, Vec), SysinspectError> { let enabled_models: std::collections::BTreeSet = self.cfg.fileserver_models().iter().cloned().collect(); @@ -881,6 +1069,27 @@ impl SysMaster { }; } + if query.model.eq(&format!("{SCHEME_COMMAND}{CLUSTER_MARK_UPGRADE_REQUIRED}")) { + return match master.lock().await.mark_upgrade_required_console_response().await { + Ok(response) => response, + Err(err) => ConsoleResponse::err(format!("Unable to mark cluster upgrade state: {err}")), + }; + } + + if query.model.eq(&format!("{SCHEME_COMMAND}{CLUSTER_UPGRADE_STATUS}")) { + return match master.lock().await.cluster_upgrade_status_console_response().await { + Ok(response) => response, + Err(err) => ConsoleResponse::err(format!("Unable to get cluster upgrade status: {err}")), + }; + } + + if query.model.eq(&format!("{SCHEME_COMMAND}{CLUSTER_UPGRADE_MINIONS}")) { + return match Self::run_cluster_upgrade_console_response(Arc::clone(&master), bcast, cfg).await { + Ok(response) => response, + Err(err) => ConsoleResponse::err(format!("Unable to run cluster upgrade: {err}")), + }; + } + if query.model.eq(&format!("{SCHEME_COMMAND}{CLUSTER_MINION_INFO}")) { return match master.lock().await.minion_info_rows(&query.query, &query.traits, &query.mid).await { Ok(rows) => ConsoleResponse::ok(ConsolePayload::MinionInfo { rows }), diff --git a/sysmaster/src/master.rs b/sysmaster/src/master.rs index e43aa18b..24fe3097 100644 --- a/sysmaster/src/master.rs +++ b/sysmaster/src/master.rs @@ -1167,6 +1167,9 @@ impl SysMaster { if let Err(err) = mreg.refresh_cmdb_observed(&mid, &traits) { log::error!("Unable to sync CMDB traits for {}: {err}", mid); } + if let Err(err) = mreg.clear_upgrade_required_if_checksum_matches(&mid, &traits) { + log::debug!("Unable to clear upgrade marker for {}: {err}", mid); + } let m = mreg.get(&mid).unwrap_or_default().unwrap_or_default(); let cmdb = mreg.get_cmdb(&mid).unwrap_or_default(); let (fqdn, hostname, ip) = Self::preferred_host(&m, cmdb.as_ref()); diff --git a/sysmaster/src/registry/mreg.rs b/sysmaster/src/registry/mreg.rs index 58e4a399..3530dd85 100644 --- a/sysmaster/src/registry/mreg.rs +++ b/sysmaster/src/registry/mreg.rs @@ -1,10 +1,12 @@ use crate::master::SHARED_SESSION; use super::rec::{MinionCmdbRecord, MinionCmdbStartup, MinionRecord}; +use chrono::{DateTime, Utc}; use globset::Glob; use libcommon::SysinspectError; use libsysinspect::traits; use libsysproto::MinionTarget; +use serde::{Deserialize, Serialize}; use serde_json::{Value, json}; use sled::{Db, Tree}; use std::{ @@ -16,6 +18,34 @@ use std::{ const DB_MINIONS: &str = "minions"; const DB_CMDB: &str = "cmdb"; +const DB_UPGRADE: &str = "upgrade"; + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub(crate) struct UpgradeMarker { + marked_at: DateTime, + #[serde(default)] + repo_version: String, + #[serde(default)] + repo_checksum: String, + #[serde(default)] + pub(crate) unreachable: bool, +} + +impl UpgradeMarker { + fn new(repo_version: impl Into, repo_checksum: impl Into) -> Self { + Self { marked_at: Utc::now(), repo_version: repo_version.into(), repo_checksum: repo_checksum.into(), unreachable: false } + } + + pub(crate) fn checksum(&self) -> &str { + &self.repo_checksum + } +} + +impl Default for UpgradeMarker { + fn default() -> Self { + Self { marked_at: Utc::now(), repo_version: String::new(), repo_checksum: String::new(), unreachable: false } + } +} #[derive(Debug, Clone)] pub struct MinionRegistry { @@ -223,6 +253,97 @@ impl MinionRegistry { Ok(ids) } + pub fn mark_upgrade_required(&self, mid: &str, repo_version: &str, repo_checksum: &str) -> Result<(), SysinspectError> { + let upgrade = self.get_tree(DB_UPGRADE)?; + upgrade.insert(mid, json!(UpgradeMarker::new(repo_version, repo_checksum)).to_string().into_bytes())?; + Ok(()) + } + + pub fn mark_upgrade_unreachable(&self, mid: &str) -> Result<(), SysinspectError> { + let upgrade = self.get_tree(DB_UPGRADE)?; + let marker = self.get_upgrade_marker(mid)?.map(|mut marker| { + marker.unreachable = true; + marker + }); + if let Some(marker) = marker { + upgrade.insert(mid, json!(marker).to_string().into_bytes())?; + } + Ok(()) + } + + pub fn clear_upgrade_required(&self, mid: &str) -> Result<(), SysinspectError> { + let upgrade = self.get_tree(DB_UPGRADE)?; + let _ = upgrade.remove(mid)?; + Ok(()) + } + + pub fn clear_upgrade_required_if_checksum_matches(&self, mid: &str, traits: &HashMap) -> Result<(), SysinspectError> { + let Some(marker) = self.get_upgrade_marker(mid)? else { + return Ok(()); + }; + let actual = traits.get("minion.binary.sha256").and_then(|v| v.as_str()).unwrap_or_default(); + if !actual.is_empty() && actual == marker.checksum() { + self.clear_upgrade_required(mid)?; + } + Ok(()) + } + + pub fn clear_all_upgrade_markers(&self) -> Result<(), SysinspectError> { + let upgrade = self.get_tree(DB_UPGRADE)?; + for entry in upgrade.iter() { + let (key, _) = entry.map_err(|err| SysinspectError::MasterGeneralError(format!("Upgrade database seems corrupt: {err}")))?; + let _ = upgrade.remove(key)?; + } + Ok(()) + } + + pub fn get_upgrade_marker(&self, mid: &str) -> Result, SysinspectError> { + let upgrade = self.get_tree(DB_UPGRADE)?; + let data = upgrade.get(mid)?; + if let Some(data) = data { + let marker = serde_json::from_str::( + &String::from_utf8(data.to_vec()).map_err(|err| SysinspectError::MasterGeneralError(format!("{err}")))?, + ) + .map_err(|err| SysinspectError::MasterGeneralError(format!("{err}")))?; + return Ok(Some(marker)); + } + Ok(None) + } + + pub fn is_upgrade_required(&self, mid: &str) -> Result { + Ok(self.get_upgrade_marker(mid)?.is_some()) + } + + pub fn get_upgrade_required_ids(&self) -> Result, SysinspectError> { + let upgrade = self.get_tree(DB_UPGRADE)?; + let mut ids = Vec::new(); + for entry in upgrade.iter() { + let (key, _) = entry.map_err(|err| SysinspectError::MasterGeneralError(format!("Upgrade database seems corrupt: {err}")))?; + ids.push(String::from_utf8(key.to_vec()).unwrap_or_default()); + } + ids.sort(); + ids.dedup(); + Ok(ids) + } + + pub fn upgrade_status_counts(&self) -> Result<(usize, usize), SysinspectError> { + let upgrade = self.get_tree(DB_UPGRADE)?; + let mut required = 0usize; + let mut unreachable = 0usize; + for entry in upgrade.iter() { + let (_, value) = entry.map_err(|err| SysinspectError::MasterGeneralError(format!("Upgrade database seems corrupt: {err}")))?; + required += 1; + let marker = serde_json::from_str::( + &String::from_utf8(value.to_vec()).map_err(|err| SysinspectError::MasterGeneralError(format!("{err}")))?, + ) + .map_err(|err| SysinspectError::MasterGeneralError(format!("{err}")))?; + if marker.unreachable { + unreachable += 1; + } + } + Ok((required, unreachable)) + } + pub fn get(&self, mid: &str) -> Result, SysinspectError> { let minions = self.get_tree(DB_MINIONS)?; let data = match minions.get(mid) { @@ -264,6 +385,16 @@ impl MinionRegistry { return Err(SysinspectError::MasterGeneralError(format!("{err}"))); }; + let upgrade = self.get_tree(DB_UPGRADE)?; + let contains = match upgrade.contains_key(mid) { + Ok(res) => res, + Err(err) => return Err(SysinspectError::MasterGeneralError(format!("{err}"))), + }; + + if contains && let Err(err) = upgrade.remove(mid) { + return Err(SysinspectError::MasterGeneralError(format!("{err}"))); + }; + Ok(()) } diff --git a/sysminion/src/main.rs b/sysminion/src/main.rs index 85a222ee..70088e27 100644 --- a/sysminion/src/main.rs +++ b/sysminion/src/main.rs @@ -281,7 +281,7 @@ fn main() -> std::io::Result<()> { 2.. => LevelFilter::max(), }) }) { - println!("Error setting logger output: {err}"); + println!("Error setting logger output: {err}!"); } // Start diff --git a/sysminion/src/minion.rs b/sysminion/src/minion.rs index 7785e3ba..b5892c58 100644 --- a/sysminion/src/minion.rs +++ b/sysminion/src/minion.rs @@ -26,7 +26,10 @@ use libsysinspect::{ get_minion_config, mmconf::{CFG_MASTER_KEY_PUB, CFG_PENDING_TASKS_ROOT, DEFAULT_PORT, MinionConfig, MinionOfflineMode, SysInspectConfig}, }, - console::{ConsoleMinionLogRequest, ConsoleMinionLogSnapshot, ConsoleMinionProcessSignalRequest, ConsoleMinionTopRequest, MinionCommandReply}, + console::{ + ConsoleMinionLogRequest, ConsoleMinionLogSnapshot, ConsoleMinionProcessSignalRequest, ConsoleMinionTopRequest, + ConsoleMinionUpgradeSelfRequest, MinionCommandReply, + }, context, inspector::SysInspectRunner, intp::{ @@ -62,7 +65,8 @@ use libsysproto::{ MinionQuery, SCHEME_COMMAND, commands::{ CLUSTER_MINION_LOGS, CLUSTER_MINION_PROCESS_SIGNAL, CLUSTER_MINION_RECONNECT, CLUSTER_MINION_SHUTDOWN, CLUSTER_MINION_TOP, - CLUSTER_REBOOT, CLUSTER_RECONNECT, CLUSTER_REMOVE_MINION, CLUSTER_ROTATE, CLUSTER_SHUTDOWN, CLUSTER_SYNC, CLUSTER_TRAITS_UPDATE, + CLUSTER_MINION_UPGRADE_SELF, CLUSTER_REBOOT, CLUSTER_RECONNECT, CLUSTER_REMOVE_MINION, CLUSTER_ROTATE, CLUSTER_SHUTDOWN, CLUSTER_SYNC, + CLUSTER_TRAITS_UPDATE, }, }, replay::{ReplayIdentity, replay_identity_from_minion_bytes}, @@ -76,7 +80,9 @@ use serde_yaml::Value as YamlValue; use std::{ collections::VecDeque, fs, + os::unix::fs::PermissionsExt, path::{Path, PathBuf}, + process::Command, sync::Arc, sync::RwLock, sync::atomic::{AtomicBool, AtomicU64, Ordering}, @@ -136,6 +142,9 @@ impl LogRingBuffers { } pub static LOG_RING: Lazy> = Lazy::new(|| RwLock::new(LogRingBuffers::new(LOG_RING_CAPACITY))); +static MINION_BINARY_PATH: Lazy> = Lazy::new(|| std::env::current_exe().ok().map(|path| path.display().to_string())); +static MINION_BINARY_SHA256: Lazy> = + Lazy::new(|| std::env::current_exe().ok().and_then(|path| util::iofs::get_file_sha256(path).ok())); #[derive(Debug, Clone, Copy, Default)] struct BacklogSnapshot { @@ -158,9 +167,15 @@ impl BacklogSnapshot { /// Session Id of the minion pub static MINION_SID: Lazy = Lazy::new(|| Uuid::new_v4().to_string()); -fn minion_traits(cfg: &MinionConfig, q: bool) -> SystemTraits { +fn minion_traits(cfg: &MinionConfig, q: bool, include_binary_sha: bool) -> SystemTraits { let mut traits = if q { traits::get_minion_traits_nolog(Some(cfg)) } else { traits::get_minion_traits(Some(cfg)) }; traits.put("minion.version".to_string(), json!(env!("CARGO_PKG_VERSION"))); + if let Some(path) = &*MINION_BINARY_PATH { + traits.put("minion.binary.path".to_string(), json!(path)); + } + if include_binary_sha && let Some(sha256) = &*MINION_BINARY_SHA256 { + traits.put("minion.binary.sha256".to_string(), json!(sha256)); + } traits } @@ -405,7 +420,7 @@ impl SysMinion { } let mut out: Vec = vec![]; - let minion_traits = minion_traits(&self.cfg, false); + let minion_traits = minion_traits(&self.cfg, false, false); for t in minion_traits.trait_keys() { out.push(format!("{}: {}", t.to_owned(), dataconv::to_string(minion_traits.get(&t)).unwrap_or_default())); } @@ -452,7 +467,7 @@ impl SysMinion { /// Display minion info pub fn print_info(cfg: &MinionConfig) { let mut out: IndexMap = IndexMap::new(); - let mut systraits = minion_traits(cfg, true); + let mut systraits = minion_traits(cfg, true, false); systraits.put("uri.master".to_string(), json!(cfg.master())); systraits.put("uri.fileserver".to_string(), json!(cfg.fileserver())); systraits.put("path.models".to_string(), json!(cfg.models_dir())); @@ -1110,13 +1125,13 @@ impl SysMinion { match msg.get_retcode() { ProtoErrorCode::Success => { if msg.target().scheme().starts_with(SCHEME_COMMAND) { - if matches_target(&msg, this.get_minion_id(), &minion_traits(&this.cfg, true)) { + if matches_target(&msg, this.get_minion_id(), &minion_traits(&this.cfg, true, false)) { this.clone().call_internal_command(msg.cycle(), msg.target().scheme(), msg.target().context()).await; } else { log::debug!("Dropped internal master command for another minion"); } } else { - if !matches_target(&msg, this.get_minion_id(), &minion_traits(&this.cfg, true)) { + if !matches_target(&msg, this.get_minion_id(), &minion_traits(&this.cfg, true, false)) { log::debug!("Dropped master model command for another minion"); continue; } @@ -1279,7 +1294,7 @@ impl SysMinion { } pub async fn send_traits(self: Arc) -> Result<(), SysinspectError> { - let fresh_traits = minion_traits(&self.cfg, false); + let fresh_traits = minion_traits(&self.cfg, false, true); let mut r = MinionMessage::new(self.get_minion_id().to_string(), RequestType::Traits, fresh_traits.to_transport_value()?); r.set_sid(MINION_SID.to_string()); self.try_request( @@ -1295,7 +1310,7 @@ impl SysMinion { /// Send ehlo pub async fn send_ehlo(self: Arc) -> Result<(), SysinspectError> { - let fresh_traits = minion_traits(&self.cfg, false); + let fresh_traits = minion_traits(&self.cfg, false, false); let mut r = MinionMessage::new(dataconv::as_str(fresh_traits.get(traits::SYS_ID)), RequestType::Ehlo, fresh_traits.to_json_value()?); r.set_sid(MINION_SID.to_string()); @@ -1599,9 +1614,75 @@ impl SysMinion { Ok(()) } + /// Replace the running sysminion binary with a newer build from the master. + async fn upgrade_self(self: Arc, request: ConsoleMinionUpgradeSelfRequest, cycle_id: &str) { + log::info!("Self-upgrade to version {} (checksum {})", request.version.bright_yellow(), request.checksum.yellow()); + let data = match self.clone().download_file(&request.subpath).await { + Ok(d) => d, + Err(err) => { + self.as_ptr().send_command_reply(cycle_id, Err(err)).await; + return; + } + }; + let exe = match std::env::current_exe().and_then(std::fs::canonicalize) { + Ok(p) => p, + Err(err) => { + self.as_ptr().send_command_reply(cycle_id, Err(SysinspectError::IoErr(err))).await; + return; + } + }; + let stage = exe.with_extension("upgrade"); + let backup = exe.with_extension("old"); + if let Err(err) = std::fs::write(&stage, data) { + self.as_ptr().send_command_reply(cycle_id, Err(SysinspectError::IoErr(err))).await; + return; + } + if let Err(err) = std::fs::set_permissions(&stage, std::fs::Permissions::from_mode(0o755)) { + log::warn!("Unable to set upgrade binary permissions: {err}"); + } + let actual = match util::iofs::get_file_sha256(stage.clone()) { + Ok(s) => s, + Err(err) => { + let _ = std::fs::remove_file(&stage); + self.as_ptr().send_command_reply(cycle_id, Err(err)).await; + return; + } + }; + if actual != request.checksum { + let _ = std::fs::remove_file(&stage); + self.as_ptr() + .send_command_reply( + cycle_id, + Err(SysinspectError::MinionGeneralError(format!("Checksum mismatch: expected {}, got {}", request.checksum, actual))), + ) + .await; + return; + } + let _ = std::fs::remove_file(&backup); + if let Err(err) = std::fs::rename(&exe, &backup) { + let _ = std::fs::remove_file(&stage); + self.as_ptr().send_command_reply(cycle_id, Err(SysinspectError::IoErr(err))).await; + return; + } + if let Err(err) = std::fs::rename(&stage, &exe) { + let _ = std::fs::rename(&backup, &exe); + self.as_ptr().send_command_reply(cycle_id, Err(SysinspectError::IoErr(err))).await; + return; + } + self.as_ptr() + .send_command_reply(cycle_id, Ok(json!({"status": "upgrading", "version": request.version, "checksum": request.checksum}))) + .await; + log::info!("Restarting sysminion from {} after upgrade", exe.display().to_string().bright_green()); + if let Err(err) = Command::new(&exe).arg("--daemon").spawn() { + log::error!("Failed to start upgraded sysminion: {err}"); + } + tokio::time::sleep(Duration::from_millis(500)).await; + std::process::exit(0); + } + /// Download a file from master async fn download_file(self: Arc, fname: &str) -> Result, SysinspectError> { - async fn fetch_file(url: &str, filename: &str) -> Result { + async fn fetch_file(url: &str, filename: &str) -> Result, SysinspectError> { let url = format!("http://{}/{}", url.trim_end_matches('/'), filename.to_string().trim_start_matches('/')); let rsp = match reqwest::get(url.to_owned()).await { Ok(rsp) => rsp, @@ -1610,16 +1691,14 @@ impl SysMinion { } }; - Ok(match rsp.status() { - reqwest::StatusCode::OK => match rsp.text().await { - Ok(data) => data, - Err(err) => { - return Err(SysinspectError::MinionGeneralError(format!("Unable to get text from the file: {err}"))); - } + match rsp.status() { + reqwest::StatusCode::OK => match rsp.bytes().await { + Ok(data) => Ok(data.to_vec()), + Err(err) => Err(SysinspectError::MinionGeneralError(format!("Unable to read bytes from the file: {err}"))), }, - reqwest::StatusCode::NOT_FOUND => return Err(SysinspectError::MinionGeneralError("File not found".to_string())), - _ => return Err(SysinspectError::MinionGeneralError("Unknown status".to_string())), - }) + reqwest::StatusCode::NOT_FOUND => Err(SysinspectError::MinionGeneralError("File not found".to_string())), + _ => Err(SysinspectError::MinionGeneralError("Unknown status".to_string())), + } } let addr = self.cfg.fileserver(); let fname = fname.to_string(); @@ -1627,7 +1706,7 @@ impl SysMinion { match fetch_file(&addr, &fname).await { Ok(data) => { log::debug!("Filename: {fname} contains {} bytes", data.len()); - Some(data.into_bytes()) + Some(data) } Err(err) => { log::error!("Error while downloading file {fname}: {err}"); @@ -1734,7 +1813,7 @@ impl SysMinion { sr.set_state(mqr_guard.state()); sr.set_entities(mqr_guard.entities()); sr.set_checkbook_labels(mqr_guard.checkbook_labels()); - sr.set_traits(minion_traits(&self.cfg, false)); + sr.set_traits(minion_traits(&self.cfg, false, false)); sr.set_context(context::get_context(context)); sr.add_action_callback(Box::new(ActionResponseCallback::new(self.as_ptr(), cycle_id, scheme))); @@ -1891,6 +1970,19 @@ impl SysMinion { }); self.as_ptr().send_command_reply(cycle_id, payload).await; } + CLUSTER_MINION_UPGRADE_SELF => match serde_json::from_str::(context) { + Ok(request) => { + self.clone().upgrade_self(request, cycle_id).await; + } + Err(err) => { + self.as_ptr() + .send_command_reply( + cycle_id, + Err(SysinspectError::DeserializationError(format!("Failed to parse self-upgrade request: {err}"))), + ) + .await; + } + }, _ => { log::warn!("Unknown command: {cmd}"); } @@ -1904,7 +1996,7 @@ impl SysMinion { log::error!("Cycle ID is empty!"); return; } - if !matches_target(&cmd, self.get_minion_id(), &minion_traits(&self.cfg, true)) { + if !matches_target(&cmd, self.get_minion_id(), &minion_traits(&self.cfg, true, false)) { log::debug!("Command was dropped as it targets another minion"); return; } From 1e5dfe95c0a3148e668f2f333a87f876b53e4e23 Mon Sep 17 00:00:00 2001 From: Bo Maryniuk Date: Tue, 16 Jun 2026 01:54:12 +0200 Subject: [PATCH 02/15] Add state to the infoping example model --- examples/demos/infoping/model.cfg | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/examples/demos/infoping/model.cfg b/examples/demos/infoping/model.cfg index c91c8c11..cfb766d3 100644 --- a/examples/demos/infoping/model.cfg +++ b/examples/demos/infoping/model.cfg @@ -203,6 +203,12 @@ actions: args: host: "claim(connectivity.target)" count: "claim(connectivity.ping-count)" + internet: + opts: + - ping + args: + host: google.com + count: 2 check-sshd: descr: Verify SSH daemon status From 75d424f8eeed49632d3e45cc57f7e3110bbbe835 Mon Sep 17 00:00:00 2001 From: Bo Maryniuk Date: Tue, 16 Jun 2026 01:54:54 +0200 Subject: [PATCH 03/15] Fix the cluster update notification --- src/ui/elements.rs | 10 +++++++++- src/ui/mod.rs | 42 ++++++++++++++++++++++++++++++++-------- sysmaster/src/console.rs | 6 +++--- sysminion/src/main.rs | 2 +- 4 files changed, 47 insertions(+), 13 deletions(-) diff --git a/src/ui/elements.rs b/src/ui/elements.rs index 413a85ca..b79d3e0e 100644 --- a/src/ui/elements.rs +++ b/src/ui/elements.rs @@ -64,6 +64,14 @@ impl CycleListItem { return vec![Span::styled(display, Style::default().fg(palette::FG))]; } + if let Some((model, label)) = display.split_once(':') { + return vec![ + Span::styled(model.to_string(), Style::default().fg(palette::PROCESSING_BASE)), + Span::styled(":", Style::default().fg(palette::FG)), + Span::styled(label.to_string(), Style::default().fg(palette::PROCESSING_PEAK)), + ]; + } + let parts: Vec<&str> = display.split('/').collect(); match parts.as_slice() { [model, target] => vec![ @@ -76,7 +84,7 @@ impl CycleListItem { Span::styled("/", Style::default().fg(palette::FG)), Span::styled((*target).to_string(), Style::default().fg(palette::PROCESSING_HEAT)), Span::styled("/", Style::default().fg(palette::FG)), - Span::styled((*state).to_string(), Style::default().fg(palette::PROCESSING_PEAK)), + Span::styled((*state).to_string(), Style::default().fg(palette::PRIMARY)), ], _ => vec![Span::styled(display, Style::default().fg(palette::FG))], } diff --git a/src/ui/mod.rs b/src/ui/mod.rs index 78c7e06d..0818335a 100644 --- a/src/ui/mod.rs +++ b/src/ui/mod.rs @@ -285,6 +285,7 @@ pub struct SysInspectUX { pub cluster_upgrade_task: Option>, pub cluster_upgrade_required_count: usize, pub cluster_upgrade_unreachable_count: usize, + pub cluster_upgrade_check_message: Option, // Exit-after-popup state (for setup config-written notice) pub pending_exit: bool, @@ -407,6 +408,7 @@ impl Default for SysInspectUX { cluster_upgrade_task: None, cluster_upgrade_required_count: 0, cluster_upgrade_unreachable_count: 0, + cluster_upgrade_check_message: None, pending_exit: false, pending_exit_message: None, @@ -590,6 +592,13 @@ impl SysInspectUX { if self.repo_manager.visible { self.status_at_repo_manager(); } + if let Some(ref msg) = self.cluster_upgrade_check_message + && self.cluster_upgrade_required_count == 0 + { + let mut spans: Vec = self.status_text.clone().spans.to_vec(); + spans.push(Span::styled(format!(" {}", msg), Style::default().fg(palette::MUTED))); + self.status_text = Line::from(spans); + } term.draw(|frame| self.draw(frame))?; self.on_events()?; if self.offline && self.last_reconnect_attempt.elapsed() >= Duration::from_secs(5) { @@ -1586,21 +1595,38 @@ impl SysInspectUX { } fn refresh_cluster_upgrade_status(&mut self) { - if let Ok((required, unreachable)) = self.fetch_cluster_upgrade_status() { - self.cluster_upgrade_required_count = required; - self.cluster_upgrade_unreachable_count = unreachable; + match self.fetch_cluster_upgrade_status() { + Ok((required, unreachable)) => { + self.cluster_upgrade_required_count = required; + self.cluster_upgrade_unreachable_count = unreachable; + } + Err(e) => { + self.error_alert_visible = true; + self.error_alert_message = format!("Cannot fetch upgrade status: {e}"); + } } } fn mark_cluster_upgrade_required(&mut self) { - let _ = tokio::task::block_in_place(|| { + match tokio::task::block_in_place(|| { tokio::runtime::Handle::current().block_on(async { call_master_console(&self.cfg, &format!("{SCHEME_COMMAND}{CLUSTER_MARK_UPGRADE_REQUIRED}"), "*", None, None, None).await }) - }); - self.refresh_cluster_upgrade_status(); - if self.minions_visible { - self.refresh_minions(); + }) { + Ok(resp) => { + self.refresh_cluster_upgrade_status(); + if self.minions_visible { + self.refresh_minions(); + } + if let ConsolePayload::Ack { count, items, .. } = &resp.payload { + self.cluster_upgrade_check_message = + Some(format!("{} marked, {}", count, items.first().map(|s| s.as_str()).unwrap_or("no response"))); + } + } + Err(e) => { + self.error_alert_visible = true; + self.error_alert_message = format!("Cannot mark cluster upgrade: {e}"); + } } } diff --git a/sysmaster/src/console.rs b/sysmaster/src/console.rs index 3abdcf46..059b076e 100644 --- a/sysmaster/src/console.rs +++ b/sysmaster/src/console.rs @@ -286,7 +286,7 @@ impl SysMaster { let (fqdn, hostname, ip) = Self::preferred_host(&minion, cmdb.as_ref()); let current_version = minion.get_traits().get("minion.version").and_then(|v| v.as_str()).unwrap_or_default().to_string(); let current_sha = minion.get_traits().get("minion.binary.sha256").and_then(|v| v.as_str()).unwrap_or_default().to_string(); - let os_dist = minion.get_traits().get("system.os.distribution").and_then(|v| v.as_str()).unwrap_or_default().to_string(); + let os_dist = minion.get_traits().get("system.os.name").and_then(|v| v.as_str()).unwrap_or_default().to_lowercase(); let arch = minion.get_traits().get("system.arch").and_then(|v| v.as_str()).unwrap_or_default().to_string(); let target_sha = repo_checksums.get(&(os_dist.clone(), arch.clone())).cloned().unwrap_or_default(); let target_version = repo_versions.get(&(os_dist.clone(), arch)).cloned().unwrap_or_default(); @@ -325,7 +325,7 @@ impl SysMaster { let Some(minion) = self.mreg.lock().await.get(&mid)? else { continue; }; - let platform = minion.get_traits().get("system.os.distribution").and_then(|v| v.as_str()).unwrap_or_default().to_string(); + let platform = minion.get_traits().get("system.os.name").and_then(|v| v.as_str()).unwrap_or_default().to_lowercase(); let arch = minion.get_traits().get("system.arch").and_then(|v| v.as_str()).unwrap_or_default().to_string(); let current_sha = minion.get_traits().get("minion.binary.sha256").and_then(|v| v.as_str()).unwrap_or_default().to_string(); if let Some(build) = repo_builds.get(&(platform, arch)) { @@ -421,7 +421,7 @@ impl SysMaster { continue; }; - let platform = minion.get_traits().get("system.os.distribution").and_then(|v| v.as_str()).unwrap_or_default().to_string(); + let platform = minion.get_traits().get("system.os.name").and_then(|v| v.as_str()).unwrap_or_default().to_lowercase(); let arch = minion.get_traits().get("system.arch").and_then(|v| v.as_str()).unwrap_or_default().to_string(); let Some(build) = repo_builds.get(&(platform, arch)) else { skipped += 1; diff --git a/sysminion/src/main.rs b/sysminion/src/main.rs index 70088e27..85a222ee 100644 --- a/sysminion/src/main.rs +++ b/sysminion/src/main.rs @@ -281,7 +281,7 @@ fn main() -> std::io::Result<()> { 2.. => LevelFilter::max(), }) }) { - println!("Error setting logger output: {err}!"); + println!("Error setting logger output: {err}"); } // Start From a7e8651c00f2123efbc9a2c02d62640710eb573b Mon Sep 17 00:00:00 2001 From: Bo Maryniuk Date: Tue, 16 Jun 2026 02:05:00 +0200 Subject: [PATCH 04/15] Add UT for cluster upgrade feature --- sysmaster/src/registry/mreg_ut.rs | 377 +++++++++++++++++++++--------- 1 file changed, 268 insertions(+), 109 deletions(-) diff --git a/sysmaster/src/registry/mreg_ut.rs b/sysmaster/src/registry/mreg_ut.rs index f387726f..d4f5540d 100644 --- a/sysmaster/src/registry/mreg_ut.rs +++ b/sysmaster/src/registry/mreg_ut.rs @@ -16,151 +16,310 @@ fn registry_with_one_minion() -> MinionRegistry { registry } +// --------------------------------------------------------------------------- +// Upgrade marker persistence +// --------------------------------------------------------------------------- + +#[test] +fn mark_upgrade_required_stores_marker() { + let tmp = tempfile::tempdir().unwrap(); + let registry = MinionRegistry::new(tmp.path().to_path_buf()).unwrap(); + + registry.mark_upgrade_required("mid-1", "0.5.0", "abcdef123456").unwrap(); + + let marker = registry.get_upgrade_marker("mid-1").unwrap().unwrap(); + assert_eq!(marker.checksum(), "abcdef123456"); + assert!(!marker.unreachable); + assert!(registry.is_upgrade_required("mid-1").unwrap()); +} + +#[test] +fn clear_upgrade_required_removes_marker() { + let tmp = tempfile::tempdir().unwrap(); + let registry = MinionRegistry::new(tmp.path().to_path_buf()).unwrap(); + + registry.mark_upgrade_required("mid-1", "0.5.0", "abc").unwrap(); + assert!(registry.is_upgrade_required("mid-1").unwrap()); + + registry.clear_upgrade_required("mid-1").unwrap(); + assert!(!registry.is_upgrade_required("mid-1").unwrap()); + assert!(registry.get_upgrade_marker("mid-1").unwrap().is_none()); +} + +#[test] +fn is_upgrade_required_true_false_unknown() { + let tmp = tempfile::tempdir().unwrap(); + let registry = MinionRegistry::new(tmp.path().to_path_buf()).unwrap(); + + // Never marked + assert!(!registry.is_upgrade_required("ghost").unwrap()); + + // Marked + registry.mark_upgrade_required("mid-1", "0.5.0", "sha").unwrap(); + assert!(registry.is_upgrade_required("mid-1").unwrap()); + + // Cleared + registry.clear_upgrade_required("mid-1").unwrap(); + assert!(!registry.is_upgrade_required("mid-1").unwrap()); +} + +#[test] +fn mark_upgrade_required_overwrites_previous() { + let tmp = tempfile::tempdir().unwrap(); + let registry = MinionRegistry::new(tmp.path().to_path_buf()).unwrap(); + + registry.mark_upgrade_required("mid-1", "0.4.0", "old-sha").unwrap(); + assert_eq!(registry.get_upgrade_marker("mid-1").unwrap().unwrap().checksum(), "old-sha"); + + registry.mark_upgrade_required("mid-1", "0.5.0", "new-sha").unwrap(); + assert_eq!(registry.get_upgrade_marker("mid-1").unwrap().unwrap().checksum(), "new-sha"); +} + #[test] -fn get_by_hostname_or_ip_matches_plain_hostname() { - let mut registry = registry_with_one_minion(); - let records = registry.get_by_hostname_or_ip("alien").unwrap(); +fn marker_survives_registry_reopen() { + let tmp = tempfile::tempdir().unwrap(); + let db_path = tmp.path().to_path_buf(); - assert_eq!(records.len(), 1); - assert_eq!(records[0].id(), "30006546535e428aba0a0caa6712e225"); + { + let registry = MinionRegistry::new(db_path.clone()).unwrap(); + registry.mark_upgrade_required("mid-1", "0.5.0", "persist-sha").unwrap(); + } + + let registry = MinionRegistry::new(db_path).unwrap(); + let marker = registry.get_upgrade_marker("mid-1").unwrap().unwrap(); + assert_eq!(marker.checksum(), "persist-sha"); + assert!(!marker.unreachable); } +// --------------------------------------------------------------------------- +// Status counts and ID listing +// --------------------------------------------------------------------------- + #[test] -fn get_by_query_matches_plain_hostname() { - let registry = registry_with_one_minion(); - let records = registry.get_by_query("alien").unwrap(); +fn upgrade_status_counts_empty_registry() { + let tmp = tempfile::tempdir().unwrap(); + let registry = MinionRegistry::new(tmp.path().to_path_buf()).unwrap(); - assert_eq!(records.len(), 1); - assert_eq!(records[0].id(), "30006546535e428aba0a0caa6712e225"); + assert_eq!(registry.upgrade_status_counts().unwrap(), (0, 0)); } -#[tokio::test] -async fn targeted_minions_resolve_plain_hostname_from_id_slot() { - let mut registry = registry_with_one_minion(); - let ids = registry.get_targeted_minions(&MinionTarget::new("alien", ""), true).await; +#[test] +fn upgrade_status_counts_multiple_minions() { + let tmp = tempfile::tempdir().unwrap(); + let registry = MinionRegistry::new(tmp.path().to_path_buf()).unwrap(); - assert_eq!(ids, vec!["30006546535e428aba0a0caa6712e225"]); + registry.mark_upgrade_required("mid-1", "0.5.0", "sha-1").unwrap(); + registry.mark_upgrade_required("mid-2", "0.5.0", "sha-2").unwrap(); + registry.mark_upgrade_required("mid-3", "0.5.0", "sha-3").unwrap(); + registry.mark_upgrade_unreachable("mid-2").unwrap(); + + let (required, unreachable) = registry.upgrade_status_counts().unwrap(); + assert_eq!(required, 3); + assert_eq!(unreachable, 1); + + let marker = registry.get_upgrade_marker("mid-2").unwrap().unwrap(); + assert!(marker.unreachable); } -#[tokio::test] -async fn targeted_minions_resolve_partial_id_prefix_from_id_slot() { - let mut registry = registry_with_one_minion(); - let ids = registry.get_targeted_minions(&MinionTarget::new("3000", ""), true).await; +#[test] +fn get_upgrade_required_ids_sorted() { + let tmp = tempfile::tempdir().unwrap(); + let registry = MinionRegistry::new(tmp.path().to_path_buf()).unwrap(); + + registry.mark_upgrade_required("zulu", "0.5.0", "sha").unwrap(); + registry.mark_upgrade_required("alpha", "0.5.0", "sha").unwrap(); + registry.mark_upgrade_required("charlie", "0.5.0", "sha").unwrap(); - assert_eq!(ids, vec!["30006546535e428aba0a0caa6712e225"]); + assert_eq!(registry.get_upgrade_required_ids().unwrap(), vec!["alpha", "charlie", "zulu"]); } -#[tokio::test] -async fn targeted_minions_resolve_traits_query() { - let mut registry = registry_with_one_minion(); - let mut target = MinionTarget::default(); - target.set_traits_query("system.hostname: alien"); +#[test] +fn get_upgrade_required_ids_empty_after_clear() { + let tmp = tempfile::tempdir().unwrap(); + let registry = MinionRegistry::new(tmp.path().to_path_buf()).unwrap(); - let ids = registry.get_targeted_minions(&target, true).await; + registry.mark_upgrade_required("mid-1", "0.5.0", "sha").unwrap(); + assert!(!registry.get_upgrade_required_ids().unwrap().is_empty()); - assert_eq!(ids, vec!["30006546535e428aba0a0caa6712e225"]); + registry.clear_upgrade_required("mid-1").unwrap(); + assert!(registry.get_upgrade_required_ids().unwrap().is_empty()); } -#[tokio::test] -async fn targeted_minions_require_hostname_and_traits_when_both_present() { - let mut registry = registry_with_one_minion(); - let mut target = MinionTarget::default(); - target.add_hostname("alien*"); - target.set_traits_query("system.hostname: alien"); +// --------------------------------------------------------------------------- +// Unreachable flag +// --------------------------------------------------------------------------- + +#[test] +fn mark_upgrade_unreachable_sets_flag_on_existing_marker() { + let tmp = tempfile::tempdir().unwrap(); + let registry = MinionRegistry::new(tmp.path().to_path_buf()).unwrap(); + + registry.mark_upgrade_required("mid-1", "0.5.0", "sha").unwrap(); + registry.mark_upgrade_unreachable("mid-1").unwrap(); + + let marker = registry.get_upgrade_marker("mid-1").unwrap().unwrap(); + assert!(marker.unreachable); + assert_eq!(registry.upgrade_status_counts().unwrap(), (1, 1)); +} - let ids = registry.get_targeted_minions(&target, true).await; - assert_eq!(ids, vec!["30006546535e428aba0a0caa6712e225"]); +#[test] +fn mark_upgrade_unreachable_noop_for_unknown_mid() { + let tmp = tempfile::tempdir().unwrap(); + let registry = MinionRegistry::new(tmp.path().to_path_buf()).unwrap(); - target.set_traits_query("system.hostname: wrong"); - assert!(registry.get_targeted_minions(&target, true).await.is_empty()); + // No marker exists — should not error + registry.mark_upgrade_unreachable("ghost").unwrap(); + assert_eq!(registry.upgrade_status_counts().unwrap(), (0, 0)); } +// --------------------------------------------------------------------------- +// Clear all markers +// --------------------------------------------------------------------------- + #[test] -fn cmdb_records_track_registered_startup_and_observed_host_data() { +fn clear_all_upgrade_markers_empties_everything() { + let tmp = tempfile::tempdir().unwrap(); + let registry = MinionRegistry::new(tmp.path().to_path_buf()).unwrap(); + + registry.mark_upgrade_required("mid-1", "0.5.0", "sha").unwrap(); + registry.mark_upgrade_required("mid-2", "0.5.0", "sha").unwrap(); + registry.mark_upgrade_required("mid-3", "0.5.0", "sha").unwrap(); + + registry.clear_all_upgrade_markers().unwrap(); + + assert_eq!(registry.upgrade_status_counts().unwrap(), (0, 0)); + assert!(registry.get_upgrade_required_ids().unwrap().is_empty()); +} + +// --------------------------------------------------------------------------- +// Checksum-match auto-clear +// --------------------------------------------------------------------------- + +#[test] +fn clear_upgrade_if_checksum_matches_clears_on_exact_match() { let tmp = tempfile::tempdir().unwrap(); let mut registry = MinionRegistry::new(tmp.path().to_path_buf()).unwrap(); - registry.ensure_cmdb_registered("mid-1").unwrap(); - let base = registry.get_cmdb("mid-1").unwrap().unwrap(); - assert_eq!(base.mid(), "mid-1"); - assert_eq!(base.host(), None); + let mut traits = HashMap::new(); + traits.insert("minion.binary.sha256".to_string(), json!("abcdef123456")); + registry.refresh("mid-1", traits.clone(), BTreeSet::new(), BTreeSet::new()).unwrap(); - registry - .upsert_cmdb_startup( - "mid-1", - MinionCmdbStartup::new( - "bo".to_string(), - "192.168.122.105".to_string(), - "/home/bo/sysinspect".to_string(), - "/home/bo/sysinspect/bin/sysminion".to_string(), - "/home/bo/sysinspect/etc/sysinspect.conf".to_string(), - "hopstart".to_string(), - ), - ) - .unwrap(); + registry.mark_upgrade_required("mid-1", "0.5.0", "abcdef123456").unwrap(); + assert!(registry.is_upgrade_required("mid-1").unwrap()); + + registry.clear_upgrade_required_if_checksum_matches("mid-1", &traits).unwrap(); + assert!(!registry.is_upgrade_required("mid-1").unwrap()); +} + +#[test] +fn clear_upgrade_if_checksum_matches_keeps_on_mismatch() { + let tmp = tempfile::tempdir().unwrap(); + let mut registry = MinionRegistry::new(tmp.path().to_path_buf()).unwrap(); let mut traits = HashMap::new(); - traits.insert("system.hostname".to_string(), json!("demo")); - traits.insert("system.hostname.fqdn".to_string(), json!("demo.lab")); - traits.insert("system.hostname.ip".to_string(), json!("192.168.122.105")); - registry.refresh_cmdb_observed("mid-1", &traits).unwrap(); + traits.insert("minion.binary.sha256".to_string(), json!("old-minion-sha")); + registry.refresh("mid-1", traits.clone(), BTreeSet::new(), BTreeSet::new()).unwrap(); + + registry.mark_upgrade_required("mid-1", "0.5.0", "new-repo-sha").unwrap(); + assert!(registry.is_upgrade_required("mid-1").unwrap()); + + // Different SHA — marker stays + registry.clear_upgrade_required_if_checksum_matches("mid-1", &traits).unwrap(); + assert!(registry.is_upgrade_required("mid-1").unwrap()); +} + +#[test] +fn clear_upgrade_if_checksum_matches_skips_without_trait() { + let tmp = tempfile::tempdir().unwrap(); + let mut registry = MinionRegistry::new(tmp.path().to_path_buf()).unwrap(); + + let traits_without_sha: HashMap = HashMap::new(); + registry.refresh("mid-1", traits_without_sha.clone(), BTreeSet::new(), BTreeSet::new()).unwrap(); - let cmdb = registry.get_cmdb("mid-1").unwrap().unwrap(); - assert_eq!(cmdb.user(), Some("bo")); - assert_eq!(cmdb.host(), Some("192.168.122.105")); - assert_eq!(cmdb.root(), Some("/home/bo/sysinspect")); - assert_eq!(cmdb.bin(), Some("/home/bo/sysinspect/bin/sysminion")); - assert_eq!(cmdb.config(), Some("/home/bo/sysinspect/etc/sysinspect.conf")); - assert_eq!(cmdb.backend(), Some("hopstart")); - assert_eq!(cmdb.hostname(), Some("demo")); - assert_eq!(cmdb.fqdn(), Some("demo.lab")); - assert_eq!(cmdb.ip(), Some("192.168.122.105")); + registry.mark_upgrade_required("mid-1", "0.5.0", "sha").unwrap(); + assert!(registry.is_upgrade_required("mid-1").unwrap()); - registry.remove("mid-1").unwrap(); - assert!(registry.get_cmdb("mid-1").unwrap().is_none()); + // No `minion.binary.sha256` trait — should not clear + registry.clear_upgrade_required_if_checksum_matches("mid-1", &traits_without_sha).unwrap(); + assert!(registry.is_upgrade_required("mid-1").unwrap()); } #[test] -fn stale_cmdb_reconcile_refreshes_host_facts_but_preserves_startup_identity() { +fn clear_upgrade_if_checksum_matches_skips_without_marker() { let tmp = tempfile::tempdir().unwrap(); let mut registry = MinionRegistry::new(tmp.path().to_path_buf()).unwrap(); let mut traits = HashMap::new(); - traits.insert("system.hostname".to_string(), json!("demo")); - traits.insert("system.hostname.fqdn".to_string(), json!("demo.lab")); - traits.insert("system.hostname.ip".to_string(), json!("192.168.122.105")); - registry.refresh("mid-1", traits, BTreeSet::new(), BTreeSet::new()).unwrap(); - registry.ensure_cmdb_registered("mid-1").unwrap(); - registry - .upsert_cmdb_startup( - "mid-1", - MinionCmdbStartup::new( - "bo".to_string(), - "requested-host".to_string(), - "/home/bo/sysinspect".to_string(), - "/home/bo/sysinspect/bin/sysminion".to_string(), - "/home/bo/sysinspect/etc/sysinspect.conf".to_string(), - "hopstart".to_string(), - ), - ) - .unwrap(); - - let mut stale = registry.get_cmdb("mid-1").unwrap().unwrap(); - stale.set_updated_at(Utc::now() - chrono::Duration::days(8)); - registry.add_cmdb("mid-1", &stale).unwrap(); - - assert!(registry.reconcile_cmdb("mid-1", std::time::Duration::from_secs(7 * 24 * 60 * 60)).unwrap()); - - let cmdb = registry.get_cmdb("mid-1").unwrap().unwrap(); - assert_eq!(cmdb.mid(), "mid-1"); - assert_eq!(cmdb.user(), Some("bo")); - assert_eq!(cmdb.host(), Some("requested-host")); - assert_eq!(cmdb.root(), Some("/home/bo/sysinspect")); - assert_eq!(cmdb.bin(), Some("/home/bo/sysinspect/bin/sysminion")); - assert_eq!(cmdb.config(), Some("/home/bo/sysinspect/etc/sysinspect.conf")); - assert_eq!(cmdb.backend(), Some("hopstart")); - assert_eq!(cmdb.hostname(), Some("demo")); - assert_eq!(cmdb.fqdn(), Some("demo.lab")); - assert_eq!(cmdb.ip(), Some("192.168.122.105")); - assert!(!cmdb.is_stale(std::time::Duration::from_secs(7 * 24 * 60 * 60))); + traits.insert("minion.binary.sha256".to_string(), json!("sha")); + registry.refresh("mid-1", traits.clone(), BTreeSet::new(), BTreeSet::new()).unwrap(); + + // No marker exists — should not error + registry.clear_upgrade_required_if_checksum_matches("mid-1", &traits).unwrap(); + assert!(!registry.is_upgrade_required("mid-1").unwrap()); +} + +#[test] +fn clear_upgrade_if_checksum_matches_preserves_other_minions() { + let tmp = tempfile::tempdir().unwrap(); + let mut registry = MinionRegistry::new(tmp.path().to_path_buf()).unwrap(); + + let mut traits_1 = HashMap::new(); + traits_1.insert("minion.binary.sha256".to_string(), json!("sha-1")); + registry.refresh("mid-1", traits_1.clone(), BTreeSet::new(), BTreeSet::new()).unwrap(); + + let mut traits_2 = HashMap::new(); + traits_2.insert("minion.binary.sha256".to_string(), json!("sha-2")); + registry.refresh("mid-2", traits_2.clone(), BTreeSet::new(), BTreeSet::new()).unwrap(); + + registry.mark_upgrade_required("mid-1", "0.5.0", "sha-1").unwrap(); + registry.mark_upgrade_required("mid-2", "0.5.0", "sha-2").unwrap(); + + // Only mid-1 matches + registry.clear_upgrade_required_if_checksum_matches("mid-1", &traits_1).unwrap(); + + assert!(!registry.is_upgrade_required("mid-1").unwrap()); + assert!(registry.is_upgrade_required("mid-2").unwrap()); + assert_eq!(registry.upgrade_status_counts().unwrap(), (1, 0)); +} + +// --------------------------------------------------------------------------- +// Full lifecycle +// --------------------------------------------------------------------------- + +#[test] +fn full_upgrade_lifecycle_register_mark_simulate_upgrade_autoclear() { + let tmp = tempfile::tempdir().unwrap(); + let mut registry = MinionRegistry::new(tmp.path().to_path_buf()).unwrap(); + + // Minion registers with old binary + let mut old_traits = HashMap::new(); + old_traits.insert("system.hostname".to_string(), json!("alien")); + old_traits.insert("system.os.name".to_string(), json!("Linux")); + old_traits.insert("system.arch".to_string(), json!("x86_64")); + old_traits.insert("minion.version".to_string(), json!("0.4.0")); + old_traits.insert("minion.binary.sha256".to_string(), json!("old-sha-256")); + registry.refresh("mid-1", old_traits, BTreeSet::new(), BTreeSet::new()).unwrap(); + + // Platform added to repo — master marks upgrade + registry.mark_upgrade_required("mid-1", "0.5.0", "new-sha-256").unwrap(); + assert!(registry.is_upgrade_required("mid-1").unwrap()); + assert_eq!(registry.upgrade_status_counts().unwrap(), (1, 0)); + + // Minion self-upgrades, restarts — sends new traits + let mut new_traits = HashMap::new(); + new_traits.insert("minion.binary.sha256".to_string(), json!("new-sha-256")); + registry.refresh("mid-1", new_traits.clone(), BTreeSet::new(), BTreeSet::new()).unwrap(); + + // Master auto-clears on checksum match + registry.clear_upgrade_required_if_checksum_matches("mid-1", &new_traits).unwrap(); + assert!(!registry.is_upgrade_required("mid-1").unwrap()); + assert_eq!(registry.upgrade_status_counts().unwrap(), (0, 0)); +} + +#[test] +fn get_upgrade_marker_returns_none_for_unknown() { + let tmp = tempfile::tempdir().unwrap(); + let registry = MinionRegistry::new(tmp.path().to_path_buf()).unwrap(); + + assert!(registry.get_upgrade_marker("nobody").unwrap().is_none()); } From c12058fe7d87e0d5fa2f65804ba9602bfe22f6af Mon Sep 17 00:00:00 2001 From: Bo Maryniuk Date: Tue, 16 Jun 2026 12:52:59 +0200 Subject: [PATCH 05/15] Precompute SHA256 checksum on minion start in background. --- sysminion/src/minion.rs | 33 +++++++++++++++++++++++++++++---- 1 file changed, 29 insertions(+), 4 deletions(-) diff --git a/sysminion/src/minion.rs b/sysminion/src/minion.rs index b5892c58..33a0e06b 100644 --- a/sysminion/src/minion.rs +++ b/sysminion/src/minion.rs @@ -84,6 +84,7 @@ use std::{ path::{Path, PathBuf}, process::Command, sync::Arc, + sync::OnceLock, sync::RwLock, sync::atomic::{AtomicBool, AtomicU64, Ordering}, time::{Duration, Instant}, @@ -143,8 +144,7 @@ impl LogRingBuffers { pub static LOG_RING: Lazy> = Lazy::new(|| RwLock::new(LogRingBuffers::new(LOG_RING_CAPACITY))); static MINION_BINARY_PATH: Lazy> = Lazy::new(|| std::env::current_exe().ok().map(|path| path.display().to_string())); -static MINION_BINARY_SHA256: Lazy> = - Lazy::new(|| std::env::current_exe().ok().and_then(|path| util::iofs::get_file_sha256(path).ok())); +static MINION_BINARY_SHA256: OnceLock = OnceLock::new(); #[derive(Debug, Clone, Copy, Default)] struct BacklogSnapshot { @@ -173,7 +173,7 @@ fn minion_traits(cfg: &MinionConfig, q: bool, include_binary_sha: bool) -> Syste if let Some(path) = &*MINION_BINARY_PATH { traits.put("minion.binary.path".to_string(), json!(path)); } - if include_binary_sha && let Some(sha256) = &*MINION_BINARY_SHA256 { + if include_binary_sha && let Some(sha256) = MINION_BINARY_SHA256.get() { traits.put("minion.binary.sha256".to_string(), json!(sha256)); } traits @@ -261,6 +261,28 @@ pub struct SysMinion { } impl SysMinion { + async fn ensure_binary_sha256() { + if MINION_BINARY_SHA256.get().is_some() { + return; + } + + let sha = tokio::task::spawn_blocking(|| { + if MINION_BINARY_SHA256.get().is_some() { + return None; + } + + let path = std::env::current_exe().ok()?; + util::iofs::get_file_sha256(path).ok() + }) + .await + .ok() + .flatten(); + + if let Some(sha) = sha { + let _ = MINION_BINARY_SHA256.set(sha); + } + } + fn classify_execution_failure(err: &SysinspectError) -> &'static str { let msg = err.to_string(); @@ -401,7 +423,10 @@ impl SysMinion { log::debug!("Initialisation done"); - Ok(Arc::new(instance)) + let instance = Arc::new(instance); + Self::ensure_binary_sha256().await; + + Ok(instance) } /// Initialise minion. From 7f7d059cad11b19da1d23397cbc4d9013c8ab096 Mon Sep 17 00:00:00 2001 From: Bo Maryniuk Date: Tue, 16 Jun 2026 16:49:33 +0200 Subject: [PATCH 06/15] Sysminion autoupdate from platforms --- libsysinspect/src/console/mod.rs | 3 + src/clifmt.rs | 2 +- src/ui/mod.rs | 13 +++-- sysmaster/src/console.rs | 9 ++- sysmaster/src/hopstart.rs | 22 ++++++-- sysmaster/src/master.rs | 34 +++++++++++ sysmaster/src/registry/mreg.rs | 96 ++++++++++++++++++++++++++++++++ sysmaster/src/registry/rec.rs | 7 +++ sysminion/src/main.rs | 50 ++--------------- sysminion/src/minion.rs | 6 ++ 10 files changed, 187 insertions(+), 55 deletions(-) diff --git a/libsysinspect/src/console/mod.rs b/libsysinspect/src/console/mod.rs index 3fced3d0..2e114349 100644 --- a/libsysinspect/src/console/mod.rs +++ b/libsysinspect/src/console/mod.rs @@ -353,6 +353,9 @@ pub enum ConsolePayload { required: usize, /// Number of marked minions last seen as unreachable during upgrade. unreachable: usize, + /// Number of minions pending post-upgrade auto-hopstart. + #[serde(default)] + pending_post_upgrade: usize, }, /// Result summary for one cluster upgrade run. UpgradeSummary { diff --git a/src/clifmt.rs b/src/clifmt.rs index aa009184..c73614c4 100644 --- a/src/clifmt.rs +++ b/src/clifmt.rs @@ -416,7 +416,7 @@ pub fn render_console_payload(payload: &ConsolePayload) -> String { ConsolePayload::MasterLogs { snapshot: _ } => String::new(), ConsolePayload::MasterModuleIndex { .. } => String::new(), ConsolePayload::MasterLibraryIndex { .. } => String::new(), - ConsolePayload::UpgradeStatus { required, unreachable } => { + ConsolePayload::UpgradeStatus { required, unreachable, .. } => { format!("Cluster upgrade status: required={required}, unreachable={unreachable}") } ConsolePayload::UpgradeSummary { updated, dispatched, skipped, failed, offline, items } => { diff --git a/src/ui/mod.rs b/src/ui/mod.rs index 0818335a..7be52efa 100644 --- a/src/ui/mod.rs +++ b/src/ui/mod.rs @@ -285,6 +285,7 @@ pub struct SysInspectUX { pub cluster_upgrade_task: Option>, pub cluster_upgrade_required_count: usize, pub cluster_upgrade_unreachable_count: usize, + pub cluster_upgrade_pending_count: usize, pub cluster_upgrade_check_message: Option, // Exit-after-popup state (for setup config-written notice) @@ -408,6 +409,7 @@ impl Default for SysInspectUX { cluster_upgrade_task: None, cluster_upgrade_required_count: 0, cluster_upgrade_unreachable_count: 0, + cluster_upgrade_pending_count: 0, cluster_upgrade_check_message: None, pending_exit: false, @@ -1596,9 +1598,10 @@ impl SysInspectUX { fn refresh_cluster_upgrade_status(&mut self) { match self.fetch_cluster_upgrade_status() { - Ok((required, unreachable)) => { + Ok((required, unreachable, pending)) => { self.cluster_upgrade_required_count = required; self.cluster_upgrade_unreachable_count = unreachable; + self.cluster_upgrade_pending_count = pending; } Err(e) => { self.error_alert_visible = true; @@ -4958,13 +4961,15 @@ impl SysInspectUX { }) } - pub fn fetch_cluster_upgrade_status(&self) -> Result<(usize, usize), SysinspectError> { + pub fn fetch_cluster_upgrade_status(&self) -> Result<(usize, usize, usize), SysinspectError> { tokio::task::block_in_place(|| { tokio::runtime::Handle::current().block_on(async { call_master_console(&self.cfg, &format!("{SCHEME_COMMAND}{CLUSTER_UPGRADE_STATUS}"), "*", None, None, None).await.map(|resp| { match resp.payload { - ConsolePayload::UpgradeStatus { required, unreachable } => (required, unreachable), - _ => (0, 0), + ConsolePayload::UpgradeStatus { required, unreachable, pending_post_upgrade } => { + (required, unreachable, pending_post_upgrade) + } + _ => (0, 0, 0), } }) }) diff --git a/sysmaster/src/console.rs b/sysmaster/src/console.rs index 059b076e..8f2eec4c 100644 --- a/sysmaster/src/console.rs +++ b/sysmaster/src/console.rs @@ -350,8 +350,10 @@ impl SysMaster { } async fn cluster_upgrade_status_console_response(&mut self) -> Result { - let (required, unreachable) = self.mreg.lock().await.upgrade_status_counts()?; - Ok(ConsoleResponse::ok(ConsolePayload::UpgradeStatus { required, unreachable })) + let mreg = self.mreg.lock().await; + let (required, unreachable) = mreg.upgrade_status_counts()?; + let pending_post_upgrade = mreg.post_upgrade_pending_count()?; + Ok(ConsoleResponse::ok(ConsolePayload::UpgradeStatus { required, unreachable, pending_post_upgrade })) } async fn upgrade_minion_over_ssh( @@ -450,6 +452,9 @@ impl SysMaster { Some(msg) => { messages.push(msg); dispatched += 1; + if let Some(c) = cmdb.as_ref() { + let _ = master.lock().await.mreg.lock().await.add_post_upgrade_pending(&mid, c); + } } None => { failed += 1; diff --git a/sysmaster/src/hopstart.rs b/sysmaster/src/hopstart.rs index 63add540..de747c8a 100644 --- a/sysmaster/src/hopstart.rs +++ b/sysmaster/src/hopstart.rs @@ -1,8 +1,22 @@ use colored::Colorize; use libcommon::SysinspectError; use libsysinspect::cfg::mmconf::HopstartConfig; +use std::sync::{Arc, OnceLock}; use tokio::{process::Command, sync::Semaphore}; +/// Shared semaphore capping concurrent hopstart SSH calls across all callers. +pub(crate) static HOPSTART_SEMAPHORE: OnceLock> = OnceLock::new(); + +/// Return the shared hopstart semaphore, falling back to allow-1 if not initialised. +pub(crate) fn hopstart_semaphore() -> Arc { + HOPSTART_SEMAPHORE.get().cloned().unwrap_or_else(|| Arc::new(Semaphore::new(1))) +} + +/// Initialise the shared hopstart semaphore from the configured batch size. +pub(crate) fn init_hopstart_semaphore(batch: usize) { + HOPSTART_SEMAPHORE.set(Arc::new(Semaphore::new(batch.max(1)))).ok(); +} + #[derive(Clone)] pub(crate) struct HopStartTarget { host: String, @@ -25,11 +39,11 @@ impl HopStartTarget { format!("{}@{}", self.user, self.host) } - fn log_issue(&self) { + pub(crate) fn log_issue(&self) { log::info!("Hop-start {} at {} as {}", self.host.yellow(), self.root.bright_white().bold(), self.user.bright_blue().bold()); } - async fn issue(&self) -> Result<(), SysinspectError> { + pub(crate) async fn issue(&self) -> Result<(), SysinspectError> { let status = Command::new("ssh").arg(self.ssh_target()).arg(self.remote_command()).status().await?; if status.success() { @@ -54,12 +68,12 @@ impl HopStarter { } pub(crate) async fn issue(&self, targets: Vec) { - let limit = std::sync::Arc::new(Semaphore::new(self.cfg.batch().max(1))); + let limit = hopstart_semaphore(); let mut tasks = Vec::with_capacity(targets.len()); for target in targets { tasks.push(tokio::spawn({ - let limit = std::sync::Arc::clone(&limit); + let limit = Arc::clone(&limit); async move { if let Ok(_permit) = limit.acquire_owned().await { target.log_issue(); diff --git a/sysmaster/src/master.rs b/sysmaster/src/master.rs index 24fe3097..1118bd24 100644 --- a/sysmaster/src/master.rs +++ b/sysmaster/src/master.rs @@ -686,6 +686,37 @@ impl SysMaster { if let Some(mid) = self.conn_to_mid.remove(minion_addr) { log::info!("Minion connection {} dropped; clearing session for {}", minion_addr, mid); self.get_session().lock().await.remove(&mid); + + // Auto-hopstart: if this minion was just dispatched for self-upgrade, + // try to bring it back up via SSH. + if let Ok(Some(pending)) = self.mreg.lock().await.get_post_upgrade_pending(&mid) { + if pending.managed_by_init().is_none_or(|m| !m) { + let target = crate::hopstart::HopStartTarget::new( + pending.host().to_string(), + pending.root().to_string(), + pending.user().to_string(), + pending.bin().to_string(), + pending.config().to_string(), + ); + let mreg = Arc::clone(&self.mreg); + let mid_clone = mid.clone(); + tokio::spawn(async move { + let sem = crate::hopstart::hopstart_semaphore(); + let _permit = sem.acquire_owned().await; + target.log_issue(); + match target.issue().await { + Ok(()) => { + let _ = mreg.lock().await.remove_post_upgrade_pending(&mid_clone); + } + Err(err) => { + log::warn!("Post-upgrade hopstart failed for {mid_clone}: {err}"); + } + } + }); + } else { + let _ = self.mreg.lock().await.remove_post_upgrade_pending(&mid); + } + } } else { log::debug!("Disconnect from {}, but no minion id mapped yet", minion_addr); } @@ -770,6 +801,8 @@ impl SysMaster { log::info!("{minion_id} connected successfully"); self.conn_to_mid.insert(minion_addr.to_string(), minion_id.to_string()); self.get_session().lock().await.ping(minion_id, Some(sid)); + // Clean up post-upgrade pending — minion came back online on its own. + let _ = self.mreg.lock().await.remove_post_upgrade_pending(minion_id); _ = bcast.send(self.msg_request_traits(minion_id.to_string(), sid.to_string())); log::info!("Syncing traits with minion at {minion_id}"); @@ -1649,6 +1682,7 @@ pub(crate) async fn master(cfg: MasterConfig) -> Result<(), SysinspectError> { { let mut m = master.lock().await; m.init().await?; + crate::hopstart::init_hopstart_semaphore(cfg.hopstart().batch()); log::info!("SysMaster initialized"); } diff --git a/sysmaster/src/registry/mreg.rs b/sysmaster/src/registry/mreg.rs index 3530dd85..75cb7d0b 100644 --- a/sysmaster/src/registry/mreg.rs +++ b/sysmaster/src/registry/mreg.rs @@ -19,6 +19,7 @@ use std::{ const DB_MINIONS: &str = "minions"; const DB_CMDB: &str = "cmdb"; const DB_UPGRADE: &str = "upgrade"; +const DB_POST_UPGRADE: &str = "post_upgrade"; #[derive(Debug, Clone, Serialize, Deserialize)] pub(crate) struct UpgradeMarker { @@ -47,6 +48,49 @@ impl Default for UpgradeMarker { } } +/// Snapshot of CMDB data stored alongside a self-upgrade dispatch +/// so the master can issue a hopstart when the minion drops offline. +#[derive(Debug, Clone, Serialize, Deserialize)] +pub(crate) struct PostUpgradePending { + dispatched_at: DateTime, + host: String, + user: String, + root: String, + bin: String, + config: String, + managed_by_init: Option, +} + +impl PostUpgradePending { + fn new(host: String, user: String, root: String, bin: String, config: String, managed_by_init: Option) -> Self { + Self { dispatched_at: Utc::now(), host, user, root, bin, config, managed_by_init } + } + + pub(crate) fn host(&self) -> &str { + &self.host + } + + pub(crate) fn user(&self) -> &str { + &self.user + } + + pub(crate) fn root(&self) -> &str { + &self.root + } + + pub(crate) fn bin(&self) -> &str { + &self.bin + } + + pub(crate) fn config(&self) -> &str { + &self.config + } + + pub(crate) fn managed_by_init(&self) -> Option { + self.managed_by_init + } +} + #[derive(Debug, Clone)] pub struct MinionRegistry { conn: Db, @@ -344,6 +388,58 @@ impl MinionRegistry { Ok((required, unreachable)) } + // ----------------------------------------------------------------------- + // Post-upgrade pending — auto-hopstart after self-upgrade + // ----------------------------------------------------------------------- + + pub fn add_post_upgrade_pending(&self, mid: &str, cmdb: &crate::registry::rec::MinionCmdbRecord) -> Result<(), SysinspectError> { + let tree = self.get_tree(DB_POST_UPGRADE)?; + let pending = json!(PostUpgradePending::new( + cmdb.host().unwrap_or_default().to_string(), + cmdb.user().unwrap_or_default().to_string(), + cmdb.root().unwrap_or_default().to_string(), + cmdb.bin().unwrap_or_default().to_string(), + cmdb.config().unwrap_or_default().to_string(), + cmdb.managed_by_init(), + )); + tree.insert(mid, pending.to_string().into_bytes())?; + Ok(()) + } + + pub fn remove_post_upgrade_pending(&self, mid: &str) -> Result<(), SysinspectError> { + let tree = self.get_tree(DB_POST_UPGRADE)?; + tree.remove(mid)?; + Ok(()) + } + + pub fn get_post_upgrade_pending(&self, mid: &str) -> Result, SysinspectError> { + let tree = self.get_tree(DB_POST_UPGRADE)?; + let Some(raw) = tree.get(mid)? else { + return Ok(None); + }; + let marker = serde_json::from_str::( + &String::from_utf8(raw.to_vec()).map_err(|err| SysinspectError::MasterGeneralError(format!("{err}")))?, + ) + .map_err(|err| SysinspectError::MasterGeneralError(format!("{err}")))?; + Ok(Some(marker)) + } + + pub fn has_post_upgrade_pending(&self, mid: &str) -> Result { + let tree = self.get_tree(DB_POST_UPGRADE)?; + Ok(tree.contains_key(mid)?) + } + + pub fn post_upgrade_pending_count(&self) -> Result { + let tree = self.get_tree(DB_POST_UPGRADE)?; + Ok(tree.iter().count()) + } + + pub fn clear_all_post_upgrade_pending(&self) -> Result<(), SysinspectError> { + let tree = self.get_tree(DB_POST_UPGRADE)?; + tree.clear()?; + Ok(()) + } + pub fn get(&self, mid: &str) -> Result, SysinspectError> { let minions = self.get_tree(DB_MINIONS)?; let data = match minions.get(mid) { diff --git a/sysmaster/src/registry/rec.rs b/sysmaster/src/registry/rec.rs index 381f159c..4bb4e4d6 100644 --- a/sysmaster/src/registry/rec.rs +++ b/sysmaster/src/registry/rec.rs @@ -44,6 +44,8 @@ pub struct MinionCmdbRecord { pub(crate) config: Option, #[serde(default)] pub(crate) backend: Option, + #[serde(default)] + pub(crate) managed_by_init: Option, pub(crate) updated_at: DateTime, } @@ -90,6 +92,7 @@ impl MinionCmdbRecord { bin: None, config: None, backend: None, + managed_by_init: None, updated_at: Utc::now(), } } @@ -158,6 +161,10 @@ impl MinionCmdbRecord { self.backend.as_deref() } + pub fn managed_by_init(&self) -> Option { + self.managed_by_init + } + pub fn updated_at(&self) -> DateTime { self.updated_at } diff --git a/sysminion/src/main.rs b/sysminion/src/main.rs index 85a222ee..d0fa5044 100644 --- a/sysminion/src/main.rs +++ b/sysminion/src/main.rs @@ -16,6 +16,9 @@ mod inbound_cmd_ut; #[cfg(test)] mod minion_ut; +#[cfg(test)] +mod minion_sha_ut; + #[cfg(test)] mod proto_ut; @@ -31,6 +34,9 @@ mod setup_ut; #[cfg(test)] mod start_ut; +#[cfg(test)] +mod stop_ut; + use clap::{ArgMatches, Command}; use clidef::cli; use colored::Colorize; @@ -329,47 +335,3 @@ fn main() -> std::io::Result<()> { Ok(()) } - -#[cfg(test)] -mod stop_ut { - use super::{running_minion_targets, stop_targets}; - use libsysinspect::cfg::mmconf::MinionConfig; - use std::{ - fs, - time::{SystemTime, UNIX_EPOCH}, - }; - - fn scratch_pidfile() -> std::path::PathBuf { - let dir = std::env::temp_dir().join(format!( - "sysminion-stop-ut-{}-{}", - std::process::id(), - SystemTime::now().duration_since(UNIX_EPOCH).unwrap().as_nanos() - )); - fs::create_dir_all(&dir).unwrap(); - dir.join("sysminion.pid") - } - - #[test] - fn stop_targets_merge_pidfile_and_sniffed_without_self() { - let pidfile = scratch_pidfile(); - fs::write(&pidfile, "42\n").unwrap(); - let mut cfg = MinionConfig::default(); - cfg.set_pid_path(pidfile.to_str().unwrap()); - - assert_eq!(stop_targets(&cfg, &[42, 43, 77], 77), vec![42, 43]); - - let _ = fs::remove_file(pidfile); - } - - #[test] - fn stale_pidfile_does_not_fake_running_minion() { - let pidfile = scratch_pidfile(); - fs::write(&pidfile, "42\n").unwrap(); - let mut cfg = MinionConfig::default(); - cfg.set_pid_path(pidfile.to_str().unwrap()); - - assert_eq!(running_minion_targets(&cfg, &[43, 77], 77), vec![43]); - - let _ = fs::remove_file(pidfile); - } -} diff --git a/sysminion/src/minion.rs b/sysminion/src/minion.rs index 33a0e06b..c62d3153 100644 --- a/sysminion/src/minion.rs +++ b/sysminion/src/minion.rs @@ -1,3 +1,5 @@ +#[cfg(test)] +use crate::minion_sha_ut::test_binary_sha256; use crate::{ callbacks::{ActionResponseCallback, ModelResponseCallback}, filedata::{MinionFiledata, SensorsFiledata}, @@ -266,6 +268,10 @@ impl SysMinion { return; } + #[cfg(test)] + let sha = Some(test_binary_sha256()); + + #[cfg(not(test))] let sha = tokio::task::spawn_blocking(|| { if MINION_BINARY_SHA256.get().is_some() { return None; From c44a9bfdb7bb32d476298115d23dca93e992b841 Mon Sep 17 00:00:00 2001 From: Bo Maryniuk Date: Tue, 16 Jun 2026 16:49:39 +0200 Subject: [PATCH 07/15] Add unit tests --- sysmaster/src/registry/mreg_ut.rs | 157 +++++++++++++++++++++++++++++- sysminion/src/minion_sha_ut.rs | 3 + sysminion/src/stop_ut.rs | 40 ++++++++ 3 files changed, 199 insertions(+), 1 deletion(-) create mode 100644 sysminion/src/minion_sha_ut.rs create mode 100644 sysminion/src/stop_ut.rs diff --git a/sysmaster/src/registry/mreg_ut.rs b/sysmaster/src/registry/mreg_ut.rs index d4f5540d..03c30275 100644 --- a/sysmaster/src/registry/mreg_ut.rs +++ b/sysmaster/src/registry/mreg_ut.rs @@ -1,5 +1,5 @@ use super::MinionRegistry; -use crate::registry::rec::MinionCmdbStartup; +use crate::registry::rec::{MinionCmdbRecord, MinionCmdbStartup}; use chrono::Utc; use libsysproto::MinionTarget; use serde_json::json; @@ -323,3 +323,158 @@ fn get_upgrade_marker_returns_none_for_unknown() { assert!(registry.get_upgrade_marker("nobody").unwrap().is_none()); } + +// --------------------------------------------------------------------------- +// Post-upgrade pending — auto-hopstart after self-upgrade +// --------------------------------------------------------------------------- + +fn cmdb_for_test(host: &str, user: &str, root: &str, bin: &str, config: &str, managed_by_init: Option) -> MinionCmdbRecord { + MinionCmdbRecord { + mid: String::new(), + user: Some(user.to_string()), + host: Some(host.to_string()), + hostname: None, + fqdn: None, + ip: None, + root: Some(root.to_string()), + bin: Some(bin.to_string()), + config: Some(config.to_string()), + backend: None, + managed_by_init, + updated_at: chrono::Utc::now(), + } +} + +#[test] +fn add_post_upgrade_pending_stores_record() { + let tmp = tempfile::tempdir().unwrap(); + let registry = MinionRegistry::new(tmp.path().to_path_buf()).unwrap(); + + registry + .add_post_upgrade_pending( + "mid-1", + &cmdb_for_test("10.0.0.1", "deploy", "/srv/sysinspect", "/srv/bin/sysminion", "/srv/etc/sysinspect.conf", None), + ) + .unwrap(); + + assert!(registry.has_post_upgrade_pending("mid-1").unwrap()); + let pending = registry.get_post_upgrade_pending("mid-1").unwrap().unwrap(); + assert_eq!(pending.host(), "10.0.0.1"); + assert_eq!(pending.user(), "deploy"); + assert_eq!(pending.root(), "/srv/sysinspect"); + assert_eq!(pending.bin(), "/srv/bin/sysminion"); + assert_eq!(pending.config(), "/srv/etc/sysinspect.conf"); + assert_eq!(pending.managed_by_init(), None); +} + +#[test] +fn remove_post_upgrade_pending_deletes() { + let tmp = tempfile::tempdir().unwrap(); + let registry = MinionRegistry::new(tmp.path().to_path_buf()).unwrap(); + + registry.add_post_upgrade_pending("mid-1", &cmdb_for_test("10.0.0.1", "deploy", "/srv", "/srv/bin", "/srv/etc/cfg.conf", None)).unwrap(); + assert!(registry.has_post_upgrade_pending("mid-1").unwrap()); + + registry.remove_post_upgrade_pending("mid-1").unwrap(); + assert!(!registry.has_post_upgrade_pending("mid-1").unwrap()); + assert!(registry.get_post_upgrade_pending("mid-1").unwrap().is_none()); +} + +#[test] +fn has_post_upgrade_pending_true_false_unknown() { + let tmp = tempfile::tempdir().unwrap(); + let registry = MinionRegistry::new(tmp.path().to_path_buf()).unwrap(); + + assert!(!registry.has_post_upgrade_pending("ghost").unwrap()); + + registry.add_post_upgrade_pending("mid-1", &cmdb_for_test("h", "u", "r", "b", "c", None)).unwrap(); + assert!(registry.has_post_upgrade_pending("mid-1").unwrap()); + + registry.remove_post_upgrade_pending("mid-1").unwrap(); + assert!(!registry.has_post_upgrade_pending("mid-1").unwrap()); +} + +#[test] +fn post_upgrade_pending_count_tracks_multiple() { + let tmp = tempfile::tempdir().unwrap(); + let registry = MinionRegistry::new(tmp.path().to_path_buf()).unwrap(); + + assert_eq!(registry.post_upgrade_pending_count().unwrap(), 0); + + registry.add_post_upgrade_pending("mid-1", &cmdb_for_test("h1", "u", "r", "b", "c", None)).unwrap(); + registry.add_post_upgrade_pending("mid-2", &cmdb_for_test("h2", "u", "r", "b", "c", None)).unwrap(); + registry.add_post_upgrade_pending("mid-3", &cmdb_for_test("h3", "u", "r", "b", "c", None)).unwrap(); + + assert_eq!(registry.post_upgrade_pending_count().unwrap(), 3); + + registry.remove_post_upgrade_pending("mid-2").unwrap(); + assert_eq!(registry.post_upgrade_pending_count().unwrap(), 2); +} + +#[test] +fn post_upgrade_pending_overwrites_previous() { + let tmp = tempfile::tempdir().unwrap(); + let registry = MinionRegistry::new(tmp.path().to_path_buf()).unwrap(); + + registry.add_post_upgrade_pending("mid-1", &cmdb_for_test("old-host", "u", "r", "b", "c", None)).unwrap(); + assert_eq!(registry.get_post_upgrade_pending("mid-1").unwrap().unwrap().host(), "old-host"); + + registry.add_post_upgrade_pending("mid-1", &cmdb_for_test("new-host", "u", "r", "b", "c", None)).unwrap(); + assert_eq!(registry.get_post_upgrade_pending("mid-1").unwrap().unwrap().host(), "new-host"); +} + +#[test] +fn post_upgrade_pending_survives_registry_reopen() { + let tmp = tempfile::tempdir().unwrap(); + let db_path = tmp.path().to_path_buf(); + + { + let registry = MinionRegistry::new(db_path.clone()).unwrap(); + registry.add_post_upgrade_pending("mid-1", &cmdb_for_test("10.0.0.1", "deploy", "/s", "/b", "/c", Some(true))).unwrap(); + } + + let registry = MinionRegistry::new(db_path).unwrap(); + assert!(registry.has_post_upgrade_pending("mid-1").unwrap()); + let pending = registry.get_post_upgrade_pending("mid-1").unwrap().unwrap(); + assert_eq!(pending.host(), "10.0.0.1"); + assert_eq!(pending.managed_by_init(), Some(true)); +} + +#[test] +fn post_upgrade_pending_managed_by_init_flag() { + let tmp = tempfile::tempdir().unwrap(); + let registry = MinionRegistry::new(tmp.path().to_path_buf()).unwrap(); + + registry.add_post_upgrade_pending("mid-1", &cmdb_for_test("h", "u", "r", "b", "c", Some(true))).unwrap(); + assert_eq!(registry.get_post_upgrade_pending("mid-1").unwrap().unwrap().managed_by_init(), Some(true)); + + registry.add_post_upgrade_pending("mid-2", &cmdb_for_test("h", "u", "r", "b", "c", Some(false))).unwrap(); + assert_eq!(registry.get_post_upgrade_pending("mid-2").unwrap().unwrap().managed_by_init(), Some(false)); +} + +#[test] +fn post_upgrade_pending_independent_per_minion() { + let tmp = tempfile::tempdir().unwrap(); + let registry = MinionRegistry::new(tmp.path().to_path_buf()).unwrap(); + + registry.add_post_upgrade_pending("mid-1", &cmdb_for_test("h1", "u", "r", "b", "c", None)).unwrap(); + registry.add_post_upgrade_pending("mid-2", &cmdb_for_test("h2", "u", "r", "b", "c", None)).unwrap(); + + registry.remove_post_upgrade_pending("mid-1").unwrap(); + assert!(!registry.has_post_upgrade_pending("mid-1").unwrap()); + assert!(registry.has_post_upgrade_pending("mid-2").unwrap()); +} + +#[test] +fn clear_all_post_upgrade_pending_empties() { + let tmp = tempfile::tempdir().unwrap(); + let registry = MinionRegistry::new(tmp.path().to_path_buf()).unwrap(); + + registry.add_post_upgrade_pending("mid-1", &cmdb_for_test("h", "u", "r", "b", "c", None)).unwrap(); + registry.add_post_upgrade_pending("mid-2", &cmdb_for_test("h", "u", "r", "b", "c", None)).unwrap(); + registry.add_post_upgrade_pending("mid-3", &cmdb_for_test("h", "u", "r", "b", "c", None)).unwrap(); + + registry.clear_all_post_upgrade_pending().unwrap(); + assert_eq!(registry.post_upgrade_pending_count().unwrap(), 0); + assert!(!registry.has_post_upgrade_pending("mid-1").unwrap()); +} diff --git a/sysminion/src/minion_sha_ut.rs b/sysminion/src/minion_sha_ut.rs new file mode 100644 index 00000000..4726944c --- /dev/null +++ b/sysminion/src/minion_sha_ut.rs @@ -0,0 +1,3 @@ +pub(crate) fn test_binary_sha256() -> String { + "sysminion-test-binary-sha256".to_string() +} diff --git a/sysminion/src/stop_ut.rs b/sysminion/src/stop_ut.rs new file mode 100644 index 00000000..092bb4bf --- /dev/null +++ b/sysminion/src/stop_ut.rs @@ -0,0 +1,40 @@ +use crate::{running_minion_targets, stop_targets}; +use libsysinspect::cfg::mmconf::MinionConfig; +use std::{ + fs, + time::{SystemTime, UNIX_EPOCH}, +}; + +fn scratch_pidfile() -> std::path::PathBuf { + let dir = std::env::temp_dir().join(format!( + "sysminion-stop-ut-{}-{}", + std::process::id(), + SystemTime::now().duration_since(UNIX_EPOCH).unwrap().as_nanos() + )); + fs::create_dir_all(&dir).unwrap(); + dir.join("sysminion.pid") +} + +#[test] +fn stop_targets_merge_pidfile_and_sniffed_without_self() { + let pidfile = scratch_pidfile(); + fs::write(&pidfile, "42\n").unwrap(); + let mut cfg = MinionConfig::default(); + cfg.set_pid_path(pidfile.to_str().unwrap()); + + assert_eq!(stop_targets(&cfg, &[42, 43, 77], 77), vec![42, 43]); + + let _ = fs::remove_file(pidfile); +} + +#[test] +fn stale_pidfile_does_not_fake_running_minion() { + let pidfile = scratch_pidfile(); + fs::write(&pidfile, "42\n").unwrap(); + let mut cfg = MinionConfig::default(); + cfg.set_pid_path(pidfile.to_str().unwrap()); + + assert_eq!(running_minion_targets(&cfg, &[43, 77], 77), vec![43]); + + let _ = fs::remove_file(pidfile); +} From 89a6b110d5b471f5f1906c8a3aad32ebcf07ada4 Mon Sep 17 00:00:00 2001 From: Bo Maryniuk Date: Tue, 16 Jun 2026 17:10:33 +0200 Subject: [PATCH 08/15] Fix race conditions on flaky UT --- libsysinspect/src/journal_ut.rs | 16 +++++++++++++++- 1 file changed, 15 insertions(+), 1 deletion(-) diff --git a/libsysinspect/src/journal_ut.rs b/libsysinspect/src/journal_ut.rs index 215e13f2..d36a0b59 100644 --- a/libsysinspect/src/journal_ut.rs +++ b/libsysinspect/src/journal_ut.rs @@ -1,5 +1,19 @@ use crate::journal::Journal; +fn open_with_retry(dir: &std::path::Path, max_bytes: u64) -> Journal { + let mut last_err = None; + for _ in 0..20 { + match Journal::open(dir, max_bytes) { + Ok(journal) => return journal, + Err(err) => { + last_err = Some(err); + std::thread::sleep(std::time::Duration::from_millis(25)); + } + } + } + panic!("failed to reopen journal: {}", last_err.unwrap()); +} + fn temp_dir() -> std::path::PathBuf { let dir = std::env::temp_dir().join(format!( "libsysinspect-journal-ut-{}-{}", @@ -73,7 +87,7 @@ fn state_persists_across_reopen() { let j1 = Journal::open(&dir, 0).unwrap(); j1.append("c1", b"lost").unwrap(); drop(j1); - let j2 = Journal::open(&dir, 0).unwrap(); + let j2 = open_with_retry(&dir, 0); let pending = j2.pending().unwrap(); assert_eq!(pending.len(), 1); assert_eq!(pending[0].0, "c1"); From 85fecd15fdf5af1bef41e6fb61d452dd34069fea Mon Sep 17 00:00:00 2001 From: Bo Maryniuk Date: Tue, 16 Jun 2026 18:48:52 +0200 Subject: [PATCH 09/15] Refine main window: align IP by octets, segmented titles for data --- src/ui/elements.rs | 9 ++- src/ui/mod.rs | 84 +++++++++++++++++++++++-- src/ui/wgt.rs | 149 +++++++++++++++++++++++++++++++++++++++++---- 3 files changed, 223 insertions(+), 19 deletions(-) diff --git a/src/ui/elements.rs b/src/ui/elements.rs index b79d3e0e..3e437a58 100644 --- a/src/ui/elements.rs +++ b/src/ui/elements.rs @@ -249,9 +249,9 @@ impl DbListItem for MinionListItem { let _ = hl; let HostInfo { ipaddr, hostname } = self.hostname(); Line::from(vec![ - Span::styled(ipaddr, Style::default().fg(palette::GRAY_1)), + Span::styled(format_ip_octets(&ipaddr), Style::default().fg(palette::GRAY_1)), Span::raw(" "), - Span::styled(hostname, Style::default().fg(palette::FG)), + Span::styled(hostname, Style::default().fg(palette::PROCESSING_PEAK)), ]) } @@ -261,6 +261,11 @@ impl DbListItem for MinionListItem { } } +fn format_ip_octets(ip: &str) -> String { + let octets: Vec<&str> = ip.split('.').collect(); + if octets.len() == 4 { format!("{:>3}.{:>3}.{:>3}.{:>3}", octets[0], octets[1], octets[2], octets[3]) } else { format!("{:>15}", ip) } +} + fn right_pad(s: &str, width: usize) -> String { let len = s.chars().count(); if len >= width { s.to_string() } else { format!("{}{}", s, " ".repeat(width - len)) } diff --git a/src/ui/mod.rs b/src/ui/mod.rs index 7be52efa..2c5e1a81 100644 --- a/src/ui/mod.rs +++ b/src/ui/mod.rs @@ -448,12 +448,26 @@ impl SysInspectUX { pub fn run_loop(mut self, term: &mut DefaultTerminal) -> io::Result<()> { self.cycles_buf = self.get_cycles().unwrap_or_default(); + if !self.cycles_buf.is_empty() { + let sid = self.get_selected_cycle().event().sid().to_string(); + if let Ok(minions) = self.get_minions(&sid) { + self.li_minions = minions; + self.refresh_events_for_selected_minion(); + } + } self.refresh_cluster_upgrade_status(); self.run_normal_loop(term) } fn run_connected(mut self, term: &mut DefaultTerminal) -> io::Result<()> { self.cycles_buf = self.get_cycles().unwrap_or_default(); + if !self.cycles_buf.is_empty() { + let sid = self.get_selected_cycle().event().sid().to_string(); + if let Ok(minions) = self.get_minions(&sid) { + self.li_minions = minions; + self.refresh_events_for_selected_minion(); + } + } self.refresh_cluster_upgrade_status(); self.run_normal_loop(term) } @@ -4215,6 +4229,9 @@ impl SysInspectUX { self.minions_buf = Vec::new(); self.events_buf = Vec::new(); self.event_data = IndexMap::new(); + self.li_events = Vec::new(); + self.li_minions = Vec::new(); + self.selected_event = 0; if down { if self.selected_cycle < self.cycles_buf.len().saturating_sub(1) { @@ -4223,6 +4240,14 @@ impl SysInspectUX { } else if self.selected_cycle > 0 { self.selected_cycle -= 1; } + if !self.cycles_buf.is_empty() { + let sid = self.get_selected_cycle().event().sid().to_string(); + if let Ok(minions) = self.get_minions(&sid) { + self.li_minions = minions; + self.selected_minion = 0; + self.refresh_events_for_selected_minion(); + } + } } Err(err) => { self.error_alert_visible = true; @@ -4231,6 +4256,21 @@ impl SysInspectUX { } } + fn refresh_events_for_selected_minion(&mut self) { + self.event_data = IndexMap::new(); + self.li_events = Vec::new(); + self.selected_event = 0; + if let Some(mli) = self.get_selected_minion() { + let sid = self.get_selected_cycle().event().sid().to_string(); + if let Ok(events) = self.get_events(&sid, mli.event().id()) { + self.li_events = events; + } + } + if !self.li_events.is_empty() { + self.event_data = self.li_events[0].event().flatten(); + } + } + fn on_mouse_move(&mut self, me: MouseEvent) { let rects = match self.popup_button_rects.get() { Some(r) => r, @@ -4538,10 +4578,22 @@ impl SysInspectUX { KeyCode::PageUp => { match self.active_box { ActiveBox::Cycles => { + self.event_data = IndexMap::new(); + self.li_events = Vec::new(); + self.li_minions = Vec::new(); + self.selected_event = 0; self.selected_cycle = self.selected_cycle.saturating_sub(self.size.get().table_cycles); + if !self.cycles_buf.is_empty() { + let sid = self.get_selected_cycle().event().sid().to_string(); + if let Ok(minions) = self.get_minions(&sid) { + self.li_minions = minions; + self.selected_minion = 0; + } + } } ActiveBox::Minions => { self.selected_minion = self.selected_minion.saturating_sub(self.size.get().table_minions); + self.refresh_events_for_selected_minion(); } ActiveBox::Events => { self.selected_event = self.selected_event.saturating_sub(self.size.get().table_events); @@ -4554,13 +4606,22 @@ impl SysInspectUX { KeyCode::PageDown => { match self.active_box { ActiveBox::Cycles => { + self.event_data = IndexMap::new(); + self.li_events = Vec::new(); + self.li_minions = Vec::new(); + self.selected_event = 0; self.selected_cycle = (self.selected_cycle + self.size.get().table_cycles).min(self.cycles_buf.len().saturating_sub(1)); + if !self.cycles_buf.is_empty() { + let sid = self.get_selected_cycle().event().sid().to_string(); + if let Ok(minions) = self.get_minions(&sid) { + self.li_minions = minions; + self.selected_minion = 0; + } + } } ActiveBox::Minions => { - if !self.li_events.is_empty() { - self.selected_minion = - (self.selected_minion + self.size.get().table_minions).min(self.li_minions.len().saturating_sub(1)); - } + self.selected_minion = (self.selected_minion + self.size.get().table_minions).min(self.li_minions.len().saturating_sub(1)); + self.refresh_events_for_selected_minion(); } ActiveBox::Events => { if !self.li_events.is_empty() { @@ -4580,6 +4641,7 @@ impl SysInspectUX { if self.selected_minion > 0 { self.selected_minion -= 1; } + self.refresh_events_for_selected_minion(); } ActiveBox::Events => { if self.selected_event > 0 { @@ -4590,6 +4652,13 @@ impl SysInspectUX { ActiveBox::Info => { if self.actdt_info_offset > 0 { self.actdt_info_offset -= 1; + } else { + self.active_box = ActiveBox::Events; + if !self.li_events.is_empty() { + self.selected_event = self.li_events.len().saturating_sub(1); + self.event_data = self.li_events[self.selected_event].event().flatten(); + } + self.status_at_action_results(); } } }; @@ -4601,10 +4670,16 @@ impl SysInspectUX { if self.selected_minion < self.li_minions.len().saturating_sub(1) { self.selected_minion += 1; } + self.refresh_events_for_selected_minion(); } ActiveBox::Events => { if self.selected_event < self.li_events.len().saturating_sub(1) { self.selected_event += 1; + } else if !self.info_rows.borrow().is_empty() { + self.active_box = ActiveBox::Info; + self.actdt_info_offset = 0; + self.status_at_action_data(); + return; } self.event_data = self.get_selected_event().unwrap().event().flatten(); } @@ -4637,6 +4712,7 @@ impl SysInspectUX { self.li_minions = minions; self.selected_minion = 0; self.selected_event = 0; + self.refresh_events_for_selected_minion(); } Err(err) => { self.error_alert_visible = true; diff --git a/src/ui/wgt.rs b/src/ui/wgt.rs index b43cc42a..930e1988 100644 --- a/src/ui/wgt.rs +++ b/src/ui/wgt.rs @@ -1,7 +1,9 @@ use super::{ SysInspectUX, UISizes, elements::{ActiveBox, DbListItem, EventListItem}, - minreg, palette, typecolors, + minreg, palette, title, + title::{TitleSegment, TitleStyle}, + typecolors, }; use ratatui::{ layout::{Constraint, Direction, Layout}, @@ -20,8 +22,64 @@ impl SysInspectUX { let csize = self.size.get(); self.size.set(UISizes { table_info: rect.height.saturating_sub(2) as usize, ..csize }); - let block = self._get_box_block("Action Data", ActiveBox::Info); + let (model, target, state_opt) = if !self.cycles_buf.is_empty() { + parse_query_for_title(self.get_selected_cycle().event().query()) + } else { + (String::new(), String::new(), None) + }; + let event_name = self.get_selected_event().map(|e| e.event().get_action_id()).unwrap_or_default(); + let show_segments = matches!(self.active_box, ActiveBox::Info); + let is_focused = self.main_box_active(ActiveBox::Info); + let events_active = self.main_box_active(ActiveBox::Events); + + let block = if is_focused { + Block::default().borders(Borders::ALL).border_type(BorderType::Rounded).border_style(Style::default().fg(palette::SUCCESS_PEAK)) + } else if events_active && !model.is_empty() { + let title_str = if let Some(ref state) = state_opt { + if !event_name.is_empty() { + format!(" Action Data / {model} / {target} / {state} / {event_name} ") + } else { + format!(" Action Data / {model} / {target} / {state} ") + } + } else if !event_name.is_empty() { + format!(" Action Data / {model} / {target} / {event_name} ") + } else { + format!(" Action Data / {model} / {target} ") + }; + Block::default() + .borders(Borders::ALL) + .title(title_str) + .title_style(Style::default().fg(palette::MUTED)) + .border_type(BorderType::Rounded) + .border_style(Style::default().fg(palette::FAINT)) + } else { + Block::default() + .borders(Borders::ALL) + .title(" Action Data ") + .title_style(Style::default().fg(palette::MUTED)) + .border_type(BorderType::Rounded) + .border_style(Style::default().fg(palette::FAINT)) + }; Widget::render(&block, rect, buf); + + if show_segments && !model.is_empty() { + let mut segments = vec![ + TitleSegment { text: " Action Data ".into(), bg: palette::SUCCESS_PEAK, fg: palette::BLACK, modifier: Modifier::empty() }, + TitleSegment { text: format!(" {model} "), bg: palette::SUCCESS_HEAT, fg: palette::BLACK, modifier: Modifier::empty() }, + TitleSegment { text: format!(" {target} "), bg: palette::SUCCESS_GLOW, fg: palette::BLACK, modifier: Modifier::empty() }, + ]; + if !event_name.is_empty() { + segments.push(TitleSegment { + text: format!(" {event_name} "), + bg: palette::SUCCESS_BASE, + fg: palette::WHITE, + modifier: Modifier::empty(), + }); + } + let title_style = TitleStyle::cyberpunk(palette::SUCCESS_PEAK); + title::overlay_gradient_title(buf, rect, &title_style, &segments); + } + let inner = block.inner(rect); let rule_title = Style::default().fg(palette::PROCESSING).add_modifier(Modifier::BOLD); @@ -103,10 +161,57 @@ impl SysInspectUX { let csize = self.size.get(); self.size.set(UISizes { table_events: rect.height.saturating_sub(2) as usize, ..csize }); - let title = "Action Results"; - let block = self._get_box_block(title, ActiveBox::Events); + let (model, target, state_opt) = if !self.cycles_buf.is_empty() { + parse_query_for_title(self.get_selected_cycle().event().query()) + } else { + (String::new(), String::new(), None) + }; + let show_segments = matches!(self.active_box, ActiveBox::Events); + let is_focused = self.main_box_active(ActiveBox::Events); + let info_active = self.main_box_active(ActiveBox::Info); + + let block = if is_focused { + Block::default().borders(Borders::ALL).border_type(BorderType::Rounded).border_style(Style::default().fg(palette::SUCCESS_PEAK)) + } else if info_active && !model.is_empty() { + let title_str = if let Some(ref state) = state_opt { + format!(" Action Results / {model} / {target} / {state} ") + } else { + format!(" Action Results / {model} / {target} ") + }; + Block::default() + .borders(Borders::ALL) + .title(title_str) + .title_style(Style::default().fg(palette::MUTED)) + .border_type(BorderType::Rounded) + .border_style(Style::default().fg(palette::FAINT)) + } else { + Block::default() + .borders(Borders::ALL) + .title(" Action Results ") + .title_style(Style::default().fg(palette::MUTED)) + .border_type(BorderType::Rounded) + .border_style(Style::default().fg(palette::FAINT)) + }; Widget::render(&block, rect, buf); + if show_segments && !model.is_empty() { + let mut segments = vec![ + TitleSegment { text: " Action Results ".into(), bg: palette::SUCCESS_PEAK, fg: palette::BLACK, modifier: Modifier::empty() }, + TitleSegment { text: format!(" {model} "), bg: palette::SUCCESS_HEAT, fg: palette::BLACK, modifier: Modifier::empty() }, + TitleSegment { text: format!(" {target} "), bg: palette::SUCCESS_GLOW, fg: palette::BLACK, modifier: Modifier::empty() }, + ]; + if let Some(ref state) = state_opt { + segments.push(TitleSegment { + text: format!(" {state} "), + bg: palette::SUCCESS_BASE, + fg: palette::WHITE, + modifier: Modifier::empty(), + }); + } + let title_style = TitleStyle::cyberpunk(palette::SUCCESS_PEAK); + title::overlay_gradient_title(buf, rect, &title_style, &segments); + } + let events_inner = block.inner(rect); let mut events_state = ListState::default(); if !self.li_events.is_empty() { @@ -191,12 +296,12 @@ impl SysInspectUX { Block::default() .borders(Borders::ALL) .title(Line::from(vec![ - Span::styled("\u{E0B2}", Style::default().fg(palette::ACCENT)), - Span::styled(t, Style::default().fg(palette::BLACK).bg(palette::ACCENT).add_modifier(Modifier::BOLD)), - Span::styled("\u{E0B0}", Style::default().fg(palette::ACCENT)), + Span::styled("\u{E0B2}", Style::default().fg(palette::SUCCESS_PEAK)), + Span::styled(t, Style::default().fg(palette::BLACK).bg(palette::SUCCESS_PEAK).add_modifier(Modifier::BOLD)), + Span::styled("\u{E0B0}", Style::default().fg(palette::SUCCESS_PEAK)), ])) .border_type(BorderType::Rounded) - .border_style(Style::default().fg(palette::ACCENT)) + .border_style(Style::default().fg(palette::SUCCESS_PEAK)) } else { Block::default() .borders(Borders::ALL) @@ -251,6 +356,21 @@ impl SysInspectUX { } } +/// Parse a model query string into model, target/label, and optional state. +pub(crate) fn parse_query_for_title(query: &str) -> (String, String, Option) { + let display = query.strip_suffix("/$").unwrap_or(query); + if let Some((model, label)) = display.split_once(':') { + return (model.to_string(), label.to_string(), None); + } + let parts: Vec<&str> = display.split('/').collect(); + match parts.as_slice() { + [model, target] => (model.to_string(), target.to_string(), None), + [model, target, state] if *state != "$" => (model.to_string(), target.to_string(), Some(state.to_string())), + [model, target, _] => (model.to_string(), target.to_string(), None), + _ => (display.to_string(), String::new(), None), + } +} + /// 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( @@ -282,15 +402,18 @@ impl Widget for &SysInspectUX { 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); + 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); - let cycles_w = (cycles_max as u16 + 5).max(20).min(area.width.saturating_sub(20)); - let minions_w = (minions_max as u16 + 5).max(20).min(area.width.saturating_sub(cycles_w).saturating_sub(10)); + let (cycles_w, minions_w) = if matches!(self.active_box, ActiveBox::Cycles | ActiveBox::Minions) { + (Constraint::Ratio(1, 3), Constraint::Ratio(1, 3)) + } else { + (Constraint::Ratio(1, 6), Constraint::Ratio(1, 6)) + }; let [cycles_a, minions_a, events_a]: [Rect; 3] = Layout::default() .direction(Direction::Horizontal) - .constraints([Constraint::Length(cycles_w), Constraint::Length(minions_w), Constraint::Min(0)]) + .constraints([cycles_w, minions_w, Constraint::Min(0)]) .split(area) .as_ref() .try_into() From 28de3bb157b2e89f7154beb6c45b248fa85a779c Mon Sep 17 00:00:00 2001 From: Bo Maryniuk Date: Tue, 16 Jun 2026 21:05:03 +0200 Subject: [PATCH 10/15] Update index docs --- docs/index.rst | 17 ++++++++++++++++- 1 file changed, 16 insertions(+), 1 deletion(-) diff --git a/docs/index.rst b/docs/index.rst index dfe754de..a34ff9ec 100644 --- a/docs/index.rst +++ b/docs/index.rst @@ -52,8 +52,23 @@ welcome—see the section on contributing for how to get involved. tutorial/wasm_modules_tutor tutorial/lua_modules_tutor tutorial/menotify_tutor - tutorial/menotify_sensor_dev_tutor + tutorial/menotify_sensor_dev_tutor +Source Code +----------- + +The project source code is hosted on GitHub: +`github.com/tinythings/sysinspect `_. + +Related Projects +---------------- + +`LogJet `_ is a sibling project providing +OTLP telemetry storage, replay, and bridge functionality. It acts as a durable +log, metric, and trace buffer that sits between local telemetry sources and +downstream collectors — optimised for weak hardware, limited RAM, intermittent +connectivity, and sequential replay. Together with Sysinspect, it enables +end-to-end disconnected-capable observability pipelines. Licence ------- From 2b82aacb262dcbea91af0936b81d6b12c706050b Mon Sep 17 00:00:00 2001 From: Bo Maryniuk Date: Tue, 16 Jun 2026 21:06:25 +0200 Subject: [PATCH 11/15] Update help --- src/ui/alert.rs | 154 +++++++++++++++++++++++++++++++++++++++++------- src/ui/mod.rs | 16 ++++- 2 files changed, 148 insertions(+), 22 deletions(-) diff --git a/src/ui/alert.rs b/src/ui/alert.rs index 87f63527..a739da40 100644 --- a/src/ui/alert.rs +++ b/src/ui/alert.rs @@ -4,7 +4,7 @@ use ratatui::{ prelude::{Buffer, Rect}, style::{Color, Modifier, Style}, text::{Line, Span, Text}, - widgets::{Block, Borders, Clear, Padding, Paragraph, Widget}, + widgets::{Block, Borders, Clear, Padding, Paragraph, Scrollbar, ScrollbarOrientation, ScrollbarState, StatefulWidget, Widget}, }; use ratatui_glamour::color::blend_2d; use unicode_width::UnicodeWidthStr; @@ -165,26 +165,138 @@ impl SysInspectUX { if !self.help_popup_visible { return; } - let rects = Self::_popup_ex( - parent, - buf, - Some("Help"), - "\"c\" - call composer\n\"h\" - show this help\n\"m\" - master operations\n\"o\" - registered minions popup\n\"p\" - purge all records\n\"q\" - quit the UI\n", - None, - Alignment::Left, - AlertResult::Close, - AlertButtons::Close, - Some(0), - Some(palette::SUCCESS), - None, - None, - Some(palette::BG_1), - None, - None, - None, - None, - ); - self.popup_button_rects.set(Some(rects)); + let lines: Vec> = vec![ + Self::help_line("c", "Open the query composer: pick a model, target, and"), + Self::help_line("", "state to run across your machines."), + Line::from(""), + Self::help_line("h", "Show this help window."), + Line::from(""), + Self::help_line("m", "Open the master menu: logs, registration, artefacts,"), + Self::help_line("", "cluster upgrades."), + Line::from(""), + Self::help_line("o", "Open the minions list with filter, tagging, and"), + Self::help_line("", "detailed per-machine inspection."), + Line::from(""), + Self::help_line("p", "Purge all locally stored records to free up space."), + Line::from(""), + Self::help_line("q", "Exit the Sysinspect TUI."), + Line::from(""), + Self::help_line("Esc", "Close popups or go back. Press twice to quit."), + Line::from(""), + Self::help_line("Enter", "Open the selected item to drill into details:"), + Self::help_line("", "cycles show machines, machines show events,"), + Self::help_line("", "events show full data."), + Line::from(""), + Self::help_line("Up/Down", "Navigate through list items in the active panel."), + Line::from(""), + Self::help_line("Left/Right", "Switch between panels: Calls, Machines, Results,"), + Self::help_line("", "Data."), + Line::from(""), + Self::help_line("Tab", "From Action Results, view full event data."), + Line::from(""), + Self::help_line("Ctrl+O", "Open master logs directly from the master."), + Line::from(""), + Self::help_line("Ctrl+L", "Open locally saved master logs for offline viewing."), + Line::from(""), + Self::help_line("Ctrl+R", "Open the registration form to add a new machine."), + Line::from(""), + Self::help_line("Ctrl+A", "Open the Artefacts Manager: modules, libraries,"), + Self::help_line("", "models, profiles, platform builds."), + Line::from(""), + Self::help_line("Ctrl+U", "Run a cluster upgrade across your machines."), + Line::from(""), + Line::from(vec![Span::styled("For bug reporting and project updates, visit:", Style::default().fg(palette::GRAY_1))]), + Line::from(vec![Span::styled("https://github.com/tinythings/sysinspect", Style::default().fg(palette::GRAY_1))]), + ]; + + let total = lines.len(); + let max_text_h = parent.height.saturating_sub(8); + let max_scroll = total.saturating_sub(max_text_h as usize); + let scroll = self.help_popup_scroll.get().min(max_scroll); + self.help_popup_scroll.set(scroll); + let visible: Vec = lines.into_iter().skip(scroll).take(max_text_h as usize).collect(); + let visible_h = visible.len() as u16; + + let w = (parent.width * 75 / 100).max(60).min(parent.width.saturating_sub(2)); + let h = visible_h.saturating_add(3); + 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 block = Block::default() + .borders(Borders::ALL) + .border_type(ratatui::widgets::BorderType::Rounded) + .border_style(Style::default().fg(palette::SUCCESS_PEAK)) + .title(Line::from(vec![ + Span::styled("\u{E0B2}", Style::default().fg(palette::SUCCESS_PEAK)), + Span::styled("Help", Style::default().fg(palette::BLACK).bg(palette::SUCCESS_PEAK).add_modifier(Modifier::BOLD)), + Span::styled("\u{E0B0}", Style::default().fg(palette::SUCCESS_PEAK)), + ])) + .style(Style::default().bg(palette::BG_1)); + let inner = block.inner(canvas); + block.render(canvas, buf); + + let text_inner = Rect::new(inner.x + 2, inner.y, inner.width.saturating_sub(2), inner.height); + Paragraph::new(Text::from(visible)).alignment(Alignment::Left).render(text_inner, buf); + + if total > max_text_h as usize { + let sb_x = inner.right().saturating_sub(1); + let mut sb_state = ScrollbarState::new(total).position(scroll); + StatefulWidget::render( + 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)), + Rect::new(sb_x, inner.y, 1, inner.height), + buf, + &mut sb_state, + ); + } + + // MS-DOS shadow + 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..w { + let sx = x.saturating_add(2).saturating_add(idx); + let sy = y.saturating_add(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 offset in 0..2u16 { + for idx in 0..h { + let sx = x.saturating_add(w).saturating_add(offset); + let sy = 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); + } + } + } + } + + fn help_line(key: &str, desc: &str) -> Line<'static> { + let key_w = 11usize; + let key_padded = format!("{:width$}", key, width = key_w); + Line::from(vec![ + Span::styled(key_padded, Style::default().fg(palette::WARNING_PEAK).add_modifier(Modifier::BOLD)), + Span::raw(""), + Span::styled(desc.to_string(), Style::default().fg(palette::GRAY_2)), + ]) } pub fn dialog_exit(&self, parent: Rect, buf: &mut Buffer) { diff --git a/src/ui/mod.rs b/src/ui/mod.rs index 2c5e1a81..a9fa6dbc 100644 --- a/src/ui/mod.rs +++ b/src/ui/mod.rs @@ -186,6 +186,7 @@ pub struct SysInspectUX { // Help popup pub help_popup_visible: bool, + pub help_popup_scroll: Cell, // Online minions popup pub minions_visible: bool, @@ -330,6 +331,7 @@ impl Default for SysInspectUX { info_alert_title: String::new(), info_alert_styled: None, help_popup_visible: false, + help_popup_scroll: Cell::new(0), minions_visible: false, minions_rows: Vec::new(), @@ -1033,11 +1035,23 @@ impl SysInspectUX { match e.code { KeyCode::Enter | KeyCode::Esc => { self.help_popup_visible = false; + self.help_popup_scroll.set(0); + } + KeyCode::Up => { + self.help_popup_scroll.set(self.help_popup_scroll.get().saturating_sub(1)); + } + KeyCode::Down => { + self.help_popup_scroll.set(self.help_popup_scroll.get().saturating_add(1)); + } + KeyCode::PageUp => { + self.help_popup_scroll.set(self.help_popup_scroll.get().saturating_sub(10)); + } + KeyCode::PageDown => { + self.help_popup_scroll.set(self.help_popup_scroll.get().saturating_add(10)); } _ => {} } } - stat } From 0e8d14897e66d9e83570a5324134b31e2bd3fd9b Mon Sep 17 00:00:00 2001 From: Bo Maryniuk Date: Tue, 16 Jun 2026 21:49:04 +0200 Subject: [PATCH 12/15] Fix title colors on the main window --- src/ui/wgt.rs | 36 ++++++++++++++++++++++-------------- 1 file changed, 22 insertions(+), 14 deletions(-) diff --git a/src/ui/wgt.rs b/src/ui/wgt.rs index 930e1988..e193f952 100644 --- a/src/ui/wgt.rs +++ b/src/ui/wgt.rs @@ -33,7 +33,7 @@ impl SysInspectUX { let events_active = self.main_box_active(ActiveBox::Events); let block = if is_focused { - Block::default().borders(Borders::ALL).border_type(BorderType::Rounded).border_style(Style::default().fg(palette::SUCCESS_PEAK)) + Block::default().borders(Borders::ALL).border_type(BorderType::Rounded).border_style(Style::default().fg(palette::SUCCESS)) } else if events_active && !model.is_empty() { let title_str = if let Some(ref state) = state_opt { if !event_name.is_empty() { @@ -64,10 +64,18 @@ impl SysInspectUX { if show_segments && !model.is_empty() { let mut segments = vec![ - TitleSegment { text: " Action Data ".into(), bg: palette::SUCCESS_PEAK, fg: palette::BLACK, modifier: Modifier::empty() }, - TitleSegment { text: format!(" {model} "), bg: palette::SUCCESS_HEAT, fg: palette::BLACK, modifier: Modifier::empty() }, - TitleSegment { text: format!(" {target} "), bg: palette::SUCCESS_GLOW, fg: palette::BLACK, modifier: Modifier::empty() }, + TitleSegment { text: " Action Data ".into(), bg: palette::SUCCESS, fg: palette::BLACK, modifier: Modifier::empty() }, + TitleSegment { text: format!(" {model} "), bg: palette::SUCCESS_PEAK, fg: palette::BLACK, modifier: Modifier::empty() }, + TitleSegment { text: format!(" {target} "), bg: palette::SUCCESS_HEAT, fg: palette::BLACK, modifier: Modifier::empty() }, ]; + if let Some(ref state) = state_opt { + segments.push(TitleSegment { + text: format!(" {state} "), + bg: palette::SUCCESS_GLOW, + fg: palette::BLACK, + modifier: Modifier::empty(), + }); + } if !event_name.is_empty() { segments.push(TitleSegment { text: format!(" {event_name} "), @@ -171,7 +179,7 @@ impl SysInspectUX { let info_active = self.main_box_active(ActiveBox::Info); let block = if is_focused { - Block::default().borders(Borders::ALL).border_type(BorderType::Rounded).border_style(Style::default().fg(palette::SUCCESS_PEAK)) + Block::default().borders(Borders::ALL).border_type(BorderType::Rounded).border_style(Style::default().fg(palette::SUCCESS)) } else if info_active && !model.is_empty() { let title_str = if let Some(ref state) = state_opt { format!(" Action Results / {model} / {target} / {state} ") @@ -196,15 +204,15 @@ impl SysInspectUX { if show_segments && !model.is_empty() { let mut segments = vec![ - TitleSegment { text: " Action Results ".into(), bg: palette::SUCCESS_PEAK, fg: palette::BLACK, modifier: Modifier::empty() }, - TitleSegment { text: format!(" {model} "), bg: palette::SUCCESS_HEAT, fg: palette::BLACK, modifier: Modifier::empty() }, - TitleSegment { text: format!(" {target} "), bg: palette::SUCCESS_GLOW, fg: palette::BLACK, modifier: Modifier::empty() }, + TitleSegment { text: " Action Results ".into(), bg: palette::SUCCESS, fg: palette::BLACK, modifier: Modifier::empty() }, + TitleSegment { text: format!(" {model} "), bg: palette::SUCCESS_PEAK, fg: palette::BLACK, modifier: Modifier::empty() }, + TitleSegment { text: format!(" {target} "), bg: palette::SUCCESS_HEAT, fg: palette::BLACK, modifier: Modifier::empty() }, ]; if let Some(ref state) = state_opt { segments.push(TitleSegment { text: format!(" {state} "), - bg: palette::SUCCESS_BASE, - fg: palette::WHITE, + bg: palette::SUCCESS_GLOW, + fg: palette::BLACK, modifier: Modifier::empty(), }); } @@ -296,12 +304,12 @@ impl SysInspectUX { Block::default() .borders(Borders::ALL) .title(Line::from(vec![ - Span::styled("\u{E0B2}", Style::default().fg(palette::SUCCESS_PEAK)), - Span::styled(t, Style::default().fg(palette::BLACK).bg(palette::SUCCESS_PEAK).add_modifier(Modifier::BOLD)), - Span::styled("\u{E0B0}", Style::default().fg(palette::SUCCESS_PEAK)), + Span::styled("\u{E0B2}", Style::default().fg(palette::SUCCESS)), + Span::styled(t, Style::default().fg(palette::BLACK).bg(palette::SUCCESS).add_modifier(Modifier::BOLD)), + Span::styled("\u{E0B0}", Style::default().fg(palette::SUCCESS)), ])) .border_type(BorderType::Rounded) - .border_style(Style::default().fg(palette::SUCCESS_PEAK)) + .border_style(Style::default().fg(palette::SUCCESS)) } else { Block::default() .borders(Borders::ALL) From a37cb120c374111a73a684481517c87b5da8a829 Mon Sep 17 00:00:00 2001 From: Bo Maryniuk Date: Tue, 16 Jun 2026 23:50:58 +0200 Subject: [PATCH 13/15] Fix flaky journal tests --- libsysinspect/src/journal_ut.rs | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/libsysinspect/src/journal_ut.rs b/libsysinspect/src/journal_ut.rs index d36a0b59..8ca84084 100644 --- a/libsysinspect/src/journal_ut.rs +++ b/libsysinspect/src/journal_ut.rs @@ -147,7 +147,7 @@ fn reopen_after_partial_ack_preserves_survivors() { j.ack_cycle("c2").unwrap(); } { - let j = Journal::open(&dir, 0).unwrap(); + let j = open_with_retry(&dir, 0); let pending = j.pending().unwrap(); assert_eq!(pending.len(), 1); assert_eq!(pending[0].0, "c1"); @@ -163,7 +163,7 @@ fn budget_applies_after_reopen() { j.append("c1", b"1234567890").unwrap(); } { - let j = Journal::open(&dir, 20).unwrap(); + let j = open_with_retry(&dir, 20); j.append("c1", b"abcdefghij").unwrap(); j.append("c2", b"overflow!").unwrap(); let pending = j.pending().unwrap(); @@ -243,7 +243,7 @@ fn completed_cycle_marker_persists_across_reopen_until_ack() { assert!(j.is_cycle_locally_complete("c1").unwrap()); } { - let j = Journal::open(&dir, 0).unwrap(); + let j = open_with_retry(&dir, 0); assert!(j.is_cycle_locally_complete("c1").unwrap()); j.ack_cycle("c1").unwrap(); assert!(!j.is_cycle_locally_complete("c1").unwrap()); @@ -388,7 +388,7 @@ fn reopen_and_continue_appending() { j.ack_cycle("c1").unwrap(); } { - let j = Journal::open(&dir, 0).unwrap(); + let j = open_with_retry(&dir, 0); j.append("c2", b"c").unwrap(); j.append("c3", b"d").unwrap(); let pending = j.pending().unwrap(); From f31859b79aa0bd8ea677b0c60622d180010fdd93 Mon Sep 17 00:00:00 2001 From: Bo Maryniuk Date: Wed, 17 Jun 2026 00:18:08 +0200 Subject: [PATCH 14/15] Align setup form --- src/ui/setup.rs | 92 +++++++++++++++++++++++++++++++------------------ 1 file changed, 58 insertions(+), 34 deletions(-) diff --git a/src/ui/setup.rs b/src/ui/setup.rs index dfed4adf..dcaf4610 100644 --- a/src/ui/setup.rs +++ b/src/ui/setup.rs @@ -13,7 +13,6 @@ use ratatui::{ use ratatui_cheese::input::{Input, InputState}; use ratatui_glamour::color::blend_2d; use ratatui_glamour::rule::dashed_title; -use unicode_width::UnicodeWidthStr; #[derive(Clone, Copy, PartialEq, Eq, Debug)] pub enum InstallationMode { @@ -36,12 +35,19 @@ pub enum SetupFocus { } impl SetupFocus { - fn next(self) -> Self { + fn next(self, mode: InstallationMode) -> Self { use SetupFocus::*; + match self { SysMasterPath => SystemRadio, SystemRadio => CustomRadio, - CustomRadio => CustomDest, + CustomRadio => { + if mode == InstallationMode::Custom { + CustomDest + } else { + BindAddr + } + } CustomDest => BindAddr, BindAddr => BindPort, BindPort => FsPort, @@ -52,14 +58,21 @@ impl SetupFocus { } } - fn prev(self) -> Self { + fn prev(self, mode: InstallationMode) -> Self { use SetupFocus::*; + match self { SysMasterPath => Cancel, SystemRadio => SysMasterPath, CustomRadio => SystemRadio, CustomDest => CustomRadio, - BindAddr => CustomDest, + BindAddr => { + if mode == InstallationMode::Custom { + CustomDest + } else { + CustomRadio + } + } BindPort => BindAddr, FsPort => BindPort, ApiCheck => FsPort, @@ -171,10 +184,14 @@ impl MasterSetupWizard { } match key.code { KeyCode::Tab => { - self.focus = if key.modifiers.contains(KeyModifiers::SHIFT) { self.focus.prev() } else { self.focus.next() }; + self.focus = if key.modifiers.contains(KeyModifiers::SHIFT) { + self.focus.prev(self.installation_mode) + } else { + self.focus.next(self.installation_mode) + }; } KeyCode::BackTab => { - self.focus = self.focus.prev(); + self.focus = self.focus.prev(self.installation_mode); } KeyCode::Enter => match self.focus { SetupFocus::SysMasterPath => { @@ -268,14 +285,14 @@ impl MasterSetupWizard { return; } let dlg_w = (parent.width * 3 / 4).clamp(60, 72); - let dlg_h = if self.installation_mode == InstallationMode::Custom { 16u16 } else { 15u16 }; + let dlg_h = if self.installation_mode == InstallationMode::Custom { 15u16 } else { 14u16 }; 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]); + let grad = blend_2d(canvas.width as usize, canvas.height as usize, 10.0, &[palette::GRAY_0, 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; @@ -298,7 +315,10 @@ impl MasterSetupWizard { buf, canvas, &title_style, - &[TitleSegment { text: " Master Setup ".into(), bg: palette::PROCESSING_BASE, fg: palette::FG, modifier: Modifier::empty() }], + &[ + TitleSegment { text: " Master ".into(), bg: palette::PROCESSING_GLOW, fg: palette::WHITE, modifier: Modifier::empty() }, + TitleSegment { text: " Setup ".into(), bg: palette::PROCESSING_HEAT, fg: palette::WHITE, modifier: Modifier::empty() }, + ], ); if inner.height < 3 { @@ -328,30 +348,35 @@ impl MasterSetupWizard { &mut row_y, inner.width, buf, - " Sys Master:", + "Sys Master:", &self.sysmaster_path, self.is_focused(SetupFocus::SysMasterPath), label_w, ); - // Radio buttons — each on its own line + // Destination row with inline radio options let sys_checked = self.installation_mode == InstallationMode::SystemWide; let sys_style = if self.is_focused(SetupFocus::SystemRadio) { focus_style } else { muted }; let cus_style = if self.is_focused(SetupFocus::CustomRadio) { focus_style } else { muted }; - let sys_bullet = if sys_checked { "(•)" } else { "( )" }; - let cus_bullet = if sys_checked { "( )" } else { "(•)" }; - buf.set_string(inner.x + 3, row_y, format!(" {sys_bullet} System wide (/usr/bin) "), sys_style); - row_y += 1; - buf.set_string(inner.x + 3, row_y, format!(" {cus_bullet} Custom "), cus_style); + let sys_bullet = if sys_checked { "\u{1F518}" } else { "\u{25EF}" }; + let cus_bullet = if sys_checked { "\u{25EF}" } else { "\u{1F518}" }; + + let dest_label = format!("{:width$}", "Destination:", width = label_w as usize); + buf.set_string(inner.x + 2, row_y, &dest_label, muted); + let sys_text = format!(" {sys_bullet} System wide (/usr/bin) "); + let cus_text = format!(" {cus_bullet} Custom"); + let label_end = (inner.x + 2) + label_w; + buf.set_string(label_end, row_y, &sys_text, sys_style); + buf.set_string(label_end + sys_text.len() as u16, row_y, &cus_text, cus_style); row_y += 1; // Custom destination row (only when Custom is selected) if self.installation_mode == InstallationMode::Custom { - let cdest_label = " Custom destination: "; + let cdest_label = "Destination path: "; let cdest_lstyle = if self.is_focused(SetupFocus::CustomDest) { focus_style } else { muted }; - buf.set_string(inner.x + 5, row_y, cdest_label, cdest_lstyle); - let input_x = inner.x + 5 + label_w; + buf.set_string(inner.x + 2, row_y, cdest_label, cdest_lstyle); + let input_x = inner.x + 2 + label_w; let input_w = inner.width.saturating_sub(8 + label_w); if input_w > 0 { let mut is = Self::copy_input_state(&self.custom_destination, self.is_focused(SetupFocus::CustomDest)); @@ -359,8 +384,6 @@ impl MasterSetupWizard { StatefulWidget::render(&inp, Rect::new(input_x, row_y, input_w, 1), buf, &mut is); } row_y += 1; - } else { - row_y += 1; } // spacing @@ -383,38 +406,39 @@ impl MasterSetupWizard { &mut row_y, inner.width, buf, - " Bind address:", + "Bind address:", &self.bind_addr, self.is_focused(SetupFocus::BindAddr), label_w, ); // Bind port row - Self::render_input_row(inner.x, &mut row_y, inner.width, buf, " Bind port:", &self.bind_port, self.is_focused(SetupFocus::BindPort), label_w); + Self::render_input_row(inner.x, &mut row_y, inner.width, buf, "Bind port:", &self.bind_port, self.is_focused(SetupFocus::BindPort), label_w); // Fileserver port row Self::render_input_row( inner.x, &mut row_y, inner.width, buf, - " Fileserver port:", + "Fileserver port:", &self.fs_port, self.is_focused(SetupFocus::FsPort), label_w, ); // API checkbox - let api_chk = if self.api_enabled { "[x] Enable Web API" } else { "[ ] Enable Web API" }; + let api_chk = if self.api_enabled { " ▣ Enable Web API" } else { " □ Enable Web API" }; let api_style = if self.is_focused(SetupFocus::ApiCheck) { focus_style } else { muted }; - buf.set_string(inner.x + 3, row_y, api_chk, api_style); + buf.set_string(inner.x + 1, row_y, api_chk, api_style); row_y += 1; // spacing row_y += 1; // ── Buttons ── - let ok_label = " [ OK ] "; - let cancel_label = " [ Cancel ] "; - let btn_w = ok_label.width() as u16 + cancel_label.width() as u16 + 6; + let ok_label = super::SysInspectUX::format_button("OK"); + let cancel_label = super::SysInspectUX::format_button("Cancel"); + let gap = 3u16; + let btn_w = ok_label.len() as u16 + gap + cancel_label.len() as u16; let btn_x = inner.x + (inner.width.saturating_sub(btn_w)) / 2; let ok_style = if self.is_focused(SetupFocus::Ok) { @@ -428,8 +452,8 @@ impl MasterSetupWizard { 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.len() as u16 + gap, row_y, &cancel_label, cancel_style); if let Some(ref err) = self.error_message { let err_y = row_y.saturating_sub(1); @@ -474,8 +498,8 @@ impl MasterSetupWizard { 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; + buf.set_string(base_x + 2, *row_y, &label_padded, lstyle); + let input_x = base_x + 2 + label_w; let input_w = inner_width.saturating_sub(label_w + 6); if input_w > 0 { let mut is = Self::copy_input_state(state, focused); From 4004f2a75b37bf482c4d1b77936f0242b2c00adf Mon Sep 17 00:00:00 2001 From: Bo Maryniuk Date: Wed, 17 Jun 2026 00:26:57 +0200 Subject: [PATCH 15/15] Fix label highlights in setup --- src/ui/palette.rs | 3 +++ src/ui/setup.rs | 8 ++++---- 2 files changed, 7 insertions(+), 4 deletions(-) diff --git a/src/ui/palette.rs b/src/ui/palette.rs index 77d1255d..21556752 100644 --- a/src/ui/palette.rs +++ b/src/ui/palette.rs @@ -123,6 +123,9 @@ pub const FORM_LABEL: Color = SUCCESS_PEAK; /// Form labels when the associated control is disabled. pub const FORM_LABEL_DIMMED: Color = SUCCESS_BASE; +/// Form labels when selected. +pub const FORM_LABEL_SELECTED: Color = HIGHLIGHT; + /// Work in progress / active processing. pub const PROCESSING: Color = Color::Indexed(171); diff --git a/src/ui/setup.rs b/src/ui/setup.rs index dcaf4610..626072d9 100644 --- a/src/ui/setup.rs +++ b/src/ui/setup.rs @@ -326,8 +326,8 @@ impl MasterSetupWizard { } let label_w = 20u16; - let focus_style = Style::default().fg(palette::ACCENT).add_modifier(Modifier::BOLD); - let muted = Style::default().fg(palette::MUTED); + let focus_style = Style::default().fg(palette::FORM_LABEL_SELECTED).add_modifier(Modifier::BOLD); + let muted = Style::default().fg(palette::FORM_LABEL); let mut row_y = inner.y; @@ -494,8 +494,8 @@ impl MasterSetupWizard { fn render_input_row( base_x: u16, row_y: &mut u16, inner_width: u16, buf: &mut 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 muted = Style::default().fg(palette::FORM_LABEL); + let focus_style = Style::default().fg(palette::FORM_LABEL_SELECTED).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 + 2, *row_y, &label_padded, lstyle);