diff --git a/src/gui/admin/mod.rs b/src/gui/admin/mod.rs new file mode 100644 index 0000000..7f50756 --- /dev/null +++ b/src/gui/admin/mod.rs @@ -0,0 +1,5 @@ +mod state; +mod ui; + +pub use state::*; +pub use ui::*; diff --git a/src/gui/admin/state.rs b/src/gui/admin/state.rs new file mode 100644 index 0000000..357d019 --- /dev/null +++ b/src/gui/admin/state.rs @@ -0,0 +1,178 @@ +use eframe::egui; +use log::{error, info}; +use std::collections::HashMap; +use std::sync::mpsc; +use crate::gui::api::{SshKey, fetch_keys}; +use crate::gui::common::KhmSettings; + +#[derive(Debug, Clone)] +pub enum AdminOperation { + LoadingKeys, + DeprecatingKey, + RestoringKey, + DeletingKey, + BulkDeprecating, + BulkRestoring, + None, +} + +#[derive(Debug, Clone)] +pub struct AdminState { + pub keys: Vec, + pub filtered_keys: Vec, + pub search_term: String, + pub show_deprecated_only: bool, + pub selected_servers: HashMap, + pub expanded_servers: HashMap, + pub current_operation: AdminOperation, + pub last_load_time: Option, +} + +impl Default for AdminState { + fn default() -> Self { + Self { + keys: Vec::new(), + filtered_keys: Vec::new(), + search_term: String::new(), + show_deprecated_only: false, + selected_servers: HashMap::new(), + expanded_servers: HashMap::new(), + current_operation: AdminOperation::None, + last_load_time: None, + } + } +} + +impl AdminState { + /// Filter keys based on current search term and deprecated filter + pub fn filter_keys(&mut self) { + let mut filtered = self.keys.clone(); + + // Apply deprecated filter + if self.show_deprecated_only { + filtered.retain(|key| key.deprecated); + } + + // Apply search filter + if !self.search_term.is_empty() { + let search_term = self.search_term.to_lowercase(); + filtered.retain(|key| { + key.server.to_lowercase().contains(&search_term) || + key.public_key.to_lowercase().contains(&search_term) + }); + } + + self.filtered_keys = filtered; + } + + /// Load keys from server + pub fn load_keys(&mut self, settings: &KhmSettings, ctx: &egui::Context) -> Option, String>>> { + if settings.host.is_empty() || settings.flow.is_empty() { + return None; + } + + self.current_operation = AdminOperation::LoadingKeys; + + let (tx, rx) = mpsc::channel(); + + let host = settings.host.clone(); + let flow = settings.flow.clone(); + let basic_auth = settings.basic_auth.clone(); + let ctx_clone = ctx.clone(); + + std::thread::spawn(move || { + let rt = tokio::runtime::Runtime::new().unwrap(); + let result = rt.block_on(async { + fetch_keys(host, flow, basic_auth).await + }); + + let _ = tx.send(result); + ctx_clone.request_repaint(); + }); + + Some(rx) + } + + /// Handle keys load result + pub fn handle_keys_loaded(&mut self, result: Result, String>) { + match result { + Ok(keys) => { + self.keys = keys; + self.last_load_time = Some(std::time::Instant::now()); + self.filter_keys(); + self.current_operation = AdminOperation::None; + info!("Keys loaded successfully: {} keys", self.keys.len()); + } + Err(error) => { + self.current_operation = AdminOperation::None; + error!("Failed to load keys: {}", error); + } + } + } + + /// Get selected servers list + pub fn get_selected_servers(&self) -> Vec { + self.selected_servers + .iter() + .filter_map(|(server, &selected)| if selected { Some(server.clone()) } else { None }) + .collect() + } + + /// Clear selected servers + pub fn clear_selection(&mut self) { + self.selected_servers.clear(); + } + + /// Get statistics + pub fn get_statistics(&self) -> AdminStatistics { + let total_keys = self.keys.len(); + let active_keys = self.keys.iter().filter(|k| !k.deprecated).count(); + let deprecated_keys = total_keys - active_keys; + let unique_servers = self.keys.iter().map(|k| &k.server).collect::>().len(); + + AdminStatistics { + total_keys, + active_keys, + deprecated_keys, + unique_servers, + } + } +} + +#[derive(Debug, Clone)] +pub struct AdminStatistics { + pub total_keys: usize, + pub active_keys: usize, + pub deprecated_keys: usize, + pub unique_servers: usize, +} + +/// Get SSH key type from public key string +pub fn get_key_type(public_key: &str) -> String { + if public_key.starts_with("ssh-rsa") { + "RSA".to_string() + } else if public_key.starts_with("ssh-ed25519") { + "ED25519".to_string() + } else if public_key.starts_with("ecdsa-sha2-nistp") { + "ECDSA".to_string() + } else if public_key.starts_with("ssh-dss") { + "DSA".to_string() + } else { + "Unknown".to_string() + } +} + +/// Get preview of SSH key (first 12 characters of key part) +pub fn get_key_preview(public_key: &str) -> String { + let parts: Vec<&str> = public_key.split_whitespace().collect(); + if parts.len() >= 2 { + let key_part = parts[1]; + if key_part.len() > 12 { + format!("{}...", &key_part[..12]) + } else { + key_part.to_string() + } + } else { + format!("{}...", &public_key[..std::cmp::min(12, public_key.len())]) + } +} diff --git a/src/gui/admin/ui.rs b/src/gui/admin/ui.rs new file mode 100644 index 0000000..bed66fe --- /dev/null +++ b/src/gui/admin/ui.rs @@ -0,0 +1,451 @@ +use eframe::egui; +use std::collections::BTreeMap; +use super::state::{AdminState, get_key_type, get_key_preview}; +use crate::gui::api::SshKey; + +/// Render statistics cards +pub fn render_statistics(ui: &mut egui::Ui, admin_state: &AdminState) { + let stats = admin_state.get_statistics(); + + ui.group(|ui| { + ui.set_min_width(ui.available_width()); + ui.vertical(|ui| { + ui.label(egui::RichText::new("📊 Statistics").size(16.0).strong()); + ui.add_space(8.0); + + ui.horizontal(|ui| { + ui.columns(4, |cols| { + // Total keys + cols[0].vertical_centered_justified(|ui| { + ui.label(egui::RichText::new("📊").size(20.0)); + ui.label(egui::RichText::new(stats.total_keys.to_string()).size(24.0).strong()); + ui.label(egui::RichText::new("Total Keys").size(11.0).color(egui::Color32::GRAY)); + }); + + // Active keys + cols[1].vertical_centered_justified(|ui| { + ui.label(egui::RichText::new("✅").size(20.0)); + ui.label(egui::RichText::new(stats.active_keys.to_string()).size(24.0).strong().color(egui::Color32::LIGHT_GREEN)); + ui.label(egui::RichText::new("Active").size(11.0).color(egui::Color32::GRAY)); + }); + + // Deprecated keys + cols[2].vertical_centered_justified(|ui| { + ui.label(egui::RichText::new("❌").size(20.0)); + ui.label(egui::RichText::new(stats.deprecated_keys.to_string()).size(24.0).strong().color(egui::Color32::LIGHT_RED)); + ui.label(egui::RichText::new("Deprecated").size(11.0).color(egui::Color32::GRAY)); + }); + + // Servers + cols[3].vertical_centered_justified(|ui| { + ui.label(egui::RichText::new("💻").size(20.0)); + ui.label(egui::RichText::new(stats.unique_servers.to_string()).size(24.0).strong().color(egui::Color32::LIGHT_BLUE)); + ui.label(egui::RichText::new("Servers").size(11.0).color(egui::Color32::GRAY)); + }); + }); + }); + }); + }); +} + +/// Render search and filter controls +pub fn render_search_controls(ui: &mut egui::Ui, admin_state: &mut AdminState) -> bool { + let mut changed = false; + + ui.group(|ui| { + ui.set_min_width(ui.available_width()); + ui.vertical(|ui| { + ui.label(egui::RichText::new("🔍 Search").size(16.0).strong()); + ui.add_space(8.0); + + // Search field with full width + ui.horizontal(|ui| { + ui.label(egui::RichText::new("🔍").size(14.0)); + let search_response = ui.add_sized( + [ui.available_width() * 0.6, 20.0], + egui::TextEdit::singleline(&mut admin_state.search_term) + .hint_text("Search servers or keys...") + ); + + if admin_state.search_term.is_empty() { + ui.label(egui::RichText::new("Type to search").size(11.0).color(egui::Color32::GRAY)); + } else { + ui.label(egui::RichText::new(format!("{} results", admin_state.filtered_keys.len())).size(11.0)); + if ui.add(egui::Button::new(egui::RichText::new("❌").color(egui::Color32::WHITE)) + .fill(egui::Color32::from_rgb(170, 170, 170)) + .stroke(egui::Stroke::new(1.0, egui::Color32::from_rgb(89, 89, 89))) + .rounding(egui::Rounding::same(3.0)) + .min_size(egui::vec2(18.0, 18.0)) + ).on_hover_text("Clear search").clicked() { + admin_state.search_term.clear(); + changed = true; + } + } + + // Handle search text changes + if search_response.changed() { + changed = true; + } + }); + + ui.add_space(5.0); + + // Filter controls + ui.horizontal(|ui| { + ui.label("Filter:"); + let show_deprecated = admin_state.show_deprecated_only; + if ui.selectable_label(!show_deprecated, "✅ Active").clicked() { + admin_state.show_deprecated_only = false; + changed = true; + } + if ui.selectable_label(show_deprecated, "❗ Deprecated").clicked() { + admin_state.show_deprecated_only = true; + changed = true; + } + }); + }); + }); + + if changed { + admin_state.filter_keys(); + } + + changed +} + +/// Render bulk actions controls +pub fn render_bulk_actions(ui: &mut egui::Ui, admin_state: &mut AdminState) -> BulkAction { + let selected_count = admin_state.selected_servers.values().filter(|&&v| v).count(); + + if selected_count == 0 { + return BulkAction::None; + } + + let mut action = BulkAction::None; + + ui.group(|ui| { + ui.set_min_width(ui.available_width()); + ui.vertical(|ui| { + ui.horizontal(|ui| { + ui.label(egui::RichText::new("📋").size(14.0)); + ui.label(egui::RichText::new(format!("Selected {} servers", selected_count)) + .size(14.0) + .strong() + .color(egui::Color32::LIGHT_BLUE)); + }); + + ui.add_space(5.0); + + ui.horizontal(|ui| { + if ui.add(egui::Button::new(egui::RichText::new("❗ Deprecate Selected").color(egui::Color32::BLACK)) + .fill(egui::Color32::from_rgb(255, 200, 0)) + .stroke(egui::Stroke::new(1.0, egui::Color32::from_rgb(102, 94, 72))) + .rounding(egui::Rounding::same(6.0)) + .min_size(egui::vec2(130.0, 28.0)) + ).clicked() { + action = BulkAction::DeprecateSelected; + } + + if ui.add(egui::Button::new(egui::RichText::new("✅ Restore Selected").color(egui::Color32::WHITE)) + .fill(egui::Color32::from_rgb(101, 199, 40)) + .stroke(egui::Stroke::new(1.0, egui::Color32::from_rgb(94, 105, 25))) + .rounding(egui::Rounding::same(6.0)) + .min_size(egui::vec2(120.0, 28.0)) + ).clicked() { + action = BulkAction::RestoreSelected; + } + + if ui.add(egui::Button::new(egui::RichText::new("X Clear Selection").color(egui::Color32::WHITE)) + .fill(egui::Color32::from_rgb(170, 170, 170)) + .stroke(egui::Stroke::new(1.0, egui::Color32::from_rgb(89, 89, 89))) + .rounding(egui::Rounding::same(6.0)) + .min_size(egui::vec2(110.0, 28.0)) + ).clicked() { + admin_state.clear_selection(); + action = BulkAction::ClearSelection; + } + }); + }); + }); + + action +} + +/// Render keys table grouped by servers +pub fn render_keys_table(ui: &mut egui::Ui, admin_state: &mut AdminState) -> KeyAction { + if admin_state.filtered_keys.is_empty() { + render_empty_state(ui, admin_state); + return KeyAction::None; + } + + let mut action = KeyAction::None; + + // Group keys by server + let mut servers: BTreeMap> = BTreeMap::new(); + for key in &admin_state.filtered_keys { + servers.entry(key.server.clone()).or_insert_with(Vec::new).push(key.clone()); + } + + // Render each server group + for (server_name, server_keys) in servers { + let is_expanded = admin_state.expanded_servers.get(&server_name).copied().unwrap_or(false); + let active_count = server_keys.iter().filter(|k| !k.deprecated).count(); + let deprecated_count = server_keys.len() - active_count; + + // Server header + ui.group(|ui| { + ui.horizontal(|ui| { + // Server selection checkbox + let mut selected = admin_state.selected_servers.get(&server_name).copied().unwrap_or(false); + if ui.add(egui::Checkbox::new(&mut selected, "") + .indeterminate(false) + ).changed() { + admin_state.selected_servers.insert(server_name.clone(), selected); + } + + // Expand/collapse button + let expand_icon = if is_expanded { "▼" } else { "▶" }; + if ui.add(egui::Button::new(expand_icon) + .fill(egui::Color32::TRANSPARENT) + .stroke(egui::Stroke::NONE) + .min_size(egui::vec2(20.0, 20.0)) + ).clicked() { + admin_state.expanded_servers.insert(server_name.clone(), !is_expanded); + } + + // Server icon and name + ui.label(egui::RichText::new("💻").size(16.0)); + ui.label(egui::RichText::new(&server_name) + .size(15.0) + .strong() + .color(egui::Color32::WHITE)); + + // Keys count badge + render_badge(ui, &format!("{} keys", server_keys.len()), egui::Color32::from_rgb(52, 152, 219), egui::Color32::WHITE); + + ui.add_space(5.0); + + // Deprecated count badge + if deprecated_count > 0 { + render_badge(ui, &format!("{} depr", deprecated_count), egui::Color32::from_rgb(231, 76, 60), egui::Color32::WHITE); + } + + ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| { + // Server action buttons + if deprecated_count > 0 { + if ui.add(egui::Button::new(egui::RichText::new("✅ Restore").color(egui::Color32::WHITE)) + .fill(egui::Color32::from_rgb(101, 199, 40)) + .stroke(egui::Stroke::new(1.0, egui::Color32::from_rgb(94, 105, 25))) + .rounding(egui::Rounding::same(4.0)) + .min_size(egui::vec2(70.0, 24.0)) + ).clicked() { + action = KeyAction::RestoreServer(server_name.clone()); + } + } + + if active_count > 0 { + if ui.add(egui::Button::new(egui::RichText::new("❗ Deprecate").color(egui::Color32::BLACK)) + .fill(egui::Color32::from_rgb(255, 200, 0)) + .stroke(egui::Stroke::new(1.0, egui::Color32::from_rgb(102, 94, 72))) + .rounding(egui::Rounding::same(4.0)) + .min_size(egui::vec2(85.0, 24.0)) + ).clicked() { + action = KeyAction::DeprecateServer(server_name.clone()); + } + } + }); + }); + }); + + // Expanded key details + if is_expanded { + ui.indent("server_keys", |ui| { + for key in &server_keys { + if let Some(key_action) = render_key_item(ui, key, &server_name) { + action = key_action; + } + } + }); + } + + ui.add_space(5.0); + } + + action +} + +/// Render empty state when no keys are available +fn render_empty_state(ui: &mut egui::Ui, admin_state: &AdminState) { + ui.vertical_centered(|ui| { + ui.add_space(60.0); + if admin_state.keys.is_empty() { + ui.label(egui::RichText::new("🔑").size(48.0).color(egui::Color32::GRAY)); + ui.label(egui::RichText::new("No SSH keys available") + .size(18.0) + .color(egui::Color32::GRAY)); + ui.label(egui::RichText::new("Keys will appear here once loaded from the server") + .size(14.0) + .color(egui::Color32::DARK_GRAY)); + } else if !admin_state.search_term.is_empty() { + ui.label(egui::RichText::new("🔍").size(48.0).color(egui::Color32::GRAY)); + ui.label(egui::RichText::new("No results found") + .size(18.0) + .color(egui::Color32::GRAY)); + ui.label(egui::RichText::new(format!("Try adjusting your search: '{}'", admin_state.search_term)) + .size(14.0) + .color(egui::Color32::DARK_GRAY)); + } else { + ui.label(egui::RichText::new("❌").size(48.0).color(egui::Color32::GRAY)); + ui.label(egui::RichText::new("No keys match current filters") + .size(18.0) + .color(egui::Color32::GRAY)); + ui.label(egui::RichText::new("Try adjusting your search or filter settings") + .size(14.0) + .color(egui::Color32::DARK_GRAY)); + } + }); +} + +/// Render individual key item +fn render_key_item(ui: &mut egui::Ui, key: &SshKey, server_name: &str) -> Option { + let mut action = None; + + ui.group(|ui| { + ui.horizontal(|ui| { + // Key type badge + let key_type = get_key_type(&key.public_key); + let (badge_color, text_color) = match key_type.as_str() { + "RSA" => (egui::Color32::from_rgb(52, 144, 220), egui::Color32::WHITE), + "ED25519" => (egui::Color32::from_rgb(46, 204, 113), egui::Color32::WHITE), + "ECDSA" => (egui::Color32::from_rgb(241, 196, 15), egui::Color32::BLACK), + "DSA" => (egui::Color32::from_rgb(230, 126, 34), egui::Color32::WHITE), + _ => (egui::Color32::GRAY, egui::Color32::WHITE), + }; + + render_small_badge(ui, &key_type, badge_color, text_color); + ui.add_space(5.0); + + // Status badge + if key.deprecated { + ui.label(egui::RichText::new("❗ DEPR") + .size(10.0) + .color(egui::Color32::from_rgb(231, 76, 60)) + .strong()); + } else { + ui.label(egui::RichText::new("[OK] ACTIVE") + .size(10.0) + .color(egui::Color32::from_rgb(46, 204, 113)) + .strong()); + } + + ui.add_space(5.0); + + // Key preview + ui.label(egui::RichText::new(get_key_preview(&key.public_key)) + .font(egui::FontId::monospace(10.0)) + .color(egui::Color32::LIGHT_GRAY)); + + ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| { + // Key action buttons + if key.deprecated { + if ui.add(egui::Button::new(egui::RichText::new("[R]").color(egui::Color32::WHITE)) + .fill(egui::Color32::from_rgb(101, 199, 40)) + .stroke(egui::Stroke::new(1.0, egui::Color32::from_rgb(94, 105, 25))) + .rounding(egui::Rounding::same(3.0)) + .min_size(egui::vec2(22.0, 18.0)) + ).on_hover_text("Restore key").clicked() { + action = Some(KeyAction::RestoreKey(server_name.to_string())); + } + if ui.add(egui::Button::new(egui::RichText::new("Del").color(egui::Color32::WHITE)) + .fill(egui::Color32::from_rgb(246, 36, 71)) + .stroke(egui::Stroke::new(1.0, egui::Color32::from_rgb(129, 18, 17))) + .rounding(egui::Rounding::same(3.0)) + .min_size(egui::vec2(26.0, 18.0)) + ).on_hover_text("Delete key").clicked() { + action = Some(KeyAction::DeleteKey(server_name.to_string())); + } + } else { + if ui.add(egui::Button::new(egui::RichText::new("❗").color(egui::Color32::BLACK)) + .fill(egui::Color32::from_rgb(255, 200, 0)) + .stroke(egui::Stroke::new(1.0, egui::Color32::from_rgb(102, 94, 72))) + .rounding(egui::Rounding::same(3.0)) + .min_size(egui::vec2(22.0, 18.0)) + ).on_hover_text("Deprecate key").clicked() { + action = Some(KeyAction::DeprecateKey(server_name.to_string())); + } + } + + if ui.add(egui::Button::new(egui::RichText::new("Copy").color(egui::Color32::WHITE)) + .fill(egui::Color32::from_rgb(0, 111, 230)) + .stroke(egui::Stroke::new(1.0, egui::Color32::from_rgb(35, 84, 97))) + .rounding(egui::Rounding::same(3.0)) + .min_size(egui::vec2(30.0, 18.0)) + ).on_hover_text("Copy to clipboard").clicked() { + ui.output_mut(|o| o.copied_text = key.public_key.clone()); + } + }); + }); + }); + + action +} + +/// Render a badge with text +fn render_badge(ui: &mut egui::Ui, text: &str, bg_color: egui::Color32, text_color: egui::Color32) { + let (rect, _) = ui.allocate_exact_size( + egui::vec2(50.0, 18.0), + egui::Sense::hover() + ); + ui.painter().rect_filled( + rect, + egui::Rounding::same(8.0), + bg_color + ); + ui.painter().text( + rect.center(), + egui::Align2::CENTER_CENTER, + text, + egui::FontId::proportional(10.0), + text_color, + ); +} + +/// Render a small badge with text +fn render_small_badge(ui: &mut egui::Ui, text: &str, bg_color: egui::Color32, text_color: egui::Color32) { + let (rect, _) = ui.allocate_exact_size( + egui::vec2(40.0, 16.0), + egui::Sense::hover() + ); + ui.painter().rect_filled( + rect, + egui::Rounding::same(3.0), + bg_color + ); + ui.painter().text( + rect.center(), + egui::Align2::CENTER_CENTER, + text, + egui::FontId::proportional(9.0), + text_color, + ); +} + +/// Actions that can be performed on keys +#[derive(Debug, Clone)] +pub enum KeyAction { + None, + DeprecateKey(String), + RestoreKey(String), + DeleteKey(String), + DeprecateServer(String), + RestoreServer(String), +} + +/// Bulk actions that can be performed +#[derive(Debug, Clone)] +pub enum BulkAction { + None, + DeprecateSelected, + RestoreSelected, + ClearSelection, +} diff --git a/src/gui/api/client.rs b/src/gui/api/client.rs new file mode 100644 index 0000000..ea0ed50 --- /dev/null +++ b/src/gui/api/client.rs @@ -0,0 +1,254 @@ +use reqwest::Client; +use log::info; +use serde::{Deserialize, Serialize}; +use crate::gui::common::{KhmSettings, perform_sync}; + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct SshKey { + pub server: String, + pub public_key: String, + #[serde(default)] + pub deprecated: bool, +} + +/// Test connection to KHM server +pub async fn test_connection(host: String, flow: String, basic_auth: String) -> Result { + if host.is_empty() || flow.is_empty() { + return Err("Host and flow must be specified".to_string()); + } + + let url = format!("{}/{}/keys", host.trim_end_matches('/'), flow); + info!("Testing connection to: {}", url); + + let client = create_http_client()?; + let mut request = client.get(&url); + + request = add_auth_if_needed(request, &basic_auth)?; + + let response = request.send().await + .map_err(|e| format!("Request failed: {}", e))?; + + check_response_status(&response)?; + + let body = response.text().await + .map_err(|e| format!("Failed to read response: {}", e))?; + + check_html_response(&body)?; + + let keys: Vec = serde_json::from_str(&body) + .map_err(|e| format!("Failed to parse response: {}", e))?; + + let message = format!("Found {} SSH keys from flow '{}'", keys.len(), flow); + info!("Connection test successful: {}", message); + Ok(message) +} + +/// Fetch all SSH keys including deprecated ones +pub async fn fetch_keys(host: String, flow: String, basic_auth: String) -> Result, String> { + if host.is_empty() || flow.is_empty() { + return Err("Host and flow must be specified".to_string()); + } + + let url = format!("{}/{}/keys?include_deprecated=true", host.trim_end_matches('/'), flow); + info!("Fetching keys from: {}", url); + + let client = create_http_client()?; + let mut request = client.get(&url); + + request = add_auth_if_needed(request, &basic_auth)?; + + let response = request.send().await + .map_err(|e| format!("Request failed: {}", e))?; + + check_response_status(&response)?; + + let body = response.text().await + .map_err(|e| format!("Failed to read response: {}", e))?; + + check_html_response(&body)?; + + let keys: Vec = serde_json::from_str(&body) + .map_err(|e| format!("Failed to parse response: {}", e))?; + + info!("Fetched {} SSH keys", keys.len()); + Ok(keys) +} + +/// Deprecate a key for a specific server +pub async fn deprecate_key(host: String, flow: String, basic_auth: String, server: String) -> Result { + let url = format!("{}/{}/keys/{}", host.trim_end_matches('/'), flow, urlencoding::encode(&server)); + info!("Deprecating key for server '{}' at: {}", server, url); + + let client = create_http_client()?; + let mut request = client.delete(&url); + + request = add_auth_if_needed(request, &basic_auth)?; + + let response = request.send().await + .map_err(|e| format!("Request failed: {}", e))?; + + check_response_status(&response)?; + + let body = response.text().await + .map_err(|e| format!("Failed to read response: {}", e))?; + + parse_api_response(&body, &format!("Successfully deprecated key for server '{}'", server)) +} + +/// Restore a key for a specific server +pub async fn restore_key(host: String, flow: String, basic_auth: String, server: String) -> Result { + let url = format!("{}/{}/keys/{}/restore", host.trim_end_matches('/'), flow, urlencoding::encode(&server)); + info!("Restoring key for server '{}' at: {}", server, url); + + let client = create_http_client()?; + let mut request = client.post(&url); + + request = add_auth_if_needed(request, &basic_auth)?; + + let response = request.send().await + .map_err(|e| format!("Request failed: {}", e))?; + + check_response_status(&response)?; + + let body = response.text().await + .map_err(|e| format!("Failed to read response: {}", e))?; + + parse_api_response(&body, &format!("Successfully restored key for server '{}'", server)) +} + +/// Delete a key permanently for a specific server +pub async fn delete_key(host: String, flow: String, basic_auth: String, server: String) -> Result { + let url = format!("{}/{}/keys/{}/delete", host.trim_end_matches('/'), flow, urlencoding::encode(&server)); + info!("Permanently deleting key for server '{}' at: {}", server, url); + + let client = create_http_client()?; + let mut request = client.delete(&url); + + request = add_auth_if_needed(request, &basic_auth)?; + + let response = request.send().await + .map_err(|e| format!("Request failed: {}", e))?; + + check_response_status(&response)?; + + let body = response.text().await + .map_err(|e| format!("Failed to read response: {}", e))?; + + parse_api_response(&body, &format!("Successfully deleted key for server '{}'", server)) +} + +/// Bulk deprecate multiple servers +pub async fn bulk_deprecate_servers(host: String, flow: String, basic_auth: String, servers: Vec) -> Result { + let url = format!("{}/{}/bulk-deprecate", host.trim_end_matches('/'), flow); + info!("Bulk deprecating {} servers at: {}", servers.len(), url); + + let client = create_http_client()?; + let mut request = client.post(&url) + .json(&serde_json::json!({ + "servers": servers + })); + + request = add_auth_if_needed(request, &basic_auth)?; + + let response = request.send().await + .map_err(|e| format!("Request failed: {}", e))?; + + check_response_status(&response)?; + + let body = response.text().await + .map_err(|e| format!("Failed to read response: {}", e))?; + + parse_api_response(&body, "Successfully deprecated servers") +} + +/// Bulk restore multiple servers +pub async fn bulk_restore_servers(host: String, flow: String, basic_auth: String, servers: Vec) -> Result { + let url = format!("{}/{}/bulk-restore", host.trim_end_matches('/'), flow); + info!("Bulk restoring {} servers at: {}", servers.len(), url); + + let client = create_http_client()?; + let mut request = client.post(&url) + .json(&serde_json::json!({ + "servers": servers + })); + + request = add_auth_if_needed(request, &basic_auth)?; + + let response = request.send().await + .map_err(|e| format!("Request failed: {}", e))?; + + check_response_status(&response)?; + + let body = response.text().await + .map_err(|e| format!("Failed to read response: {}", e))?; + + parse_api_response(&body, "Successfully restored servers") +} + +/// Perform manual sync operation +pub async fn perform_manual_sync(settings: KhmSettings) -> Result { + match perform_sync(&settings).await { + Ok(keys_count) => Ok(format!("Sync completed successfully with {} keys", keys_count)), + Err(e) => Err(e.to_string()), + } +} + +// Helper functions + +fn create_http_client() -> Result { + Client::builder() + .timeout(std::time::Duration::from_secs(30)) + .redirect(reqwest::redirect::Policy::none()) + .build() + .map_err(|e| format!("Failed to create HTTP client: {}", e)) +} + +fn add_auth_if_needed(request: reqwest::RequestBuilder, basic_auth: &str) -> Result { + if basic_auth.is_empty() { + return Ok(request); + } + + let auth_parts: Vec<&str> = basic_auth.splitn(2, ':').collect(); + if auth_parts.len() == 2 { + Ok(request.basic_auth(auth_parts[0], Some(auth_parts[1]))) + } else { + Err("Basic auth format should be 'username:password'".to_string()) + } +} + +fn check_response_status(response: &reqwest::Response) -> Result<(), String> { + let status = response.status().as_u16(); + + if status == 401 { + return Err("Authentication required. Please provide valid basic auth credentials.".to_string()); + } + + if status >= 300 && status < 400 { + return Err("Server redirects to login page. Authentication may be required.".to_string()); + } + + if !response.status().is_success() { + return Err(format!("Server returned error: {} {}", status, response.status().canonical_reason().unwrap_or("Unknown"))); + } + + Ok(()) +} + +fn check_html_response(body: &str) -> Result<(), String> { + if body.trim_start().starts_with(" Result { + if let Ok(json_response) = serde_json::from_str::(body) { + if let Some(message) = json_response.get("message").and_then(|v| v.as_str()) { + Ok(message.to_string()) + } else { + Ok(default_message.to_string()) + } + } else { + Ok(default_message.to_string()) + } +} diff --git a/src/gui/api/mod.rs b/src/gui/api/mod.rs new file mode 100644 index 0000000..be50984 --- /dev/null +++ b/src/gui/api/mod.rs @@ -0,0 +1,3 @@ +mod client; + +pub use client::*; diff --git a/src/gui/common/mod.rs b/src/gui/common/mod.rs new file mode 100644 index 0000000..a10a586 --- /dev/null +++ b/src/gui/common/mod.rs @@ -0,0 +1,3 @@ +mod settings; + +pub use settings::*; diff --git a/src/gui/common/settings.rs b/src/gui/common/settings.rs new file mode 100644 index 0000000..c30f34e --- /dev/null +++ b/src/gui/common/settings.rs @@ -0,0 +1,143 @@ +use dirs::home_dir; +use log::{debug, error, info}; +use serde::{Deserialize, Serialize}; +use std::fs; +use std::path::PathBuf; + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct KhmSettings { + pub host: String, + pub flow: String, + pub known_hosts: String, + pub basic_auth: String, + pub in_place: bool, + pub auto_sync_interval_minutes: u32, +} + +impl Default for KhmSettings { + fn default() -> Self { + Self { + host: String::new(), + flow: String::new(), + known_hosts: get_default_known_hosts_path(), + basic_auth: String::new(), + in_place: true, + auto_sync_interval_minutes: 60, + } + } +} + +/// Get default known_hosts file path based on OS +fn get_default_known_hosts_path() -> String { + #[cfg(target_os = "windows")] + { + if let Ok(user_profile) = std::env::var("USERPROFILE") { + format!("{}/.ssh/known_hosts", user_profile) + } else { + "~/.ssh/known_hosts".to_string() + } + } + #[cfg(not(target_os = "windows"))] + { + "~/.ssh/known_hosts".to_string() + } +} + +/// Get configuration file path +pub fn get_config_path() -> PathBuf { + let mut path = home_dir().expect("Could not find home directory"); + path.push(".khm"); + fs::create_dir_all(&path).ok(); + path.push("khm_config.json"); + path +} + +/// Load settings from configuration file +pub fn load_settings() -> KhmSettings { + let path = get_config_path(); + match fs::read_to_string(&path) { + Ok(contents) => { + let mut settings: KhmSettings = serde_json::from_str(&contents).unwrap_or_else(|e| { + error!("Failed to parse KHM config: {}", e); + KhmSettings::default() + }); + + // Fill in default known_hosts path if empty + if settings.known_hosts.is_empty() { + settings.known_hosts = get_default_known_hosts_path(); + } + + settings + } + Err(_) => { + debug!("KHM config file not found, using defaults"); + KhmSettings::default() + } + } +} + +/// Save settings to configuration file +pub fn save_settings(settings: &KhmSettings) -> Result<(), std::io::Error> { + let path = get_config_path(); + let json = serde_json::to_string_pretty(settings)?; + fs::write(&path, json)?; + info!("KHM settings saved"); + Ok(()) +} + +/// Expand path with ~ substitution +pub fn expand_path(path: &str) -> String { + if path.starts_with("~/") { + if let Some(home) = home_dir() { + return home.join(&path[2..]).to_string_lossy().to_string(); + } + } + path.to_string() +} + +/// Perform sync operation using KHM client logic +pub async fn perform_sync(settings: &KhmSettings) -> Result { + use crate::Args; + + info!("Starting sync with settings: host={}, flow={}, known_hosts={}, in_place={}", + settings.host, settings.flow, settings.known_hosts, settings.in_place); + + // Convert KhmSettings to Args for client module + let args = Args { + server: false, + gui: false, + settings_ui: false, + in_place: settings.in_place, + flows: vec!["default".to_string()], // Not used in client mode + ip: "127.0.0.1".to_string(), // Not used in client mode + port: 8080, // Not used in client mode + db_host: "127.0.0.1".to_string(), // Not used in client mode + db_name: "khm".to_string(), // Not used in client mode + db_user: None, // Not used in client mode + db_password: None, // Not used in client mode + host: Some(settings.host.clone()), + flow: Some(settings.flow.clone()), + known_hosts: expand_path(&settings.known_hosts), + basic_auth: settings.basic_auth.clone(), + }; + + info!("Expanded known_hosts path: {}", args.known_hosts); + + // Get keys count before and after sync + let keys_before = crate::client::read_known_hosts(&args.known_hosts) + .unwrap_or_else(|_| Vec::new()) + .len(); + + crate::client::run_client(args.clone()).await?; + + let keys_after = if args.in_place { + crate::client::read_known_hosts(&args.known_hosts) + .unwrap_or_else(|_| Vec::new()) + .len() + } else { + keys_before + }; + + info!("Sync completed: {} keys before, {} keys after", keys_before, keys_after); + Ok(keys_after) +} diff --git a/src/gui/mod.rs b/src/gui/mod.rs index 37abe19..7c25092 100644 --- a/src/gui/mod.rs +++ b/src/gui/mod.rs @@ -1,565 +1,28 @@ -use log::{debug, error, info}; -use notify::RecursiveMode; -use notify_debouncer_mini::{new_debouncer, DebounceEventResult}; -use std::sync::{Arc, Mutex}; -use std::time::Duration; -use tray_icon::{ - menu::{Menu, MenuEvent, MenuItem, MenuId}, - TrayIcon, TrayIconBuilder, -}; -use winit::{ - application::ApplicationHandler, - event_loop::{EventLoop, EventLoopProxy}, -}; - -#[cfg(target_os = "macos")] -use winit::platform::macos::EventLoopBuilderExtMacOS; +use log::info; +use tray_icon::menu::MenuEvent; +// Modules +mod api; +mod admin; +mod common; mod settings; -pub use settings::{KhmSettings, load_settings}; +mod tray; -// Function to run settings window (for --settings-ui mode) -pub fn run_settings_window() { - settings::run_settings_window(); -} - -// Function to perform sync operation using KHM client logic -async fn perform_sync(settings: &KhmSettings) -> Result { - use crate::Args; - - info!("Starting sync with settings: host={}, flow={}, known_hosts={}, in_place={}", - settings.host, settings.flow, settings.known_hosts, settings.in_place); - - // Convert KhmSettings to Args for client module - let args = Args { - server: false, - gui: false, - settings_ui: false, - in_place: settings.in_place, - flows: vec!["default".to_string()], // Not used in client mode - ip: "127.0.0.1".to_string(), // Not used in client mode - port: 8080, // Not used in client mode - db_host: "127.0.0.1".to_string(), // Not used in client mode - db_name: "khm".to_string(), // Not used in client mode - db_user: None, // Not used in client mode - db_password: None, // Not used in client mode - host: Some(settings.host.clone()), - flow: Some(settings.flow.clone()), - known_hosts: settings::expand_path(&settings.known_hosts), - basic_auth: settings.basic_auth.clone(), - }; - - info!("Expanded known_hosts path: {}", args.known_hosts); - - // Get keys count before and after sync - let keys_before = crate::client::read_known_hosts(&args.known_hosts) - .unwrap_or_else(|_| Vec::new()) - .len(); - - crate::client::run_client(args.clone()).await?; - - let keys_after = if args.in_place { - crate::client::read_known_hosts(&args.known_hosts) - .unwrap_or_else(|_| Vec::new()) - .len() - } else { - keys_before - }; - - info!("Sync completed: {} keys before, {} keys after", keys_before, keys_after); - Ok(keys_after) -} +// Re-exports for backward compatibility and external usage +pub use settings::run_settings_window; +pub use tray::run_tray_app; +// User events for GUI communication #[derive(Debug)] -enum UserEvent { +pub enum UserEvent { TrayIconEvent, - MenuEvent(tray_icon::menu::MenuEvent), + MenuEvent(MenuEvent), ConfigFileChanged, UpdateMenu, } -#[derive(Debug, Clone)] -struct SyncStatus { - last_sync_time: Option, - last_sync_keys: Option, - next_sync_in_seconds: Option, -} - -impl Default for SyncStatus { - fn default() -> Self { - Self { - last_sync_time: None, - last_sync_keys: None, - next_sync_in_seconds: None, - } - } -} - -fn create_tray_icon(settings: &KhmSettings, sync_status: &SyncStatus) -> (TrayIcon, MenuId, MenuId, MenuId) { - // Create simple blue icon with "KHM" text representation - let icon_data: Vec = (0..32*32).flat_map(|i| { - let y = i / 32; - let x = i % 32; - if x < 2 || x >= 30 || y < 2 || y >= 30 { - [255, 255, 255, 255] // White border - } else { - [64, 128, 255, 255] // Blue center - } - }).collect(); - - let icon = tray_icon::Icon::from_rgba(icon_data, 32, 32).unwrap(); - let menu = Menu::new(); - - // Show current configuration status (static) - let host_text = if settings.host.is_empty() { - "Host: Not configured" - } else { - &format!("Host: {}", settings.host) - }; - menu.append(&MenuItem::new(host_text, false, None)).unwrap(); - - let flow_text = if settings.flow.is_empty() { - "Flow: Not configured" - } else { - &format!("Flow: {}", settings.flow) - }; - menu.append(&MenuItem::new(flow_text, false, None)).unwrap(); - - let is_auto_sync_enabled = !settings.host.is_empty() && !settings.flow.is_empty() && settings.in_place; - let sync_text = format!("Auto sync: {} ({}min)", - if is_auto_sync_enabled { "On" } else { "Off" }, - settings.auto_sync_interval_minutes); - menu.append(&MenuItem::new(&sync_text, false, None)).unwrap(); - - menu.append(&tray_icon::menu::PredefinedMenuItem::separator()).unwrap(); - - // Sync Now menu item - let sync_item = MenuItem::new("Sync Now", !settings.host.is_empty() && !settings.flow.is_empty(), None); - let sync_id = sync_item.id().clone(); - menu.append(&sync_item).unwrap(); - - menu.append(&tray_icon::menu::PredefinedMenuItem::separator()).unwrap(); - - // Settings menu item - let settings_item = MenuItem::new("Settings", true, None); - let settings_id = settings_item.id().clone(); - menu.append(&settings_item).unwrap(); - - menu.append(&tray_icon::menu::PredefinedMenuItem::separator()).unwrap(); - - // Quit menu item - let quit_item = MenuItem::new("Quit", true, None); - let quit_id = quit_item.id().clone(); - menu.append(&quit_item).unwrap(); - - // Create initial tooltip - let mut tooltip = format!("KHM - SSH Key Manager\nHost: {}\nFlow: {}", settings.host, settings.flow); - if let Some(keys_count) = sync_status.last_sync_keys { - tooltip.push_str(&format!("\nLast sync: {} keys", keys_count)); - } else { - tooltip.push_str("\nLast sync: Never"); - } - - let tray_icon = TrayIconBuilder::new() - .with_tooltip(&tooltip) - .with_icon(icon) - .with_menu(Box::new(menu)) - .build() - .unwrap(); - - (tray_icon, settings_id, quit_id, sync_id) -} - -struct Application { - tray_icon: Option, - settings_id: Option, - quit_id: Option, - sync_id: Option, - settings: Arc>, - sync_status: Arc>, - _debouncer: Option>, - proxy: EventLoopProxy, - auto_sync_handle: Option>, -} - -impl Application { - fn new(proxy: EventLoopProxy) -> Self { - Self { - tray_icon: None, - settings_id: None, - quit_id: None, - sync_id: None, - settings: Arc::new(Mutex::new(load_settings())), - sync_status: Arc::new(Mutex::new(SyncStatus::default())), - _debouncer: None, - proxy, - auto_sync_handle: None, - } - } - - fn update_menu(&mut self) { - if let Some(tray_icon) = &self.tray_icon { - let settings = self.settings.lock().unwrap(); - let menu = Menu::new(); - - // Show current configuration status (static) - let host_text = if settings.host.is_empty() { - "Host: Not configured" - } else { - &format!("Host: {}", settings.host) - }; - menu.append(&MenuItem::new(host_text, false, None)).unwrap(); - - let flow_text = if settings.flow.is_empty() { - "Flow: Not configured" - } else { - &format!("Flow: {}", settings.flow) - }; - menu.append(&MenuItem::new(flow_text, false, None)).unwrap(); - - let is_auto_sync_enabled = !settings.host.is_empty() && !settings.flow.is_empty() && settings.in_place; - let sync_text = format!("Auto sync: {} ({}min)", - if is_auto_sync_enabled { "On" } else { "Off" }, - settings.auto_sync_interval_minutes); - menu.append(&MenuItem::new(&sync_text, false, None)).unwrap(); - - menu.append(&tray_icon::menu::PredefinedMenuItem::separator()).unwrap(); - - // Sync Now menu item - let sync_item = MenuItem::new("Sync Now", !settings.host.is_empty() && !settings.flow.is_empty(), None); - let new_sync_id = sync_item.id().clone(); - menu.append(&sync_item).unwrap(); - - menu.append(&tray_icon::menu::PredefinedMenuItem::separator()).unwrap(); - - // Settings menu item - let settings_item = MenuItem::new("Settings", true, None); - let new_settings_id = settings_item.id().clone(); - menu.append(&settings_item).unwrap(); - - menu.append(&tray_icon::menu::PredefinedMenuItem::separator()).unwrap(); - - // Quit menu item - let quit_item = MenuItem::new("Quit", true, None); - let new_quit_id = quit_item.id().clone(); - menu.append(&quit_item).unwrap(); - - tray_icon.set_menu(Some(Box::new(menu))); - self.settings_id = Some(new_settings_id); - self.quit_id = Some(new_quit_id); - self.sync_id = Some(new_sync_id); - } - } - - fn setup_file_watcher(&mut self) { - let config_path = settings::get_config_path(); - let (tx, rx) = std::sync::mpsc::channel::(); - let proxy = self.proxy.clone(); - - std::thread::spawn(move || { - while let Ok(result) = rx.recv() { - if let Ok(events) = result { - if events.iter().any(|e| e.path.to_string_lossy().contains("khm_config.json")) { - let _ = proxy.send_event(UserEvent::ConfigFileChanged); - } - } - } - }); - - if let Ok(mut debouncer) = new_debouncer(Duration::from_millis(500), tx) { - if let Some(config_dir) = config_path.parent() { - if debouncer.watcher().watch(config_dir, RecursiveMode::NonRecursive).is_ok() { - debug!("File watcher started"); - self._debouncer = Some(debouncer); - } else { - error!("Failed to start file watcher"); - } - } - } - } - - fn start_auto_sync(&mut self) { - let settings = self.settings.lock().unwrap().clone(); - - // Only start auto sync if settings are valid and in_place is enabled - if settings.host.is_empty() || settings.flow.is_empty() || !settings.in_place { - info!("Auto sync disabled or settings invalid"); - return; - } - - info!("Starting auto sync with interval {} minutes", settings.auto_sync_interval_minutes); - - let settings_clone = Arc::clone(&self.settings); - let sync_status_clone = Arc::clone(&self.sync_status); - let proxy_clone = self.proxy.clone(); - let interval_minutes = settings.auto_sync_interval_minutes; - - let handle = std::thread::spawn(move || { - // Initial sync on startup - info!("Performing initial sync on startup"); - let current_settings = settings_clone.lock().unwrap().clone(); - if !current_settings.host.is_empty() && !current_settings.flow.is_empty() { - let rt = tokio::runtime::Runtime::new().unwrap(); - rt.block_on(async { - match perform_sync(¤t_settings).await { - Ok(keys_count) => { - info!("Initial sync completed successfully with {} keys", keys_count); - let mut status = sync_status_clone.lock().unwrap(); - status.last_sync_time = Some(std::time::Instant::now()); - status.last_sync_keys = Some(keys_count); - let _ = proxy_clone.send_event(UserEvent::UpdateMenu); - } - Err(e) => { - error!("Initial sync failed: {}", e); - } - } - }); - } - - // Start menu update timer - let proxy_timer = proxy_clone.clone(); - std::thread::spawn(move || { - loop { - std::thread::sleep(std::time::Duration::from_secs(1)); - let _ = proxy_timer.send_event(UserEvent::UpdateMenu); - } - }); - - // Periodic sync - loop { - std::thread::sleep(std::time::Duration::from_secs(interval_minutes as u64 * 60)); - - let current_settings = settings_clone.lock().unwrap().clone(); - if current_settings.host.is_empty() || current_settings.flow.is_empty() || !current_settings.in_place { - info!("Auto sync stopped due to invalid settings or disabled in_place"); - break; - } - - info!("Performing scheduled auto sync"); - let rt = tokio::runtime::Runtime::new().unwrap(); - rt.block_on(async { - match perform_sync(¤t_settings).await { - Ok(keys_count) => { - info!("Auto sync completed successfully with {} keys", keys_count); - let mut status = sync_status_clone.lock().unwrap(); - status.last_sync_time = Some(std::time::Instant::now()); - status.last_sync_keys = Some(keys_count); - let _ = proxy_clone.send_event(UserEvent::UpdateMenu); - } - Err(e) => { - error!("Auto sync failed: {}", e); - } - } - }); - } - }); - - self.auto_sync_handle = Some(handle); - } -} - -impl ApplicationHandler for Application { - fn window_event( - &mut self, - _event_loop: &winit::event_loop::ActiveEventLoop, - _window_id: winit::window::WindowId, - _event: winit::event::WindowEvent, - ) {} - - fn resumed(&mut self, _event_loop: &winit::event_loop::ActiveEventLoop) { - if self.tray_icon.is_none() { - info!("Creating tray icon"); - let settings = self.settings.lock().unwrap(); - let sync_status = self.sync_status.lock().unwrap(); - let (tray_icon, settings_id, quit_id, sync_id) = create_tray_icon(&settings, &sync_status); - drop(settings); - drop(sync_status); - - self.tray_icon = Some(tray_icon); - self.settings_id = Some(settings_id); - self.quit_id = Some(quit_id); - self.sync_id = Some(sync_id); - - self.setup_file_watcher(); - self.start_auto_sync(); - info!("KHM tray application ready"); - } - } - - fn user_event(&mut self, event_loop: &winit::event_loop::ActiveEventLoop, event: UserEvent) { - match event { - UserEvent::TrayIconEvent => {} - UserEvent::UpdateMenu => { - // Update tooltip with sync status instead of menu items - let settings = self.settings.lock().unwrap(); - if !settings.host.is_empty() && !settings.flow.is_empty() && settings.in_place { - let mut sync_status = self.sync_status.lock().unwrap(); - if let Some(last_sync) = sync_status.last_sync_time { - let elapsed = last_sync.elapsed().as_secs(); - let interval_seconds = settings.auto_sync_interval_minutes as u64 * 60; - - if elapsed < interval_seconds { - sync_status.next_sync_in_seconds = Some(interval_seconds - elapsed); - } else { - sync_status.next_sync_in_seconds = Some(0); - } - } else { - sync_status.next_sync_in_seconds = None; - } - - // Update tooltip with current status - if let Some(tray_icon) = &self.tray_icon { - let mut tooltip = format!("KHM - SSH Key Manager\nHost: {}\nFlow: {}", settings.host, settings.flow); - - if let Some(keys_count) = sync_status.last_sync_keys { - tooltip.push_str(&format!("\nLast sync: {} keys", keys_count)); - } else { - tooltip.push_str("\nLast sync: Never"); - } - - if let Some(seconds) = sync_status.next_sync_in_seconds { - if seconds > 60 { - tooltip.push_str(&format!("\nNext sync: {}m {}s", seconds / 60, seconds % 60)); - } else { - tooltip.push_str(&format!("\nNext sync: {}s", seconds)); - } - } - - let _ = tray_icon.set_tooltip(Some(&tooltip)); - } - } - drop(settings); - } - UserEvent::MenuEvent(event) => { - if let (Some(settings_id), Some(quit_id), Some(sync_id)) = (&self.settings_id, &self.quit_id, &self.sync_id) { - if event.id == *settings_id { - info!("Settings menu clicked"); - if let Ok(exe_path) = std::env::current_exe() { - std::thread::spawn(move || { - if let Err(e) = std::process::Command::new(&exe_path) - .arg("--gui") - .arg("--settings-ui") - .spawn() - { - error!("Failed to launch settings window: {}", e); - } - }); - } - } else if event.id == *quit_id { - info!("Quitting KHM application"); - event_loop.exit(); - } else if event.id == *sync_id { - info!("Starting sync operation"); - let settings = self.settings.lock().unwrap().clone(); - let sync_status_clone = Arc::clone(&self.sync_status); - let proxy_clone = self.proxy.clone(); - - // Check if settings are valid - if settings.host.is_empty() || settings.flow.is_empty() { - error!("Cannot sync: host or flow not configured"); - } else { - info!("Syncing with host: {}, flow: {}", settings.host, settings.flow); - - // Run sync in separate thread with its own tokio runtime - std::thread::spawn(move || { - let rt = tokio::runtime::Runtime::new().unwrap(); - rt.block_on(async { - match perform_sync(&settings).await { - Ok(keys_count) => { - info!("Sync completed successfully with {} keys", keys_count); - let mut status = sync_status_clone.lock().unwrap(); - status.last_sync_time = Some(std::time::Instant::now()); - status.last_sync_keys = Some(keys_count); - let _ = proxy_clone.send_event(UserEvent::UpdateMenu); - } - Err(e) => { - error!("Sync failed: {}", e); - } - } - }); - }); - } - } - } - } - UserEvent::ConfigFileChanged => { - debug!("Config file changed"); - let new_settings = load_settings(); - let old_interval = self.settings.lock().unwrap().auto_sync_interval_minutes; - let new_interval = new_settings.auto_sync_interval_minutes; - - *self.settings.lock().unwrap() = new_settings; - self.update_menu(); - - // Update tooltip with new settings - if let Some(tray_icon) = &self.tray_icon { - let settings = self.settings.lock().unwrap(); - let sync_status = self.sync_status.lock().unwrap(); - let mut tooltip = format!("KHM - SSH Key Manager\nHost: {}\nFlow: {}", settings.host, settings.flow); - - if let Some(keys_count) = sync_status.last_sync_keys { - tooltip.push_str(&format!("\nLast sync: {} keys", keys_count)); - } else { - tooltip.push_str("\nLast sync: Never"); - } - - if let Some(seconds) = sync_status.next_sync_in_seconds { - if seconds > 60 { - tooltip.push_str(&format!("\nNext sync: {}m {}s", seconds / 60, seconds % 60)); - } else { - tooltip.push_str(&format!("\nNext sync: {}s", seconds)); - } - } - - let _ = tray_icon.set_tooltip(Some(&tooltip)); - } - - // Restart auto sync if interval changed or settings changed - if old_interval != new_interval { - info!("Auto sync interval changed from {} to {} minutes, restarting auto sync", old_interval, new_interval); - // Note: The auto sync thread will automatically stop and restart based on settings - self.start_auto_sync(); - } - } - } - } -} - -async fn run_tray() -> std::io::Result<()> { - #[cfg(target_os = "macos")] - let event_loop = { - use winit::platform::macos::ActivationPolicy; - EventLoop::::with_user_event() - .with_activation_policy(ActivationPolicy::Accessory) - .build() - .map_err(|e| std::io::Error::new(std::io::ErrorKind::Other, format!("Failed to create event loop: {}", e)))? - }; - - #[cfg(not(target_os = "macos"))] - let event_loop = EventLoop::::with_user_event().build() - .map_err(|e| std::io::Error::new(std::io::ErrorKind::Other, format!("Failed to create event loop: {}", e)))?; - - let proxy = event_loop.create_proxy(); - - let proxy_clone = proxy.clone(); - tray_icon::TrayIconEvent::set_event_handler(Some(move |_event| { - let _ = proxy_clone.send_event(UserEvent::TrayIconEvent); - })); - - let proxy_clone = proxy.clone(); - MenuEvent::set_event_handler(Some(move |event: MenuEvent| { - let _ = proxy_clone.send_event(UserEvent::MenuEvent(event)); - })); - - let mut app = Application::new(proxy); - - event_loop.run_app(&mut app) - .map_err(|e| std::io::Error::new(std::io::ErrorKind::Other, format!("Event loop error: {:?}", e)))?; - - Ok(()) -} - +/// Run GUI application in tray mode pub async fn run_gui() -> std::io::Result<()> { info!("Starting KHM tray application"); - run_tray().await + run_tray_app().await } diff --git a/src/gui/settings/connection.rs b/src/gui/settings/connection.rs new file mode 100644 index 0000000..367e7f4 --- /dev/null +++ b/src/gui/settings/connection.rs @@ -0,0 +1,190 @@ +use eframe::egui; +use log::{error, info}; +use std::sync::mpsc; +use crate::gui::api::{test_connection, perform_manual_sync}; +use crate::gui::common::{KhmSettings, save_settings}; + +#[derive(Debug, Clone)] +pub enum ConnectionStatus { + Unknown, + Connected { keys_count: usize, flow: String }, + Error(String), +} + +#[derive(Debug, Clone)] +pub enum SyncStatus { + Unknown, + Success { keys_count: usize }, + Error(String), +} + +#[derive(Debug, Clone, PartialEq)] +pub enum SettingsTab { + Connection, + Admin, +} + +pub struct ConnectionTab { + pub connection_status: ConnectionStatus, + pub is_testing_connection: bool, + pub test_result_receiver: Option>>, + pub is_syncing: bool, + pub sync_result_receiver: Option>>, + pub sync_status: SyncStatus, +} + +impl Default for ConnectionTab { + fn default() -> Self { + Self { + connection_status: ConnectionStatus::Unknown, + is_testing_connection: false, + test_result_receiver: None, + is_syncing: false, + sync_result_receiver: None, + sync_status: SyncStatus::Unknown, + } + } +} + +impl ConnectionTab { + /// Start connection test + pub fn start_test(&mut self, settings: &KhmSettings, ctx: &egui::Context) { + if self.is_testing_connection { + return; + } + + self.is_testing_connection = true; + self.connection_status = ConnectionStatus::Unknown; + + let (tx, rx) = mpsc::channel(); + self.test_result_receiver = Some(rx); + + let host = settings.host.clone(); + let flow = settings.flow.clone(); + let basic_auth = settings.basic_auth.clone(); + let ctx_clone = ctx.clone(); + + std::thread::spawn(move || { + let rt = tokio::runtime::Runtime::new().unwrap(); + let result = rt.block_on(async { + test_connection(host, flow, basic_auth).await + }); + + let _ = tx.send(result); + ctx_clone.request_repaint(); + }); + } + + /// Start manual sync + pub fn start_sync(&mut self, settings: &KhmSettings, ctx: &egui::Context) { + if self.is_syncing { + return; + } + + self.is_syncing = true; + self.sync_status = SyncStatus::Unknown; + + let (tx, rx) = mpsc::channel(); + self.sync_result_receiver = Some(rx); + + let settings = settings.clone(); + let ctx_clone = ctx.clone(); + + std::thread::spawn(move || { + let rt = tokio::runtime::Runtime::new().unwrap(); + let result = rt.block_on(async { + perform_manual_sync(settings).await + }); + + let _ = tx.send(result); + ctx_clone.request_repaint(); + }); + } + + /// Check for test/sync results + pub fn check_results(&mut self, ctx: &egui::Context, settings: &KhmSettings) { + // Check for test connection result + if let Some(receiver) = &self.test_result_receiver { + if let Ok(result) = receiver.try_recv() { + self.is_testing_connection = false; + match result { + Ok(message) => { + // Parse keys count from message + let keys_count = if let Some(start) = message.find("Found ") { + if let Some(end) = message[start + 6..].find(" SSH keys") { + message[start + 6..start + 6 + end].parse::().unwrap_or(0) + } else { 0 } + } else { 0 }; + + self.connection_status = ConnectionStatus::Connected { + keys_count, + flow: settings.flow.clone() + }; + info!("Connection test successful: {}", message); + } + Err(error) => { + self.connection_status = ConnectionStatus::Error(error); + error!("Connection test failed"); + } + } + self.test_result_receiver = None; + ctx.request_repaint(); + } + } + + // Check for sync result + if let Some(receiver) = &self.sync_result_receiver { + if let Ok(result) = receiver.try_recv() { + self.is_syncing = false; + match result { + Ok(message) => { + // Parse keys count from message + let keys_count = parse_keys_count(&message); + self.sync_status = SyncStatus::Success { keys_count }; + info!("Sync successful: {}", message); + } + Err(error) => { + self.sync_status = SyncStatus::Error(error); + error!("Sync failed"); + } + } + self.sync_result_receiver = None; + ctx.request_repaint(); + } + } + } +} + +/// Parse keys count from sync result message +fn parse_keys_count(message: &str) -> usize { + if let Some(start) = message.find("updated with ") { + let search_start = start + "updated with ".len(); + if let Some(end) = message[search_start..].find(" keys") { + let number_str = &message[search_start..search_start + end]; + return number_str.parse::().unwrap_or(0); + } + } else if let Some(start) = message.find("Retrieved ") { + let search_start = start + "Retrieved ".len(); + if let Some(end) = message[search_start..].find(" keys") { + let number_str = &message[search_start..search_start + end]; + return number_str.parse::().unwrap_or(0); + } + } else if let Some(keys_pos) = message.find(" keys") { + let before_keys = &message[..keys_pos]; + if let Some(space_pos) = before_keys.rfind(' ') { + let number_str = &before_keys[space_pos + 1..]; + return number_str.parse::().unwrap_or(0); + } + } + + 0 +} + +/// Save settings with validation +pub fn save_settings_validated(settings: &KhmSettings) -> Result<(), String> { + if settings.host.is_empty() || settings.flow.is_empty() { + return Err("Host URL and Flow Name are required".to_string()); + } + + save_settings(settings).map_err(|e| format!("Failed to save settings: {}", e)) +} diff --git a/src/gui/settings/mod.rs b/src/gui/settings/mod.rs index e91a931..32c7ddd 100644 --- a/src/gui/settings/mod.rs +++ b/src/gui/settings/mod.rs @@ -1,1906 +1,5 @@ -use dirs::home_dir; -use eframe::egui; -use log::{debug, error, info}; -use serde::{Deserialize, Serialize}; -use std::collections::HashMap; -use std::fs; -use std::path::PathBuf; -use std::sync::mpsc; -use reqwest::Client; +mod connection; +mod ui; +mod window; -#[derive(Debug, Clone, Serialize, Deserialize)] -pub struct KhmSettings { - pub host: String, - pub flow: String, - pub known_hosts: String, - pub basic_auth: String, - pub in_place: bool, - pub auto_sync_interval_minutes: u32, -} - -#[derive(Debug, Clone, Serialize, Deserialize)] -pub struct SshKey { - pub server: String, - pub public_key: String, - #[serde(default)] - pub deprecated: bool, -} - -#[derive(Debug, Clone)] -enum AdminOperation { - LoadingKeys, - DeprecatingKey, - RestoringKey, - DeletingKey, - BulkDeprecating, - BulkRestoring, - None, -} - -#[derive(Debug, Clone)] -struct AdminState { - keys: Vec, - filtered_keys: Vec, - search_term: String, - show_deprecated_only: bool, - selected_servers: HashMap, - expanded_servers: HashMap, - current_operation: AdminOperation, - last_load_time: Option, -} - -impl Default for AdminState { - fn default() -> Self { - Self { - keys: Vec::new(), - filtered_keys: Vec::new(), - search_term: String::new(), - show_deprecated_only: false, - selected_servers: HashMap::new(), - expanded_servers: HashMap::new(), - current_operation: AdminOperation::None, - last_load_time: None, - } - } -} - -async fn fetch_admin_keys(host: String, flow: String, basic_auth: String) -> Result, String> { - if host.is_empty() || flow.is_empty() { - return Err("Host and flow must be specified".to_string()); - } - - let url = format!("{}/{}/keys?include_deprecated=true", host.trim_end_matches('/'), flow); - info!("Fetching admin keys from: {}", url); - - let client_builder = Client::builder() - .timeout(std::time::Duration::from_secs(30)) - .redirect(reqwest::redirect::Policy::none()); // Don't follow redirects - - let client = client_builder.build().map_err(|e| format!("Failed to create HTTP client: {}", e))?; - - let mut request = client.get(&url); - - // Add basic auth if provided - if !basic_auth.is_empty() { - let auth_parts: Vec<&str> = basic_auth.splitn(2, ':').collect(); - if auth_parts.len() == 2 { - request = request.basic_auth(auth_parts[0], Some(auth_parts[1])); - } else { - return Err("Basic auth format should be 'username:password'".to_string()); - } - } - - let response = request.send().await - .map_err(|e| format!("Request failed: {}", e))?; - - // Check for authentication required - if response.status().as_u16() == 401 { - return Err("Authentication required. Please provide valid basic auth credentials.".to_string()); - } - - // Check for redirects (usually to login page) - if response.status().as_u16() >= 300 && response.status().as_u16() < 400 { - return Err("Server redirects to login page. Authentication may be required.".to_string()); - } - - if !response.status().is_success() { - return Err(format!("Server returned error: {} {}", response.status().as_u16(), response.status().canonical_reason().unwrap_or("Unknown"))); - } - - let body = response.text().await - .map_err(|e| format!("Failed to read response: {}", e))?; - - // Check if response looks like HTML (login page) - if body.trim_start().starts_with(" = serde_json::from_str(&body) - .map_err(|e| format!("Failed to parse response: {}", e))?; - - info!("Fetched {} SSH keys", keys.len()); - Ok(keys) -} - -fn get_default_known_hosts_path() -> String { - #[cfg(target_os = "windows")] - { - if let Ok(user_profile) = std::env::var("USERPROFILE") { - format!("{}/.ssh/known_hosts", user_profile) - } else { - "~/.ssh/known_hosts".to_string() - } - } - #[cfg(not(target_os = "windows"))] - { - "~/.ssh/known_hosts".to_string() - } -} -impl Default for KhmSettings { - fn default() -> Self { - Self { - host: String::new(), - flow: String::new(), - known_hosts: get_default_known_hosts_path(), - basic_auth: String::new(), - in_place: true, - auto_sync_interval_minutes: 60, - } - } -} - -pub fn get_config_path() -> PathBuf { - let mut path = home_dir().expect("Could not find home directory"); - path.push(".khm"); - fs::create_dir_all(&path).ok(); - path.push("khm_config.json"); - path -} - -pub fn load_settings() -> KhmSettings { - let path = get_config_path(); - match fs::read_to_string(&path) { - Ok(contents) => { - let mut settings: KhmSettings = serde_json::from_str(&contents).unwrap_or_else(|e| { - error!("Failed to parse KHM config: {}", e); - KhmSettings::default() - }); - - // Fill in default known_hosts path if empty - if settings.known_hosts.is_empty() { - settings.known_hosts = get_default_known_hosts_path(); - } - - settings - } - Err(_) => { - debug!("KHM config file not found, using defaults"); - KhmSettings::default() - } - } -} - -pub fn save_settings(settings: &KhmSettings) -> Result<(), std::io::Error> { - let path = get_config_path(); - let json = serde_json::to_string_pretty(settings)?; - fs::write(&path, json)?; - info!("KHM settings saved"); - Ok(()) -} - -pub fn expand_path(path: &str) -> String { - if path.starts_with("~/") { - if let Some(home) = home_dir() { - return home.join(&path[2..]).to_string_lossy().to_string(); - } - } - path.to_string() -} - -struct KhmSettingsWindow { - settings: KhmSettings, - auto_sync_interval_str: String, - config_content: String, - connection_status: ConnectionStatus, - is_testing_connection: bool, - test_result_receiver: Option>>, - admin_state: AdminState, - admin_receiver: Option, String>>>, - operation_receiver: Option>>, - current_tab: SettingsTab, - is_syncing: bool, - sync_result_receiver: Option>>, - sync_status: SyncStatus, - operation_log: Vec, -} - -#[derive(Debug, Clone, PartialEq)] -enum SettingsTab { - Connection, - Admin, -} - -#[derive(Debug, Clone)] -enum ConnectionStatus { - Unknown, - Connected { keys_count: usize, flow: String }, - Error(String), -} - -#[derive(Debug, Clone)] -enum SyncStatus { - Unknown, - Success { keys_count: usize }, - Error(String), -} - -impl eframe::App for KhmSettingsWindow { - fn update(&mut self, ctx: &egui::Context, _frame: &mut eframe::Frame) { - // Check for test connection result - if let Some(receiver) = &self.test_result_receiver { - if let Ok(result) = receiver.try_recv() { - self.is_testing_connection = false; - match result { - Ok(message) => { - // Parse keys count from message - let keys_count = if let Some(start) = message.find("Found ") { - if let Some(end) = message[start + 6..].find(" SSH keys") { - message[start + 6..start + 6 + end].parse::().unwrap_or(0) - } else { 0 } - } else { 0 }; - - let flow = self.settings.flow.clone(); - self.connection_status = ConnectionStatus::Connected { keys_count, flow }; - self.add_log_entry(format!("✅ Connection test successful: Found {} keys", keys_count)); - info!("Connection test successful: {}", message); - } - Err(error) => { - self.connection_status = ConnectionStatus::Error(error.clone()); - self.add_log_entry(format!("❌ Connection test failed: {}", error)); - error!("Connection test failed: {}", error); - } - } - self.test_result_receiver = None; - ctx.request_repaint(); - } - } - - // Check for admin operation results - if let Some(receiver) = &self.admin_receiver { - if let Ok(result) = receiver.try_recv() { - match result { - Ok(keys) => { - self.admin_state.keys = keys; - self.admin_state.last_load_time = Some(std::time::Instant::now()); - self.filter_admin_keys(); - self.admin_state.current_operation = AdminOperation::None; - info!("Keys loaded successfully: {} keys", self.admin_state.keys.len()); - } - Err(error) => { - self.admin_state.current_operation = AdminOperation::None; - error!("Failed to load keys: {}", error); - } - } - self.admin_receiver = None; - ctx.request_repaint(); - } - } - - // Check for sync result - if let Some(receiver) = &self.sync_result_receiver { - if let Ok(result) = receiver.try_recv() { - self.is_syncing = false; - match result { - Ok(message) => { - info!("Parsing sync result message: '{}'", message); - - // Parse keys count from message - fix parsing patterns - let keys_count = if let Some(start) = message.find("updated with ") { - let search_start = start + "updated with ".len(); - if let Some(end) = message[search_start..].find(" keys") { - let number_str = &message[search_start..search_start + end]; - info!("Found 'updated with' pattern, parsing: '{}'", number_str); - number_str.parse::().unwrap_or(0) - } else { 0 } - } else if let Some(start) = message.find("Retrieved ") { - let search_start = start + "Retrieved ".len(); - if let Some(end) = message[search_start..].find(" keys") { - let number_str = &message[search_start..search_start + end]; - info!("Found 'Retrieved' pattern, parsing: '{}'", number_str); - number_str.parse::().unwrap_or(0) - } else { 0 } - } else { - // Try to extract any number followed by "keys" in the message - if let Some(keys_pos) = message.find(" keys") { - let before_keys = &message[..keys_pos]; - // Find the last number in the string before "keys" - if let Some(space_pos) = before_keys.rfind(' ') { - let number_str = &before_keys[space_pos + 1..]; - info!("Found fallback pattern, parsing: '{}'", number_str); - number_str.parse::().unwrap_or(0) - } else { - 0 - } - } else { - 0 - } - }; - - info!("Parsed keys count: {}", keys_count); - self.sync_status = SyncStatus::Success { keys_count }; - self.add_log_entry(format!("✅ Sync completed successfully: {} keys", keys_count)); - info!("Sync successful: {}", message); - } - Err(error) => { - self.sync_status = SyncStatus::Error(error.clone()); - self.add_log_entry(format!("❌ Sync failed: {}", error)); - error!("Sync failed: {}", error); - } - } - self.sync_result_receiver = None; - ctx.request_repaint(); - } - } - // Check for operation results - if let Some(receiver) = &self.operation_receiver { - if let Ok(result) = receiver.try_recv() { - match result { - Ok(message) => { - info!("Operation completed: {}", message); - // Reload keys after operation - self.load_admin_keys(ctx); - } - Err(error) => { - error!("Operation failed: {}", error); - } - } - self.admin_state.current_operation = AdminOperation::None; - self.operation_receiver = None; - ctx.request_repaint(); - } - } - - // Apply enhanced modern dark theme for admin interface - let mut visuals = egui::Visuals::dark(); - visuals.window_fill = egui::Color32::from_gray(25); - visuals.panel_fill = egui::Color32::from_gray(30); - visuals.faint_bg_color = egui::Color32::from_gray(35); - visuals.extreme_bg_color = egui::Color32::from_gray(15); - visuals.button_frame = true; - visuals.collapsing_header_frame = true; - visuals.indent_has_left_vline = true; - visuals.menu_rounding = egui::Rounding::same(8.0); - visuals.window_rounding = egui::Rounding::same(12.0); - visuals.widgets.noninteractive.rounding = egui::Rounding::same(6.0); - visuals.widgets.inactive.rounding = egui::Rounding::same(6.0); - visuals.widgets.hovered.rounding = egui::Rounding::same(6.0); - visuals.widgets.active.rounding = egui::Rounding::same(6.0); - ctx.set_visuals(visuals); - - egui::CentralPanel::default() - .frame(egui::Frame::none().inner_margin(egui::Margin::same(20.0))) - .show(ctx, |ui| { - // Header with title - ui.horizontal(|ui| { - ui.heading(egui::RichText::new("🔑 KHM Settings").size(24.0)); - }); - - ui.add_space(10.0); - - // Tab selector - ui.horizontal(|ui| { - ui.selectable_value(&mut self.current_tab, SettingsTab::Connection, "📃 Settings"); - ui.selectable_value(&mut self.current_tab, SettingsTab::Admin, "🔧 Admin"); - }); - - ui.separator(); - ui.add_space(15.0); - - match self.current_tab { - SettingsTab::Connection => self.render_connection_tab(ui, ctx), - SettingsTab::Admin => self.render_admin_tab(ui, ctx), - } - }); - } -} - -impl KhmSettingsWindow { - fn add_log_entry(&mut self, message: String) { - let now = std::time::SystemTime::now() - .duration_since(std::time::UNIX_EPOCH) - .unwrap(); - let secs = now.as_secs(); - let millis = now.subsec_millis(); - - // Format as HH:MM:SS.mmm - let hours = (secs / 3600) % 24; - let minutes = (secs / 60) % 60; - let seconds = secs % 60; - let timestamp = format!("{:02}:{:02}:{:02}.{:03}", hours, minutes, seconds, millis); - - let log_entry = format!("{} {}", timestamp, message); - - self.operation_log.push(log_entry); - - // Keep only last 20 entries to prevent memory growth - if self.operation_log.len() > 20 { - self.operation_log.remove(0); - } - } - - fn render_connection_tab(&mut self, ui: &mut egui::Ui, ctx: &egui::Context) { - let available_height = ui.available_height(); - let button_area_height = 120.0; // Reserve space for buttons and status - let content_height = available_height - button_area_height; - - // Main content area (scrollable) - ui.allocate_ui_with_layout( - [ui.available_width(), content_height].into(), - egui::Layout::top_down(egui::Align::Min), - |ui| { - egui::ScrollArea::vertical() - .auto_shrink([false; 2]) - .show(ui, |ui| { - // Connection section - ui.group(|ui| { - ui.set_min_width(ui.available_width()); - ui.vertical(|ui| { - ui.horizontal(|ui| { - ui.label(egui::RichText::new("🌐 Connection").size(16.0).strong()); - ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| { - let mut connected = matches!(self.connection_status, ConnectionStatus::Connected { .. }); - ui.add_enabled(false, egui::Checkbox::new(&mut connected, "Connected")); - - if self.is_testing_connection { - ui.spinner(); - ui.label(egui::RichText::new("Testing...").italics()); - } - }); - }); - - ui.add_space(5.0); - - egui::Grid::new("connection_grid") - .num_columns(2) - .min_col_width(120.0) - .spacing([10.0, 8.0]) - .show(ui, |ui| { - ui.label("Host URL:"); - ui.add_sized( - [ui.available_width(), 20.0], - egui::TextEdit::singleline(&mut self.settings.host) - .hint_text("https://your-khm-server.com") - ); - ui.end_row(); - - ui.label("Flow Name:"); - ui.add_sized( - [ui.available_width(), 20.0], - egui::TextEdit::singleline(&mut self.settings.flow) - .hint_text("production, staging, etc.") - ); - ui.end_row(); - - ui.label("Basic Auth:"); - ui.add_sized( - [ui.available_width(), 20.0], - egui::TextEdit::singleline(&mut self.settings.basic_auth) - .hint_text("username:password (optional)") - .password(true) - ); - ui.end_row(); - }); - }); - }); - - ui.add_space(10.0); - - // Local settings section - ui.group(|ui| { - ui.set_min_width(ui.available_width()); - ui.vertical(|ui| { - ui.label(egui::RichText::new("📁 Local Settings").size(16.0).strong()); - ui.add_space(8.0); - - egui::Grid::new("local_grid") - .num_columns(2) - .min_col_width(120.0) - .spacing([10.0, 8.0]) - .show(ui, |ui| { - ui.label("Known Hosts File:"); - ui.add_sized( - [ui.available_width(), 20.0], - egui::TextEdit::singleline(&mut self.settings.known_hosts) - .hint_text("~/.ssh/known_hosts") - ); - ui.end_row(); - }); - - ui.add_space(8.0); - ui.checkbox(&mut self.settings.in_place, "✏ Update known_hosts file in-place after sync"); - }); - }); - - ui.add_space(15.0); - - // Auto-sync section - ui.group(|ui| { - ui.set_min_width(ui.available_width()); - ui.vertical(|ui| { - ui.label(egui::RichText::new("🔄 Auto Sync").size(16.0).strong()); - ui.add_space(8.0); - - let is_auto_sync_enabled = !self.settings.host.is_empty() - && !self.settings.flow.is_empty() - && self.settings.in_place; - - ui.horizontal(|ui| { - ui.label("Interval (minutes):"); - ui.add_sized( - [80.0, 20.0], - egui::TextEdit::singleline(&mut self.auto_sync_interval_str) - ); - - if let Ok(value) = self.auto_sync_interval_str.parse::() { - if value > 0 { - self.settings.auto_sync_interval_minutes = value; - } - } - - ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| { - if is_auto_sync_enabled { - ui.label(egui::RichText::new("🔄 Enabled").color(egui::Color32::GREEN)); - } else { - ui.label(egui::RichText::new("❌ Disabled").color(egui::Color32::YELLOW)); - ui.label("(Configure host, flow & enable in-place sync)"); - } - }); - }); - }); - }); - - ui.add_space(10.0); - - // Configuration file location - ui.group(|ui| { - ui.set_min_width(ui.available_width()); - ui.horizontal(|ui| { - ui.label("🗁 Config file:"); - let config_path = get_config_path(); - ui.add_sized( - [ui.available_width(), 20.0], - egui::TextEdit::singleline(&mut config_path.display().to_string()) - .interactive(false) - ); - }); - }); - }); - }, - ); - - // Bottom area with buttons (fixed position) - ui.allocate_ui_with_layout( - [ui.available_width(), button_area_height].into(), - egui::Layout::bottom_up(egui::Align::Min), - |ui| { - // Operation log area - ui.group(|ui| { - ui.set_min_width(ui.available_width()); - ui.vertical(|ui| { - ui.label(egui::RichText::new("📄 Operation Log").size(14.0).strong()); - ui.add_space(5.0); - - let log_text = self.operation_log.join("\n"); - ui.add_sized( - [ui.available_width(), 60.0], - egui::TextEdit::multiline(&mut log_text.clone()) - .font(egui::FontId::monospace(10.0)) - .interactive(false) - ); - }); - }); - - ui.add_space(8.0); - - // Show validation hints - let save_enabled = !self.settings.host.is_empty() && !self.settings.flow.is_empty(); - if !save_enabled { - ui.label(egui::RichText::new("❗ Please fill in Host URL and Flow Name to save settings") - .color(egui::Color32::YELLOW) - .italics()); - } - - ui.add_space(5.0); - - // Action buttons - ui.horizontal(|ui| { - if ui.add_enabled( - save_enabled, - egui::Button::new("💾 Save Settings") - .min_size(egui::vec2(120.0, 32.0)) - ).clicked() { - if let Err(e) = save_settings(&self.settings) { - error!("Failed to save KHM settings: {}", e); - } else { - info!("KHM settings saved successfully"); - } - ctx.send_viewport_cmd(egui::ViewportCommand::Close); - } - - if ui.add( - egui::Button::new("✖ Cancel") - .min_size(egui::vec2(80.0, 32.0)) - ).clicked() { - ctx.send_viewport_cmd(egui::ViewportCommand::Close); - } - - ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| { - let can_test = !self.settings.host.is_empty() && !self.settings.flow.is_empty() && !self.is_testing_connection; - let can_sync = !self.settings.host.is_empty() && !self.settings.flow.is_empty() && !self.is_syncing; - - if ui.add_enabled( - can_test, - egui::Button::new( - if self.is_testing_connection { - "▶ Testing..." - } else { - "🔍 Test Connection" - } - ).min_size(egui::vec2(120.0, 32.0)) - ).clicked() { - self.start_connection_test(ctx); - } - - if ui.add_enabled( - can_sync, - egui::Button::new( - if self.is_syncing { - "🔄 Syncing..." - } else { - "🔄 Sync Now" - } - ).min_size(egui::vec2(100.0, 32.0)) - ).clicked() { - self.start_manual_sync(ctx); - } - }); - }); - }, - ); - } - - fn start_connection_test(&mut self, ctx: &egui::Context) { - if self.is_testing_connection { - return; - } - - self.is_testing_connection = true; - self.connection_status = ConnectionStatus::Unknown; - self.add_log_entry("🔍 Starting connection test...".to_string()); - - let (tx, rx) = mpsc::channel(); - self.test_result_receiver = Some(rx); - - let host = self.settings.host.clone(); - let flow = self.settings.flow.clone(); - let basic_auth = self.settings.basic_auth.clone(); - let ctx_clone = ctx.clone(); - - std::thread::spawn(move || { - let rt = tokio::runtime::Runtime::new().unwrap(); - let result = rt.block_on(async { - test_khm_connection(host, flow, basic_auth).await - }); - - let _ = tx.send(result); - ctx_clone.request_repaint(); - }); - } - - fn start_manual_sync(&mut self, ctx: &egui::Context) { - if self.is_syncing { - return; - } - - self.is_syncing = true; - self.sync_status = SyncStatus::Unknown; - self.add_log_entry("🔄 Starting manual sync...".to_string()); - - let (tx, rx) = mpsc::channel(); - self.sync_result_receiver = Some(rx); - - let settings = self.settings.clone(); - let ctx_clone = ctx.clone(); - - std::thread::spawn(move || { - let rt = tokio::runtime::Runtime::new().unwrap(); - let result = rt.block_on(async { - perform_manual_sync(settings).await - }); - - let _ = tx.send(result); - ctx_clone.request_repaint(); - }); - } - - fn render_admin_tab(&mut self, ui: &mut egui::Ui, ctx: &egui::Context) { - // Admin tab header - ui.horizontal(|ui| { - ui.label(egui::RichText::new("🔧 Admin Panel").size(18.0).strong()); - - ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| { - if ui.button("🔁 Refresh").clicked() { - self.load_admin_keys(ctx); - } - - if let Some(last_load) = self.admin_state.last_load_time { - let elapsed = last_load.elapsed().as_secs(); - ui.label(format!("Updated {}s ago", elapsed)); - } - }); - }); - - ui.separator(); - ui.add_space(10.0); - - // Check if connection is configured - if self.settings.host.is_empty() || self.settings.flow.is_empty() { - ui.vertical_centered(|ui| { - ui.label(egui::RichText::new("❗ Please configure connection settings first") - .size(16.0) - .color(egui::Color32::YELLOW)); - ui.add_space(10.0); - if ui.button("Go to Connection Settings").clicked() { - self.current_tab = SettingsTab::Connection; - } - }); - return; - } - - // Load keys automatically on first view - if self.admin_state.keys.is_empty() && !matches!(self.admin_state.current_operation, AdminOperation::LoadingKeys) { - self.load_admin_keys(ctx); - } - - // Show loading state - if matches!(self.admin_state.current_operation, AdminOperation::LoadingKeys) { - ui.vertical_centered(|ui| { - ui.spinner(); - ui.label("Loading keys..."); - }); - return; - } - - // Statistics cards - адаптивные как в Connection - ui.group(|ui| { - ui.set_min_width(ui.available_width()); - ui.vertical(|ui| { - ui.label(egui::RichText::new("📊 Statistics").size(16.0).strong()); - ui.add_space(8.0); - - let total_keys = self.admin_state.keys.len(); - let active_keys = self.admin_state.keys.iter().filter(|k| !k.deprecated).count(); - let deprecated_keys = total_keys - active_keys; - let unique_servers = self.admin_state.keys.iter().map(|k| &k.server).collect::>().len(); - - ui.horizontal(|ui| { - ui.columns(4, |cols| { - // Total keys - cols[0].vertical_centered_justified(|ui| { - ui.label(egui::RichText::new("📊").size(20.0)); - ui.label(egui::RichText::new(total_keys.to_string()).size(24.0).strong()); - ui.label(egui::RichText::new("Total Keys").size(11.0).color(egui::Color32::GRAY)); - }); - - // Active keys - cols[1].vertical_centered_justified(|ui| { - ui.label(egui::RichText::new("✅").size(20.0)); - ui.label(egui::RichText::new(active_keys.to_string()).size(24.0).strong().color(egui::Color32::LIGHT_GREEN)); - ui.label(egui::RichText::new("Active").size(11.0).color(egui::Color32::GRAY)); - }); - - // Deprecated keys - cols[2].vertical_centered_justified(|ui| { - ui.label(egui::RichText::new("❌").size(20.0)); - ui.label(egui::RichText::new(deprecated_keys.to_string()).size(24.0).strong().color(egui::Color32::LIGHT_RED)); - ui.label(egui::RichText::new("Deprecated").size(11.0).color(egui::Color32::GRAY)); - }); - - // Servers - cols[3].vertical_centered_justified(|ui| { - ui.label(egui::RichText::new("💻").size(20.0)); - ui.label(egui::RichText::new(unique_servers.to_string()).size(24.0).strong().color(egui::Color32::LIGHT_BLUE)); - ui.label(egui::RichText::new("Servers").size(11.0).color(egui::Color32::GRAY)); - }); - }); - }); - }); - }); - - ui.add_space(10.0); - - // Enhanced search and filters - адаптивный подход как в блоках статистики - ui.group(|ui| { - ui.set_min_width(ui.available_width()); - ui.vertical(|ui| { - ui.label(egui::RichText::new("🔍 Search").size(16.0).strong()); - ui.add_space(8.0); - - // Search field with full width like statistics blocks - ui.horizontal(|ui| { - ui.label(egui::RichText::new("🔍").size(14.0)); - let search_response = ui.add_sized( - [ui.available_width() * 0.6, 20.0], - egui::TextEdit::singleline(&mut self.admin_state.search_term) - .hint_text("Search servers or keys...") - ); - - if self.admin_state.search_term.is_empty() { - ui.label(egui::RichText::new("Type to search").size(11.0).color(egui::Color32::GRAY)); - } else { - ui.label(egui::RichText::new(format!("{} results", self.admin_state.filtered_keys.len())).size(11.0)); - if ui.add(egui::Button::new(egui::RichText::new("❌").color(egui::Color32::WHITE)) - .fill(egui::Color32::from_rgb(170, 170, 170)) - .stroke(egui::Stroke::new(1.0, egui::Color32::from_rgb(89, 89, 89))) - .rounding(egui::Rounding::same(3.0)) - .min_size(egui::vec2(18.0, 18.0)) - ).on_hover_text("Clear search").clicked() { - self.admin_state.search_term.clear(); - self.filter_admin_keys(); - } - } - - // Handle search text changes - if search_response.changed() { - self.filter_admin_keys(); - } - }); - - ui.add_space(5.0); - - // Вторая строка - фильтры - ui.horizontal(|ui| { - ui.label("Filter:"); - let show_deprecated = self.admin_state.show_deprecated_only; - if ui.selectable_label(!show_deprecated, "✅ Active").clicked() { - self.admin_state.show_deprecated_only = false; - self.filter_admin_keys(); - } - if ui.selectable_label(show_deprecated, "❗ Deprecated").clicked() { - self.admin_state.show_deprecated_only = true; - self.filter_admin_keys(); - } - }); - }); - }); - - ui.add_space(10.0); - - // Enhanced bulk actions - лучшие цвета - let selected_count = self.admin_state.selected_servers.values().filter(|&&v| v).count(); - if selected_count > 0 { - ui.group(|ui| { - ui.set_min_width(ui.available_width()); - ui.vertical(|ui| { - ui.horizontal(|ui| { - ui.label(egui::RichText::new("📋").size(14.0)); - ui.label(egui::RichText::new(format!("Selected {} servers", selected_count)) - .size(14.0) - .strong() - .color(egui::Color32::LIGHT_BLUE)); - }); - - ui.add_space(5.0); - - ui.horizontal(|ui| { - if ui.add(egui::Button::new(egui::RichText::new("❗ Deprecate Selected").color(egui::Color32::BLACK)) - .fill(egui::Color32::from_rgb(255, 200, 0)) - .stroke(egui::Stroke::new(1.0, egui::Color32::from_rgb(102, 94, 72))) - .rounding(egui::Rounding::same(6.0)) - .min_size(egui::vec2(130.0, 28.0)) - ).clicked() { - self.deprecate_selected_servers(ctx); - } - - if ui.add(egui::Button::new(egui::RichText::new("✅ Restore Selected").color(egui::Color32::WHITE)) - .fill(egui::Color32::from_rgb(101, 199, 40)) - .stroke(egui::Stroke::new(1.0, egui::Color32::from_rgb(94, 105, 25))) - .rounding(egui::Rounding::same(6.0)) - .min_size(egui::vec2(120.0, 28.0)) - ).clicked() { - self.restore_selected_servers(ctx); - } - - if ui.add(egui::Button::new(egui::RichText::new("X Clear Selection").color(egui::Color32::WHITE)) - .fill(egui::Color32::from_rgb(170, 170, 170)) - .stroke(egui::Stroke::new(1.0, egui::Color32::from_rgb(89, 89, 89))) - .rounding(egui::Rounding::same(6.0)) - .min_size(egui::vec2(110.0, 28.0)) - ).clicked() { - self.admin_state.selected_servers.clear(); - } - }); - }); - }); - ui.add_space(8.0); - } - - // Modern scrollable keys table with better styling - egui::ScrollArea::vertical() - .max_height(450.0) - .auto_shrink([false; 2]) - .show(ui, |ui| { - if self.admin_state.filtered_keys.is_empty() && !self.admin_state.search_term.is_empty() { - ui.vertical_centered(|ui| { - ui.add_space(40.0); - ui.label(egui::RichText::new("🔍").size(48.0).color(egui::Color32::GRAY)); - ui.label(egui::RichText::new("No results found") - .size(18.0) - .color(egui::Color32::GRAY)); - ui.label(egui::RichText::new(format!("Try adjusting your search: '{}'", self.admin_state.search_term)) - .size(14.0) - .color(egui::Color32::DARK_GRAY)); - }); - } else { - self.render_keys_table(ui, ctx); - } - }); - } - - fn load_admin_keys(&mut self, ctx: &egui::Context) { - if self.settings.host.is_empty() || self.settings.flow.is_empty() { - return; - } - - self.admin_state.current_operation = AdminOperation::LoadingKeys; - - let (tx, rx) = mpsc::channel(); - self.admin_receiver = Some(rx); - - let host = self.settings.host.clone(); - let flow = self.settings.flow.clone(); - let basic_auth = self.settings.basic_auth.clone(); - let ctx_clone = ctx.clone(); - - std::thread::spawn(move || { - let rt = tokio::runtime::Runtime::new().unwrap(); - let result = rt.block_on(async { - fetch_admin_keys(host, flow, basic_auth).await - }); - - let _ = tx.send(result); - ctx_clone.request_repaint(); - }); - } - - fn filter_admin_keys(&mut self) { - let mut filtered = self.admin_state.keys.clone(); - - // Apply deprecated filter - if self.admin_state.show_deprecated_only { - filtered.retain(|key| key.deprecated); - } - - // Apply search filter - if !self.admin_state.search_term.is_empty() { - let search_term = self.admin_state.search_term.to_lowercase(); - filtered.retain(|key| { - key.server.to_lowercase().contains(&search_term) || - key.public_key.to_lowercase().contains(&search_term) - }); - } - - self.admin_state.filtered_keys = filtered; - } - - fn render_keys_table(&mut self, ui: &mut egui::Ui, ctx: &egui::Context) { - if self.admin_state.filtered_keys.is_empty() { - ui.vertical_centered(|ui| { - ui.add_space(60.0); - if self.admin_state.keys.is_empty() { - ui.label(egui::RichText::new("🔑").size(48.0).color(egui::Color32::GRAY)); - ui.label(egui::RichText::new("No SSH keys available") - .size(18.0) - .color(egui::Color32::GRAY)); - ui.label(egui::RichText::new("Keys will appear here once loaded from the server") - .size(14.0) - .color(egui::Color32::DARK_GRAY)); - } else { - ui.label(egui::RichText::new("❌").size(48.0).color(egui::Color32::GRAY)); - ui.label(egui::RichText::new("No keys match current filters") - .size(18.0) - .color(egui::Color32::GRAY)); - ui.label(egui::RichText::new("Try adjusting your search or filter settings") - .size(14.0) - .color(egui::Color32::DARK_GRAY)); - } - }); - return; - } - - // Group keys by server - clone to avoid borrowing conflicts - let filtered_keys = self.admin_state.filtered_keys.clone(); - let expanded_servers = self.admin_state.expanded_servers.clone(); - let selected_servers = self.admin_state.selected_servers.clone(); - - let mut servers: std::collections::BTreeMap> = std::collections::BTreeMap::new(); - for key in &filtered_keys { - servers.entry(key.server.clone()).or_insert_with(Vec::new).push(key.clone()); - } - - // Render each server group - for (server_name, server_keys) in servers { - let is_expanded = expanded_servers.get(&server_name).copied().unwrap_or(false); - let active_count = server_keys.iter().filter(|k| !k.deprecated).count(); - let deprecated_count = server_keys.len() - active_count; - - // Modern server header with enhanced styling - ui.group(|ui| { - ui.horizontal(|ui| { - // Stylized checkbox for server selection - let mut selected = selected_servers.get(&server_name).copied().unwrap_or(false); - if ui.add(egui::Checkbox::new(&mut selected, "") - .indeterminate(false) - ).changed() { - self.admin_state.selected_servers.insert(server_name.clone(), selected); - } - - // Modern expand/collapse button - let expand_icon = if is_expanded { "▼" } else { "▶" }; - if ui.add(egui::Button::new(expand_icon) - .fill(egui::Color32::TRANSPARENT) - .stroke(egui::Stroke::NONE) - .min_size(egui::vec2(20.0, 20.0)) - ).clicked() { - self.admin_state.expanded_servers.insert(server_name.clone(), !is_expanded); - } - - // Server icon and name - ui.label(egui::RichText::new("💻").size(16.0)); - ui.label(egui::RichText::new(&server_name) - .size(15.0) - .strong() - .color(egui::Color32::WHITE)); - - // Keys count badge - более компактный - let (rect, _) = ui.allocate_exact_size( - egui::vec2(50.0, 18.0), - egui::Sense::hover() - ); - ui.painter().rect_filled( - rect, - egui::Rounding::same(8.0), - egui::Color32::from_rgb(52, 152, 219) - ); - ui.painter().text( - rect.center(), - egui::Align2::CENTER_CENTER, - &format!("{} keys", server_keys.len()), - egui::FontId::proportional(10.0), - egui::Color32::WHITE, - ); - - ui.add_space(5.0); - - // Status indicators - меньшие - if deprecated_count > 0 { - let (rect, _) = ui.allocate_exact_size( - egui::vec2(65.0, 18.0), - egui::Sense::hover() - ); - ui.painter().rect_filled( - rect, - egui::Rounding::same(8.0), - egui::Color32::from_rgb(231, 76, 60) - ); - ui.painter().text( - rect.center(), - egui::Align2::CENTER_CENTER, - &format!("{} depr", deprecated_count), - egui::FontId::proportional(9.0), - egui::Color32::WHITE, - ); - } - - ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| { - // Stylized action buttons - improved colors - if deprecated_count > 0 { - if ui.add(egui::Button::new(egui::RichText::new("✅ Restore").color(egui::Color32::WHITE)) - .fill(egui::Color32::from_rgb(101, 199, 40)) - .stroke(egui::Stroke::new(1.0, egui::Color32::from_rgb(94, 105, 25))) - .rounding(egui::Rounding::same(4.0)) - .min_size(egui::vec2(70.0, 24.0)) - ).clicked() { - self.restore_server_keys(&server_name, ctx); - } - } - - if active_count > 0 { - if ui.add(egui::Button::new(egui::RichText::new("❗ Deprecate").color(egui::Color32::BLACK)) - .fill(egui::Color32::from_rgb(255, 200, 0)) - .stroke(egui::Stroke::new(1.0, egui::Color32::from_rgb(102, 94, 72))) - .rounding(egui::Rounding::same(4.0)) - .min_size(egui::vec2(85.0, 24.0)) - ).clicked() { - self.deprecate_server_keys(&server_name, ctx); - } - } - }); - }); - }); - - // Expanded key details - if is_expanded { - ui.indent("server_keys", |ui| { - for key in &server_keys { - ui.group(|ui| { - ui.horizontal(|ui| { - // Key type badge with modern styling - let key_type = self.get_key_type(&key.public_key); - let (badge_color, text_color) = match key_type.as_str() { - "RSA" => (egui::Color32::from_rgb(52, 144, 220), egui::Color32::WHITE), - "ED25519" => (egui::Color32::from_rgb(46, 204, 113), egui::Color32::WHITE), - "ECDSA" => (egui::Color32::from_rgb(241, 196, 15), egui::Color32::BLACK), - "DSA" => (egui::Color32::from_rgb(230, 126, 34), egui::Color32::WHITE), - _ => (egui::Color32::GRAY, egui::Color32::WHITE), - }; - - // Custom badge rendering - меньшие - let (rect, _) = ui.allocate_exact_size( - egui::vec2(40.0, 16.0), - egui::Sense::hover() - ); - ui.painter().rect_filled( - rect, - egui::Rounding::same(3.0), - badge_color - ); - ui.painter().text( - rect.center(), - egui::Align2::CENTER_CENTER, - &key_type, - egui::FontId::proportional(9.0), - text_color, - ); - - ui.add_space(5.0); - - // Status badge with icons - меньшие - if key.deprecated { - ui.label(egui::RichText::new("❗ DEPR") - .size(10.0) - .color(egui::Color32::from_rgb(231, 76, 60)) - .strong()); - } else { - ui.label(egui::RichText::new("[OK] ACTIVE") - .size(10.0) - .color(egui::Color32::from_rgb(46, 204, 113)) - .strong()); - } - - ui.add_space(5.0); - - // Key preview with monospace font - короче - ui.label(egui::RichText::new(self.get_key_preview(&key.public_key)) - .font(egui::FontId::monospace(10.0)) - .color(egui::Color32::LIGHT_GRAY)); - - let server_name_for_action = server_name.clone(); - ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| { - // Modern action buttons - improved colors - if key.deprecated { - if ui.add(egui::Button::new(egui::RichText::new("[R]").color(egui::Color32::WHITE)) - .fill(egui::Color32::from_rgb(101, 199, 40)) - .stroke(egui::Stroke::new(1.0, egui::Color32::from_rgb(94, 105, 25))) - .rounding(egui::Rounding::same(3.0)) - .min_size(egui::vec2(22.0, 18.0)) - ).on_hover_text("Restore key").clicked() { - self.restore_key(&server_name_for_action, ctx); - } - if ui.add(egui::Button::new(egui::RichText::new("Del").color(egui::Color32::WHITE)) - .fill(egui::Color32::from_rgb(246, 36, 71)) - .stroke(egui::Stroke::new(1.0, egui::Color32::from_rgb(129, 18, 17))) - .rounding(egui::Rounding::same(3.0)) - .min_size(egui::vec2(26.0, 18.0)) - ).on_hover_text("Delete key").clicked() { - self.delete_key(&server_name_for_action, ctx); - } - } else { - if ui.add(egui::Button::new(egui::RichText::new("❗").color(egui::Color32::BLACK)) - .fill(egui::Color32::from_rgb(255, 200, 0)) - .stroke(egui::Stroke::new(1.0, egui::Color32::from_rgb(102, 94, 72))) - .rounding(egui::Rounding::same(3.0)) - .min_size(egui::vec2(22.0, 18.0)) - ).on_hover_text("Deprecate key").clicked() { - self.deprecate_key(&server_name_for_action, ctx); - } - } - - if ui.add(egui::Button::new(egui::RichText::new("Copy").color(egui::Color32::WHITE)) - .fill(egui::Color32::from_rgb(0, 111, 230)) - .stroke(egui::Stroke::new(1.0, egui::Color32::from_rgb(35, 84, 97))) - .rounding(egui::Rounding::same(3.0)) - .min_size(egui::vec2(30.0, 18.0)) - ).on_hover_text("Copy to clipboard").clicked() { - ui.output_mut(|o| o.copied_text = key.public_key.clone()); - } - }); - }); - }); - } - }); - } - - ui.add_space(5.0); - } - } - - fn deprecate_selected_servers(&mut self, ctx: &egui::Context) { - let selected: Vec = self.admin_state.selected_servers - .iter() - .filter_map(|(server, &selected)| if selected { Some(server.clone()) } else { None }) - .collect(); - - if selected.is_empty() { - return; - } - - self.admin_state.current_operation = AdminOperation::BulkDeprecating; - - let (tx, rx) = mpsc::channel(); - self.operation_receiver = Some(rx); - - let host = self.settings.host.clone(); - let flow = self.settings.flow.clone(); - let basic_auth = self.settings.basic_auth.clone(); - let ctx_clone = ctx.clone(); - - std::thread::spawn(move || { - let rt = tokio::runtime::Runtime::new().unwrap(); - let result = rt.block_on(async { - bulk_deprecate_servers(host, flow, basic_auth, selected).await - }); - - let _ = tx.send(result); - ctx_clone.request_repaint(); - }); - } - - fn restore_selected_servers(&mut self, ctx: &egui::Context) { - let selected: Vec = self.admin_state.selected_servers - .iter() - .filter_map(|(server, &selected)| if selected { Some(server.clone()) } else { None }) - .collect(); - - if selected.is_empty() { - return; - } - - self.admin_state.current_operation = AdminOperation::BulkRestoring; - - let (tx, rx) = mpsc::channel(); - self.operation_receiver = Some(rx); - - let host = self.settings.host.clone(); - let flow = self.settings.flow.clone(); - let basic_auth = self.settings.basic_auth.clone(); - let ctx_clone = ctx.clone(); - - std::thread::spawn(move || { - let rt = tokio::runtime::Runtime::new().unwrap(); - let result = rt.block_on(async { - bulk_restore_servers(host, flow, basic_auth, selected).await - }); - - let _ = tx.send(result); - ctx_clone.request_repaint(); - }); - } - - fn get_key_type(&self, public_key: &str) -> String { - if public_key.starts_with("ssh-rsa") { - "RSA".to_string() - } else if public_key.starts_with("ssh-ed25519") { - "ED25519".to_string() - } else if public_key.starts_with("ecdsa-sha2-nistp") { - "ECDSA".to_string() - } else if public_key.starts_with("ssh-dss") { - "DSA".to_string() - } else { - "Unknown".to_string() - } - } - - fn get_key_preview(&self, public_key: &str) -> String { - let parts: Vec<&str> = public_key.split_whitespace().collect(); - if parts.len() >= 2 { - let key_part = parts[1]; - if key_part.len() > 12 { - format!("{}...", &key_part[..12]) - } else { - key_part.to_string() - } - } else { - format!("{}...", &public_key[..std::cmp::min(12, public_key.len())]) - } - } - - fn deprecate_server_keys(&mut self, server: &str, ctx: &egui::Context) { - self.admin_state.current_operation = AdminOperation::DeprecatingKey; - - let (tx, rx) = mpsc::channel(); - self.operation_receiver = Some(rx); - - let host = self.settings.host.clone(); - let flow = self.settings.flow.clone(); - let basic_auth = self.settings.basic_auth.clone(); - let server_name = server.to_string(); - let ctx_clone = ctx.clone(); - - std::thread::spawn(move || { - let rt = tokio::runtime::Runtime::new().unwrap(); - let result = rt.block_on(async { - deprecate_key_by_server(host, flow, basic_auth, server_name).await - }); - - let _ = tx.send(result); - ctx_clone.request_repaint(); - }); - } - - fn restore_server_keys(&mut self, server: &str, ctx: &egui::Context) { - self.admin_state.current_operation = AdminOperation::RestoringKey; - - let (tx, rx) = mpsc::channel(); - self.operation_receiver = Some(rx); - - let host = self.settings.host.clone(); - let flow = self.settings.flow.clone(); - let basic_auth = self.settings.basic_auth.clone(); - let server_name = server.to_string(); - let ctx_clone = ctx.clone(); - - std::thread::spawn(move || { - let rt = tokio::runtime::Runtime::new().unwrap(); - let result = rt.block_on(async { - restore_key_by_server(host, flow, basic_auth, server_name).await - }); - - let _ = tx.send(result); - ctx_clone.request_repaint(); - }); - } - - fn deprecate_key(&mut self, server: &str, ctx: &egui::Context) { - self.deprecate_server_keys(server, ctx); - } - - fn restore_key(&mut self, server: &str, ctx: &egui::Context) { - self.restore_server_keys(server, ctx); - } - - fn delete_key(&mut self, server: &str, ctx: &egui::Context) { - self.admin_state.current_operation = AdminOperation::DeletingKey; - - let (tx, rx) = mpsc::channel(); - self.operation_receiver = Some(rx); - - let host = self.settings.host.clone(); - let flow = self.settings.flow.clone(); - let basic_auth = self.settings.basic_auth.clone(); - let server_name = server.to_string(); - let ctx_clone = ctx.clone(); - - std::thread::spawn(move || { - let rt = tokio::runtime::Runtime::new().unwrap(); - let result = rt.block_on(async { - permanently_delete_key_by_server(host, flow, basic_auth, server_name).await - }); - - let _ = tx.send(result); - ctx_clone.request_repaint(); - }); - } -} - -pub fn run_settings_window() { - let settings = load_settings(); - let auto_sync_interval_str = settings.auto_sync_interval_minutes.to_string(); - - // Load config file content - let config_content = match fs::read_to_string(get_config_path()) { - Ok(content) => content, - Err(_) => "Configuration file not found or empty".to_string(), - }; - - let options = eframe::NativeOptions { - viewport: egui::ViewportBuilder::default() - .with_title("KHM Settings") - .with_inner_size([600.0, 800.0]) - .with_min_inner_size([500.0, 650.0]) - .with_resizable(true) - .with_icon(create_window_icon()), - ..Default::default() - }; - - let _ = eframe::run_native( - "KHM Settings", - options, - Box::new(|_cc| Ok(Box::new(KhmSettingsWindow { - settings, - auto_sync_interval_str, - config_content, - connection_status: ConnectionStatus::Unknown, - is_testing_connection: false, - test_result_receiver: None, - admin_state: AdminState::default(), - admin_receiver: None, - operation_receiver: None, - current_tab: SettingsTab::Connection, - is_syncing: false, - sync_result_receiver: None, - sync_status: SyncStatus::Unknown, - operation_log: Vec::new(), - }))), - ); -} - -fn create_window_icon() -> egui::IconData { - // Create a simple programmatic icon (blue square with white border) - let icon_size = 32; - let icon_data: Vec = (0..icon_size * icon_size) - .flat_map(|i| { - let y = i / icon_size; - let x = i % icon_size; - if x < 2 || x >= 30 || y < 2 || y >= 30 { - [255, 255, 255, 255] // White border - } else { - [64, 128, 255, 255] // Blue center - } - }) - .collect(); - - egui::IconData { - rgba: icon_data, - width: icon_size as u32, - height: icon_size as u32, - } -} - -// Admin API functions -async fn bulk_deprecate_servers(host: String, flow: String, basic_auth: String, servers: Vec) -> Result { - if host.is_empty() || flow.is_empty() { - return Err("Host and flow must be specified".to_string()); - } - - let url = format!("{}/{}/bulk-deprecate", host.trim_end_matches('/'), flow); - info!("Bulk deprecating {} servers at: {}", servers.len(), url); - - let client_builder = Client::builder() - .timeout(std::time::Duration::from_secs(30)) - .redirect(reqwest::redirect::Policy::none()); - - let client = client_builder.build().map_err(|e| format!("Failed to create HTTP client: {}", e))?; - - let mut request = client.post(&url) - .json(&serde_json::json!({ - "servers": servers - })); - - // Add basic auth if provided - if !basic_auth.is_empty() { - let auth_parts: Vec<&str> = basic_auth.splitn(2, ':').collect(); - if auth_parts.len() == 2 { - request = request.basic_auth(auth_parts[0], Some(auth_parts[1])); - } else { - return Err("Basic auth format should be 'username:password'".to_string()); - } - } - - let response = request.send().await - .map_err(|e| format!("Request failed: {}", e))?; - - // Check for authentication required - if response.status().as_u16() == 401 { - return Err("Authentication required. Please provide valid basic auth credentials.".to_string()); - } - - // Check for redirects (usually to login page) - if response.status().as_u16() >= 300 && response.status().as_u16() < 400 { - return Err("Server redirects to login page. Authentication may be required.".to_string()); - } - - if !response.status().is_success() { - return Err(format!("Server returned error: {} {}", response.status().as_u16(), response.status().canonical_reason().unwrap_or("Unknown"))); - } - - let body = response.text().await - .map_err(|e| format!("Failed to read response: {}", e))?; - - // Parse JSON response to get message - if let Ok(json_response) = serde_json::from_str::(&body) { - if let Some(message) = json_response.get("message").and_then(|v| v.as_str()) { - Ok(message.to_string()) - } else { - Ok("Successfully deprecated servers".to_string()) - } - } else { - Ok("Successfully deprecated servers".to_string()) - } -} - -async fn bulk_restore_servers(host: String, flow: String, basic_auth: String, servers: Vec) -> Result { - if host.is_empty() || flow.is_empty() { - return Err("Host and flow must be specified".to_string()); - } - - let url = format!("{}/{}/bulk-restore", host.trim_end_matches('/'), flow); - info!("Bulk restoring {} servers at: {}", servers.len(), url); - - let client_builder = Client::builder() - .timeout(std::time::Duration::from_secs(30)) - .redirect(reqwest::redirect::Policy::none()); - - let client = client_builder.build().map_err(|e| format!("Failed to create HTTP client: {}", e))?; - - let mut request = client.post(&url) - .json(&serde_json::json!({ - "servers": servers - })); - - // Add basic auth if provided - if !basic_auth.is_empty() { - let auth_parts: Vec<&str> = basic_auth.splitn(2, ':').collect(); - if auth_parts.len() == 2 { - request = request.basic_auth(auth_parts[0], Some(auth_parts[1])); - } else { - return Err("Basic auth format should be 'username:password'".to_string()); - } - } - - let response = request.send().await - .map_err(|e| format!("Request failed: {}", e))?; - - // Check for authentication required - if response.status().as_u16() == 401 { - return Err("Authentication required. Please provide valid basic auth credentials.".to_string()); - } - - // Check for redirects (usually to login page) - if response.status().as_u16() >= 300 && response.status().as_u16() < 400 { - return Err("Server redirects to login page. Authentication may be required.".to_string()); - } - - if !response.status().is_success() { - return Err(format!("Server returned error: {} {}", response.status().as_u16(), response.status().canonical_reason().unwrap_or("Unknown"))); - } - - let body = response.text().await - .map_err(|e| format!("Failed to read response: {}", e))?; - - // Parse JSON response to get message - if let Ok(json_response) = serde_json::from_str::(&body) { - if let Some(message) = json_response.get("message").and_then(|v| v.as_str()) { - Ok(message.to_string()) - } else { - Ok("Successfully restored servers".to_string()) - } - } else { - Ok("Successfully restored servers".to_string()) - } -} - -async fn deprecate_key_by_server(host: String, flow: String, basic_auth: String, server: String) -> Result { - if host.is_empty() || flow.is_empty() { - return Err("Host and flow must be specified".to_string()); - } - - let url = format!("{}/{}/keys/{}", host.trim_end_matches('/'), flow, urlencoding::encode(&server)); - info!("Deprecating key for server '{}' at: {}", server, url); - - let client_builder = Client::builder() - .timeout(std::time::Duration::from_secs(30)) - .redirect(reqwest::redirect::Policy::none()); - - let client = client_builder.build().map_err(|e| format!("Failed to create HTTP client: {}", e))?; - - let mut request = client.delete(&url); - - // Add basic auth if provided - if !basic_auth.is_empty() { - let auth_parts: Vec<&str> = basic_auth.splitn(2, ':').collect(); - if auth_parts.len() == 2 { - request = request.basic_auth(auth_parts[0], Some(auth_parts[1])); - } else { - return Err("Basic auth format should be 'username:password'".to_string()); - } - } - - let response = request.send().await - .map_err(|e| format!("Request failed: {}", e))?; - - // Check for authentication required - if response.status().as_u16() == 401 { - return Err("Authentication required. Please provide valid basic auth credentials.".to_string()); - } - - // Check for redirects (usually to login page) - if response.status().as_u16() >= 300 && response.status().as_u16() < 400 { - return Err("Server redirects to login page. Authentication may be required.".to_string()); - } - - if !response.status().is_success() { - return Err(format!("Server returned error: {} {}", response.status().as_u16(), response.status().canonical_reason().unwrap_or("Unknown"))); - } - - let body = response.text().await - .map_err(|e| format!("Failed to read response: {}", e))?; - - // Parse JSON response to get message - if let Ok(json_response) = serde_json::from_str::(&body) { - if let Some(message) = json_response.get("message").and_then(|v| v.as_str()) { - Ok(message.to_string()) - } else { - Ok(format!("Successfully deprecated key for server '{}'", server)) - } - } else { - Ok(format!("Successfully deprecated key for server '{}'", server)) - } -} - -async fn restore_key_by_server(host: String, flow: String, basic_auth: String, server: String) -> Result { - if host.is_empty() || flow.is_empty() { - return Err("Host and flow must be specified".to_string()); - } - - let url = format!("{}/{}/keys/{}/restore", host.trim_end_matches('/'), flow, urlencoding::encode(&server)); - info!("Restoring key for server '{}' at: {}", server, url); - - let client_builder = Client::builder() - .timeout(std::time::Duration::from_secs(30)) - .redirect(reqwest::redirect::Policy::none()); - - let client = client_builder.build().map_err(|e| format!("Failed to create HTTP client: {}", e))?; - - let mut request = client.post(&url); - - // Add basic auth if provided - if !basic_auth.is_empty() { - let auth_parts: Vec<&str> = basic_auth.splitn(2, ':').collect(); - if auth_parts.len() == 2 { - request = request.basic_auth(auth_parts[0], Some(auth_parts[1])); - } else { - return Err("Basic auth format should be 'username:password'".to_string()); - } - } - - let response = request.send().await - .map_err(|e| format!("Request failed: {}", e))?; - - // Check for authentication required - if response.status().as_u16() == 401 { - return Err("Authentication required. Please provide valid basic auth credentials.".to_string()); - } - - // Check for redirects (usually to login page) - if response.status().as_u16() >= 300 && response.status().as_u16() < 400 { - return Err("Server redirects to login page. Authentication may be required.".to_string()); - } - - if !response.status().is_success() { - return Err(format!("Server returned error: {} {}", response.status().as_u16(), response.status().canonical_reason().unwrap_or("Unknown"))); - } - - let body = response.text().await - .map_err(|e| format!("Failed to read response: {}", e))?; - - // Parse JSON response to get message - if let Ok(json_response) = serde_json::from_str::(&body) { - if let Some(message) = json_response.get("message").and_then(|v| v.as_str()) { - Ok(message.to_string()) - } else { - Ok(format!("Successfully restored key for server '{}'", server)) - } - } else { - Ok(format!("Successfully restored key for server '{}'", server)) - } -} - -async fn permanently_delete_key_by_server(host: String, flow: String, basic_auth: String, server: String) -> Result { - if host.is_empty() || flow.is_empty() { - return Err("Host and flow must be specified".to_string()); - } - - let url = format!("{}/{}/keys/{}/delete", host.trim_end_matches('/'), flow, urlencoding::encode(&server)); - info!("Permanently deleting key for server '{}' at: {}", server, url); - - let client_builder = Client::builder() - .timeout(std::time::Duration::from_secs(30)) - .redirect(reqwest::redirect::Policy::none()); - - let client = client_builder.build().map_err(|e| format!("Failed to create HTTP client: {}", e))?; - - let mut request = client.delete(&url); - - // Add basic auth if provided - if !basic_auth.is_empty() { - let auth_parts: Vec<&str> = basic_auth.splitn(2, ':').collect(); - if auth_parts.len() == 2 { - request = request.basic_auth(auth_parts[0], Some(auth_parts[1])); - } else { - return Err("Basic auth format should be 'username:password'".to_string()); - } - } - - let response = request.send().await - .map_err(|e| format!("Request failed: {}", e))?; - - // Check for authentication required - if response.status().as_u16() == 401 { - return Err("Authentication required. Please provide valid basic auth credentials.".to_string()); - } - - // Check for redirects (usually to login page) - if response.status().as_u16() >= 300 && response.status().as_u16() < 400 { - return Err("Server redirects to login page. Authentication may be required.".to_string()); - } - - if !response.status().is_success() { - return Err(format!("Server returned error: {} {}", response.status().as_u16(), response.status().canonical_reason().unwrap_or("Unknown"))); - } - - let body = response.text().await - .map_err(|e| format!("Failed to read response: {}", e))?; - - // Parse JSON response to get message - if let Ok(json_response) = serde_json::from_str::(&body) { - if let Some(message) = json_response.get("message").and_then(|v| v.as_str()) { - Ok(message.to_string()) - } else { - Ok(format!("Successfully deleted key for server '{}'", server)) - } - } else { - Ok(format!("Successfully deleted key for server '{}'", server)) - } -} - -async fn test_khm_connection(host: String, flow: String, basic_auth: String) -> Result { - if host.is_empty() || flow.is_empty() { - return Err("Host and flow must be specified".to_string()); - } - - let url = format!("{}/{}/keys", host.trim_end_matches('/'), flow); - info!("Testing connection to: {}", url); - - let client_builder = reqwest::Client::builder() - .timeout(std::time::Duration::from_secs(10)) - .redirect(reqwest::redirect::Policy::none()); // Don't follow redirects - - let client = client_builder.build().map_err(|e| format!("Failed to create HTTP client: {}", e))?; - - let mut request = client.get(&url); - - // Add basic auth if provided - if !basic_auth.is_empty() { - let auth_parts: Vec<&str> = basic_auth.splitn(2, ':').collect(); - if auth_parts.len() == 2 { - request = request.basic_auth(auth_parts[0], Some(auth_parts[1])); - } else { - return Err("Basic auth format should be 'username:password'".to_string()); - } - } - - let response = request.send().await - .map_err(|e| format!("Connection failed: {}", e))?; - - // Check for authentication required - if response.status().as_u16() == 401 { - return Err("Authentication required. Please provide valid basic auth credentials.".to_string()); - } - - // Check for redirects (usually to login page) - if response.status().as_u16() >= 300 && response.status().as_u16() < 400 { - return Err("Server redirects to login page. Authentication may be required.".to_string()); - } - - if !response.status().is_success() { - return Err(format!("Server returned error: {} {}", response.status().as_u16(), response.status().canonical_reason().unwrap_or("Unknown"))); - } - - let body = response.text().await - .map_err(|e| format!("Failed to read response: {}", e))?; - - if body.trim().is_empty() { - return Err("Server returned empty response".to_string()); - } - - // Try to parse as JSON array - let keys: Result, _> = serde_json::from_str(&body); - - match keys { - Ok(key_array) => { - let ssh_key_count = key_array.len(); - Ok(format!("Connection successful! Found {} SSH keys in flow '{}'", ssh_key_count, flow)) - } - Err(_) => { - // Check if response looks like HTML (login page) - if body.trim_start().starts_with(" = body.lines().collect(); - let ssh_key_count = lines.iter() - .filter(|line| !line.trim().is_empty() && !line.starts_with('#')) - .count(); - - if ssh_key_count == 0 && !body.trim().is_empty() { - return Err("Invalid response format - not JSON array or SSH keys text".to_string()); - } - - Ok(format!("Connection successful! Found {} SSH keys in flow '{}'", ssh_key_count, flow)) - } - } -} - -async fn perform_manual_sync(settings: KhmSettings) -> Result { - use crate::Args; - - if settings.host.is_empty() || settings.flow.is_empty() { - return Err("Host and flow must be configured".to_string()); - } - - if settings.known_hosts.is_empty() { - return Err("Known hosts file path must be configured".to_string()); - } - - info!("Starting manual sync with host: {}, flow: {}", settings.host, settings.flow); - - // Convert KhmSettings to Args for client module - let args = Args { - server: false, - gui: false, - settings_ui: false, - in_place: settings.in_place, - flows: vec!["default".to_string()], // Not used in client mode - ip: "127.0.0.1".to_string(), // Not used in client mode - port: 8080, // Not used in client mode - db_host: "127.0.0.1".to_string(), // Not used in client mode - db_name: "khm".to_string(), // Not used in client mode - db_user: None, // Not used in client mode - db_password: None, // Not used in client mode - host: Some(settings.host.clone()), - flow: Some(settings.flow.clone()), - known_hosts: expand_path(&settings.known_hosts), - basic_auth: settings.basic_auth.clone(), - }; - - // Get keys count before sync - let keys_before = crate::client::read_known_hosts(&args.known_hosts) - .unwrap_or_else(|_| Vec::new()) - .len(); - - // Perform sync - crate::client::run_client(args.clone()).await - .map_err(|e| format!("Sync failed: {}", e))?; - - // Get keys count after sync - let keys_after = if args.in_place { - crate::client::read_known_hosts(&args.known_hosts) - .unwrap_or_else(|_| Vec::new()) - .len() - } else { - keys_before - }; - - info!("Manual sync completed: {} keys before, {} keys after", keys_before, keys_after); - - let result_message = if args.in_place { - format!("Sync completed successfully! Known hosts file updated with {} keys (was {})", keys_after, keys_before) - } else { - format!("Sync completed successfully! Retrieved {} keys from server", keys_after) - }; - - info!("Returning sync result message: '{}'", result_message); - Ok(result_message) -} +pub use window::*; diff --git a/src/gui/settings/ui.rs b/src/gui/settings/ui.rs new file mode 100644 index 0000000..9b493dc --- /dev/null +++ b/src/gui/settings/ui.rs @@ -0,0 +1,356 @@ +use eframe::egui; +use crate::gui::common::{KhmSettings, get_config_path}; +use super::connection::{ConnectionTab, ConnectionStatus, SyncStatus, save_settings_validated}; + +/// Render connection settings tab +pub fn render_connection_tab( + ui: &mut egui::Ui, + ctx: &egui::Context, + settings: &mut KhmSettings, + auto_sync_interval_str: &mut String, + connection_tab: &mut ConnectionTab, + operation_log: &mut Vec +) { + // Check for connection test and sync results + connection_tab.check_results(ctx, settings); + + let available_height = ui.available_height(); + let button_area_height = 120.0; // Reserve space for buttons and status + let content_height = available_height - button_area_height; + + // Main content area (scrollable) + ui.allocate_ui_with_layout( + [ui.available_width(), content_height].into(), + egui::Layout::top_down(egui::Align::Min), + |ui| { + egui::ScrollArea::vertical() + .auto_shrink([false; 2]) + .show(ui, |ui| { + // Connection section + render_connection_section(ui, settings, connection_tab); + ui.add_space(10.0); + + // Local settings section + render_local_settings_section(ui, settings); + ui.add_space(15.0); + + // Auto-sync section + render_auto_sync_section(ui, settings, auto_sync_interval_str); + ui.add_space(10.0); + + // Configuration file location + render_config_location_section(ui); + }); + }, + ); + + // Bottom area with buttons and log + render_bottom_area(ui, ctx, settings, connection_tab, operation_log); +} + +fn render_connection_section(ui: &mut egui::Ui, settings: &mut KhmSettings, connection_tab: &ConnectionTab) { + ui.group(|ui| { + ui.set_min_width(ui.available_width()); + ui.vertical(|ui| { + ui.horizontal(|ui| { + ui.label(egui::RichText::new("🌐 Connection").size(16.0).strong()); + ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| { + // Display connection status with details + match &connection_tab.connection_status { + ConnectionStatus::Connected { keys_count, flow } => { + let status_text = if flow.is_empty() { + format!("Connected ({} keys)", keys_count) + } else { + format!("Connected to '{}' ({} keys)", flow, keys_count) + }; + ui.add_enabled(false, egui::Checkbox::new(&mut true, status_text)); + } + ConnectionStatus::Error(error_msg) => { + ui.label(egui::RichText::new("❌ Error").color(egui::Color32::RED)) + .on_hover_text(error_msg); + } + ConnectionStatus::Unknown => { + ui.add_enabled(false, egui::Checkbox::new(&mut false, "Not connected")); + } + } + + if connection_tab.is_testing_connection { + ui.spinner(); + ui.label(egui::RichText::new("Testing...").italics()); + } + }); + }); + + // Display sync status if available + match &connection_tab.sync_status { + SyncStatus::Success { keys_count } => { + ui.horizontal(|ui| { + ui.label("🔄 Last sync:"); + ui.label(egui::RichText::new(format!("{} keys synced", keys_count)) + .color(egui::Color32::GREEN)); + }); + } + SyncStatus::Error(error_msg) => { + ui.horizontal(|ui| { + ui.label("🔄 Last sync:"); + ui.label(egui::RichText::new("Failed") + .color(egui::Color32::RED)) + .on_hover_text(error_msg); + }); + } + SyncStatus::Unknown => {} + } + + ui.add_space(5.0); + + egui::Grid::new("connection_grid") + .num_columns(2) + .min_col_width(120.0) + .spacing([10.0, 8.0]) + .show(ui, |ui| { + ui.label("Host URL:"); + ui.add_sized( + [ui.available_width(), 20.0], + egui::TextEdit::singleline(&mut settings.host) + .hint_text("https://your-khm-server.com") + ); + ui.end_row(); + + ui.label("Flow Name:"); + ui.add_sized( + [ui.available_width(), 20.0], + egui::TextEdit::singleline(&mut settings.flow) + .hint_text("production, staging, etc.") + ); + ui.end_row(); + + ui.label("Basic Auth:"); + ui.add_sized( + [ui.available_width(), 20.0], + egui::TextEdit::singleline(&mut settings.basic_auth) + .hint_text("username:password (optional)") + .password(true) + ); + ui.end_row(); + }); + }); + }); +} + +fn render_local_settings_section(ui: &mut egui::Ui, settings: &mut KhmSettings) { + ui.group(|ui| { + ui.set_min_width(ui.available_width()); + ui.vertical(|ui| { + ui.label(egui::RichText::new("📁 Local Settings").size(16.0).strong()); + ui.add_space(8.0); + + egui::Grid::new("local_grid") + .num_columns(2) + .min_col_width(120.0) + .spacing([10.0, 8.0]) + .show(ui, |ui| { + ui.label("Known Hosts File:"); + ui.add_sized( + [ui.available_width(), 20.0], + egui::TextEdit::singleline(&mut settings.known_hosts) + .hint_text("~/.ssh/known_hosts") + ); + ui.end_row(); + }); + + ui.add_space(8.0); + ui.checkbox(&mut settings.in_place, "✏ Update known_hosts file in-place after sync"); + }); + }); +} + +fn render_auto_sync_section(ui: &mut egui::Ui, settings: &mut KhmSettings, auto_sync_interval_str: &mut String) { + ui.group(|ui| { + ui.set_min_width(ui.available_width()); + ui.vertical(|ui| { + ui.label(egui::RichText::new("🔄 Auto Sync").size(16.0).strong()); + ui.add_space(8.0); + + let is_auto_sync_enabled = !settings.host.is_empty() + && !settings.flow.is_empty() + && settings.in_place; + + ui.horizontal(|ui| { + ui.label("Interval (minutes):"); + ui.add_sized( + [80.0, 20.0], + egui::TextEdit::singleline(auto_sync_interval_str) + ); + + if let Ok(value) = auto_sync_interval_str.parse::() { + if value > 0 { + settings.auto_sync_interval_minutes = value; + } + } + + ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| { + if is_auto_sync_enabled { + ui.label(egui::RichText::new("🔄 Enabled").color(egui::Color32::GREEN)); + } else { + ui.label(egui::RichText::new("❌ Disabled").color(egui::Color32::YELLOW)); + ui.label("(Configure host, flow & enable in-place sync)"); + } + }); + }); + }); + }); +} + +fn render_config_location_section(ui: &mut egui::Ui) { + ui.group(|ui| { + ui.set_min_width(ui.available_width()); + ui.horizontal(|ui| { + ui.label("🗁 Config file:"); + let config_path = get_config_path(); + ui.add_sized( + [ui.available_width(), 20.0], + egui::TextEdit::singleline(&mut config_path.display().to_string()) + .interactive(false) + ); + }); + }); +} + +fn render_bottom_area( + ui: &mut egui::Ui, + ctx: &egui::Context, + settings: &KhmSettings, + connection_tab: &mut ConnectionTab, + operation_log: &mut Vec +) { + let button_area_height = 120.0; + + ui.allocate_ui_with_layout( + [ui.available_width(), button_area_height].into(), + egui::Layout::bottom_up(egui::Align::Min), + |ui| { + // Operation log area + ui.group(|ui| { + ui.set_min_width(ui.available_width()); + ui.vertical(|ui| { + ui.label(egui::RichText::new("📄 Operation Log").size(14.0).strong()); + ui.add_space(5.0); + + let log_text = operation_log.join("\n"); + ui.add_sized( + [ui.available_width(), 60.0], + egui::TextEdit::multiline(&mut log_text.clone()) + .font(egui::FontId::monospace(10.0)) + .interactive(false) + ); + }); + }); + + ui.add_space(8.0); + + // Show validation hints + let save_enabled = !settings.host.is_empty() && !settings.flow.is_empty(); + if !save_enabled { + ui.label(egui::RichText::new("❗ Please fill in Host URL and Flow Name to save settings") + .color(egui::Color32::YELLOW) + .italics()); + } + + ui.add_space(5.0); + + // Action buttons + render_action_buttons(ui, ctx, settings, connection_tab, save_enabled, operation_log); + }, + ); +} + +fn render_action_buttons( + ui: &mut egui::Ui, + ctx: &egui::Context, + settings: &KhmSettings, + connection_tab: &mut ConnectionTab, + save_enabled: bool, + operation_log: &mut Vec +) { + ui.horizontal(|ui| { + if ui.add_enabled( + save_enabled, + egui::Button::new("💾 Save Settings") + .min_size(egui::vec2(120.0, 32.0)) + ).clicked() { + match save_settings_validated(settings) { + Ok(()) => { + add_log_entry(operation_log, "✅ Settings saved successfully".to_string()); + ctx.send_viewport_cmd(egui::ViewportCommand::Close); + } + Err(e) => { + add_log_entry(operation_log, format!("❌ Failed to save settings: {}", e)); + } + } + } + + if ui.add( + egui::Button::new("✖ Cancel") + .min_size(egui::vec2(80.0, 32.0)) + ).clicked() { + ctx.send_viewport_cmd(egui::ViewportCommand::Close); + } + + ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| { + let can_test = !settings.host.is_empty() && !settings.flow.is_empty() && !connection_tab.is_testing_connection; + let can_sync = !settings.host.is_empty() && !settings.flow.is_empty() && !connection_tab.is_syncing; + + if ui.add_enabled( + can_test, + egui::Button::new( + if connection_tab.is_testing_connection { + "▶ Testing..." + } else { + "🔍 Test Connection" + } + ).min_size(egui::vec2(120.0, 32.0)) + ).clicked() { + add_log_entry(operation_log, "🔍 Starting connection test...".to_string()); + connection_tab.start_test(settings, ctx); + } + + if ui.add_enabled( + can_sync, + egui::Button::new( + if connection_tab.is_syncing { + "🔄 Syncing..." + } else { + "🔄 Sync Now" + } + ).min_size(egui::vec2(100.0, 32.0)) + ).clicked() { + add_log_entry(operation_log, "🔄 Starting manual sync...".to_string()); + connection_tab.start_sync(settings, ctx); + } + }); + }); +} + +/// Add entry to operation log with timestamp +pub fn add_log_entry(operation_log: &mut Vec, message: String) { + let now = std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .unwrap(); + let secs = now.as_secs(); + let millis = now.subsec_millis(); + + // Format as HH:MM:SS.mmm + let hours = (secs / 3600) % 24; + let minutes = (secs / 60) % 60; + let seconds = secs % 60; + let timestamp = format!("{:02}:{:02}:{:02}.{:03}", hours, minutes, seconds, millis); + + let log_entry = format!("{} {}", timestamp, message); + + operation_log.push(log_entry); + + // Keep only last 20 entries to prevent memory growth + if operation_log.len() > 20 { + operation_log.remove(0); + } +} diff --git a/src/gui/settings/window.rs b/src/gui/settings/window.rs new file mode 100644 index 0000000..4dfba7a --- /dev/null +++ b/src/gui/settings/window.rs @@ -0,0 +1,414 @@ +use eframe::egui; +use log::info; +use std::sync::mpsc; +use crate::gui::common::{load_settings, KhmSettings}; +use crate::gui::admin::{AdminState, AdminOperation, render_statistics, render_search_controls, + render_bulk_actions, render_keys_table, KeyAction, BulkAction}; +use crate::gui::api::{SshKey, bulk_deprecate_servers, bulk_restore_servers, + deprecate_key, restore_key, delete_key}; + +use super::connection::{ConnectionTab, SettingsTab}; +use super::ui::{render_connection_tab, add_log_entry}; + +pub struct SettingsWindow { + settings: KhmSettings, + auto_sync_interval_str: String, + current_tab: SettingsTab, + connection_tab: ConnectionTab, + admin_state: AdminState, + admin_receiver: Option, String>>>, + operation_receiver: Option>>, + operation_log: Vec, +} + +impl SettingsWindow { + pub fn new() -> Self { + let settings = load_settings(); + let auto_sync_interval_str = settings.auto_sync_interval_minutes.to_string(); + + Self { + settings, + auto_sync_interval_str, + current_tab: SettingsTab::Connection, + connection_tab: ConnectionTab::default(), + admin_state: AdminState::default(), + admin_receiver: None, + operation_receiver: None, + operation_log: Vec::new(), + } + } +} + +impl eframe::App for SettingsWindow { + fn update(&mut self, ctx: &egui::Context, _frame: &mut eframe::Frame) { + // Check for admin operation results + self.check_admin_results(ctx); + + // Apply enhanced modern dark theme + apply_modern_theme(ctx); + + egui::CentralPanel::default() + .frame(egui::Frame::none().inner_margin(egui::Margin::same(20.0))) + .show(ctx, |ui| { + // Header with title + ui.horizontal(|ui| { + ui.heading(egui::RichText::new("🔑 KHM Settings").size(24.0)); + }); + + ui.add_space(10.0); + + // Tab selector + ui.horizontal(|ui| { + ui.selectable_value(&mut self.current_tab, SettingsTab::Connection, "📃 Settings"); + ui.selectable_value(&mut self.current_tab, SettingsTab::Admin, "🔧 Admin"); + }); + + ui.separator(); + ui.add_space(15.0); + + match self.current_tab { + SettingsTab::Connection => { + render_connection_tab( + ui, + ctx, + &mut self.settings, + &mut self.auto_sync_interval_str, + &mut self.connection_tab, + &mut self.operation_log + ); + } + SettingsTab::Admin => { + self.render_admin_tab(ui, ctx); + } + } + }); + } +} + +impl SettingsWindow { + fn check_admin_results(&mut self, ctx: &egui::Context) { + // Check for admin keys loading result + if let Some(receiver) = &self.admin_receiver { + if let Ok(result) = receiver.try_recv() { + self.admin_state.handle_keys_loaded(result); + self.admin_receiver = None; + ctx.request_repaint(); + } + } + + // Check for operation results + if let Some(receiver) = &self.operation_receiver { + if let Ok(result) = receiver.try_recv() { + match result { + Ok(message) => { + info!("Operation completed: {}", message); + add_log_entry(&mut self.operation_log, format!("✅ {}", message)); + // Reload keys after operation + self.load_admin_keys(ctx); + } + Err(error) => { + add_log_entry(&mut self.operation_log, format!("❌ Operation failed: {}", error)); + } + } + self.admin_state.current_operation = AdminOperation::None; + self.operation_receiver = None; + ctx.request_repaint(); + } + } + } + + fn render_admin_tab(&mut self, ui: &mut egui::Ui, ctx: &egui::Context) { + // Admin tab header + ui.horizontal(|ui| { + ui.label(egui::RichText::new("🔧 Admin Panel").size(18.0).strong()); + + ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| { + if ui.button("🔁 Refresh").clicked() { + self.load_admin_keys(ctx); + } + + if let Some(last_load) = self.admin_state.last_load_time { + let elapsed = last_load.elapsed().as_secs(); + ui.label(format!("Updated {}s ago", elapsed)); + } + }); + }); + + ui.separator(); + ui.add_space(10.0); + + // Check if connection is configured + if self.settings.host.is_empty() || self.settings.flow.is_empty() { + ui.vertical_centered(|ui| { + ui.label(egui::RichText::new("❗ Please configure connection settings first") + .size(16.0) + .color(egui::Color32::YELLOW)); + ui.add_space(10.0); + if ui.button("Go to Connection Settings").clicked() { + self.current_tab = SettingsTab::Connection; + } + }); + return; + } + + // Load keys automatically on first view + if self.admin_state.keys.is_empty() && !matches!(self.admin_state.current_operation, AdminOperation::LoadingKeys) { + self.load_admin_keys(ctx); + } + + // Show loading state + if matches!(self.admin_state.current_operation, AdminOperation::LoadingKeys) { + ui.vertical_centered(|ui| { + ui.spinner(); + ui.label("Loading keys..."); + }); + return; + } + + // Statistics section + render_statistics(ui, &self.admin_state); + ui.add_space(10.0); + + // Search and filters + render_search_controls(ui, &mut self.admin_state); + ui.add_space(10.0); + + // Bulk actions + let bulk_action = render_bulk_actions(ui, &mut self.admin_state); + self.handle_bulk_action(bulk_action, ctx); + + if self.admin_state.selected_servers.values().any(|&v| v) { + ui.add_space(8.0); + } + + // Keys table + egui::ScrollArea::vertical() + .max_height(450.0) + .auto_shrink([false; 2]) + .show(ui, |ui| { + let key_action = render_keys_table(ui, &mut self.admin_state); + self.handle_key_action(key_action, ctx); + }); + } + + fn load_admin_keys(&mut self, ctx: &egui::Context) { + if let Some(receiver) = self.admin_state.load_keys(&self.settings, ctx) { + self.admin_receiver = Some(receiver); + } + } + + fn handle_bulk_action(&mut self, action: BulkAction, ctx: &egui::Context) { + match action { + BulkAction::DeprecateSelected => { + let selected = self.admin_state.get_selected_servers(); + if !selected.is_empty() { + self.start_bulk_deprecate(selected, ctx); + } + } + BulkAction::RestoreSelected => { + let selected = self.admin_state.get_selected_servers(); + if !selected.is_empty() { + self.start_bulk_restore(selected, ctx); + } + } + BulkAction::ClearSelection => { + // Selection already cleared in UI + } + BulkAction::None => {} + } + } + + fn handle_key_action(&mut self, action: KeyAction, ctx: &egui::Context) { + match action { + KeyAction::DeprecateKey(server) | KeyAction::DeprecateServer(server) => { + self.start_deprecate_key(&server, ctx); + } + KeyAction::RestoreKey(server) | KeyAction::RestoreServer(server) => { + self.start_restore_key(&server, ctx); + } + KeyAction::DeleteKey(server) => { + self.start_delete_key(&server, ctx); + } + KeyAction::None => {} + } + } + + fn start_bulk_deprecate(&mut self, servers: Vec, ctx: &egui::Context) { + self.admin_state.current_operation = AdminOperation::BulkDeprecating; + add_log_entry(&mut self.operation_log, format!("Deprecating {} servers...", servers.len())); + + let (tx, rx) = mpsc::channel(); + self.operation_receiver = Some(rx); + + let host = self.settings.host.clone(); + let flow = self.settings.flow.clone(); + let basic_auth = self.settings.basic_auth.clone(); + let ctx_clone = ctx.clone(); + + std::thread::spawn(move || { + let rt = tokio::runtime::Runtime::new().unwrap(); + let result = rt.block_on(async { + bulk_deprecate_servers(host, flow, basic_auth, servers).await + }); + + let _ = tx.send(result); + ctx_clone.request_repaint(); + }); + } + + fn start_bulk_restore(&mut self, servers: Vec, ctx: &egui::Context) { + self.admin_state.current_operation = AdminOperation::BulkRestoring; + add_log_entry(&mut self.operation_log, format!("Restoring {} servers...", servers.len())); + + let (tx, rx) = mpsc::channel(); + self.operation_receiver = Some(rx); + + let host = self.settings.host.clone(); + let flow = self.settings.flow.clone(); + let basic_auth = self.settings.basic_auth.clone(); + let ctx_clone = ctx.clone(); + + std::thread::spawn(move || { + let rt = tokio::runtime::Runtime::new().unwrap(); + let result = rt.block_on(async { + bulk_restore_servers(host, flow, basic_auth, servers).await + }); + + let _ = tx.send(result); + ctx_clone.request_repaint(); + }); + } + + fn start_deprecate_key(&mut self, server: &str, ctx: &egui::Context) { + self.admin_state.current_operation = AdminOperation::DeprecatingKey; + add_log_entry(&mut self.operation_log, format!("Deprecating key for server: {}", server)); + + let (tx, rx) = mpsc::channel(); + self.operation_receiver = Some(rx); + + let host = self.settings.host.clone(); + let flow = self.settings.flow.clone(); + let basic_auth = self.settings.basic_auth.clone(); + let server_name = server.to_string(); + let ctx_clone = ctx.clone(); + + std::thread::spawn(move || { + let rt = tokio::runtime::Runtime::new().unwrap(); + let result = rt.block_on(async { + deprecate_key(host, flow, basic_auth, server_name).await + }); + + let _ = tx.send(result); + ctx_clone.request_repaint(); + }); + } + + fn start_restore_key(&mut self, server: &str, ctx: &egui::Context) { + self.admin_state.current_operation = AdminOperation::RestoringKey; + add_log_entry(&mut self.operation_log, format!("Restoring key for server: {}", server)); + + let (tx, rx) = mpsc::channel(); + self.operation_receiver = Some(rx); + + let host = self.settings.host.clone(); + let flow = self.settings.flow.clone(); + let basic_auth = self.settings.basic_auth.clone(); + let server_name = server.to_string(); + let ctx_clone = ctx.clone(); + + std::thread::spawn(move || { + let rt = tokio::runtime::Runtime::new().unwrap(); + let result = rt.block_on(async { + restore_key(host, flow, basic_auth, server_name).await + }); + + let _ = tx.send(result); + ctx_clone.request_repaint(); + }); + } + + fn start_delete_key(&mut self, server: &str, ctx: &egui::Context) { + self.admin_state.current_operation = AdminOperation::DeletingKey; + add_log_entry(&mut self.operation_log, format!("Deleting key for server: {}", server)); + + let (tx, rx) = mpsc::channel(); + self.operation_receiver = Some(rx); + + let host = self.settings.host.clone(); + let flow = self.settings.flow.clone(); + let basic_auth = self.settings.basic_auth.clone(); + let server_name = server.to_string(); + let ctx_clone = ctx.clone(); + + std::thread::spawn(move || { + let rt = tokio::runtime::Runtime::new().unwrap(); + let result = rt.block_on(async { + delete_key(host, flow, basic_auth, server_name).await + }); + + let _ = tx.send(result); + ctx_clone.request_repaint(); + }); + } +} + +/// Apply modern dark theme for the settings window +fn apply_modern_theme(ctx: &egui::Context) { + let mut visuals = egui::Visuals::dark(); + visuals.window_fill = egui::Color32::from_gray(25); + visuals.panel_fill = egui::Color32::from_gray(30); + visuals.faint_bg_color = egui::Color32::from_gray(35); + visuals.extreme_bg_color = egui::Color32::from_gray(15); + visuals.button_frame = true; + visuals.collapsing_header_frame = true; + visuals.indent_has_left_vline = true; + visuals.menu_rounding = egui::Rounding::same(8.0); + visuals.window_rounding = egui::Rounding::same(12.0); + visuals.widgets.noninteractive.rounding = egui::Rounding::same(6.0); + visuals.widgets.inactive.rounding = egui::Rounding::same(6.0); + visuals.widgets.hovered.rounding = egui::Rounding::same(6.0); + visuals.widgets.active.rounding = egui::Rounding::same(6.0); + ctx.set_visuals(visuals); +} + +/// Create window icon for settings window +pub fn create_window_icon() -> egui::IconData { + // Create a simple programmatic icon (blue square with white border) + let icon_size = 32; + let icon_data: Vec = (0..icon_size * icon_size) + .flat_map(|i| { + let y = i / icon_size; + let x = i % icon_size; + if x < 2 || x >= 30 || y < 2 || y >= 30 { + [255, 255, 255, 255] // White border + } else { + [64, 128, 255, 255] // Blue center + } + }) + .collect(); + + egui::IconData { + rgba: icon_data, + width: icon_size as u32, + height: icon_size as u32, + } +} + +/// Run the settings window application +pub fn run_settings_window() { + let options = eframe::NativeOptions { + viewport: egui::ViewportBuilder::default() + .with_title("KHM Settings") + .with_inner_size([600.0, 800.0]) + .with_min_inner_size([500.0, 650.0]) + .with_resizable(true) + .with_icon(create_window_icon()), + ..Default::default() + }; + + let _ = eframe::run_native( + "KHM Settings", + options, + Box::new(|_cc| Ok(Box::new(SettingsWindow::new()))), + ); +} diff --git a/src/gui/tray/app.rs b/src/gui/tray/app.rs new file mode 100644 index 0000000..801613d --- /dev/null +++ b/src/gui/tray/app.rs @@ -0,0 +1,269 @@ +use log::{error, info}; +use notify::RecursiveMode; +use notify_debouncer_mini::{new_debouncer, DebounceEventResult}; +use std::sync::{Arc, Mutex}; +use std::time::Duration; +use tray_icon::{ + menu::MenuEvent, + TrayIcon, +}; +use winit::{ + application::ApplicationHandler, + event_loop::{EventLoop, EventLoopProxy}, +}; + +#[cfg(target_os = "macos")] +use winit::platform::macos::EventLoopBuilderExtMacOS; + +use super::{SyncStatus, TrayMenuIds, create_tray_icon, update_tray_menu, + create_tooltip, start_auto_sync_task, update_sync_status}; +use crate::gui::common::{load_settings, get_config_path, perform_sync, KhmSettings}; + +pub struct TrayApplication { + tray_icon: Option, + menu_ids: Option, + settings: Arc>, + sync_status: Arc>, + _debouncer: Option>, + proxy: EventLoopProxy, + auto_sync_handle: Option>, +} + +impl TrayApplication { + pub fn new(proxy: EventLoopProxy) -> Self { + Self { + tray_icon: None, + menu_ids: None, + settings: Arc::new(Mutex::new(load_settings())), + sync_status: Arc::new(Mutex::new(SyncStatus::default())), + _debouncer: None, + proxy, + auto_sync_handle: None, + } + } + + fn setup_file_watcher(&mut self) { + let config_path = get_config_path(); + let (tx, rx) = std::sync::mpsc::channel::(); + let proxy = self.proxy.clone(); + + std::thread::spawn(move || { + while let Ok(result) = rx.recv() { + if let Ok(events) = result { + if events.iter().any(|e| e.path.to_string_lossy().contains("khm_config.json")) { + let _ = proxy.send_event(crate::gui::UserEvent::ConfigFileChanged); + } + } + } + }); + + if let Ok(mut debouncer) = new_debouncer(Duration::from_millis(500), tx) { + if let Some(config_dir) = config_path.parent() { + if debouncer.watcher().watch(config_dir, RecursiveMode::NonRecursive).is_ok() { + info!("File watcher started"); + self._debouncer = Some(debouncer); + } else { + error!("Failed to start file watcher"); + } + } + } + } + + fn handle_config_change(&mut self) { + info!("Config file changed"); + let new_settings = load_settings(); + let old_interval = self.settings.lock().unwrap().auto_sync_interval_minutes; + let new_interval = new_settings.auto_sync_interval_minutes; + + *self.settings.lock().unwrap() = new_settings; + + // Update menu + if let Some(tray_icon) = &self.tray_icon { + let settings = self.settings.lock().unwrap(); + let new_menu_ids = update_tray_menu(tray_icon, &settings); + self.menu_ids = Some(new_menu_ids); + } + + // Update tooltip + self.update_tooltip(); + + // Restart auto sync if interval changed + if old_interval != new_interval { + info!("Auto sync interval changed from {} to {} minutes, restarting auto sync", old_interval, new_interval); + self.start_auto_sync(); + } + } + + fn start_auto_sync(&mut self) { + if let Some(handle) = self.auto_sync_handle.take() { + // Note: In a real implementation, you'd want to properly signal the thread to stop + drop(handle); + } + + self.auto_sync_handle = start_auto_sync_task( + Arc::clone(&self.settings), + Arc::clone(&self.sync_status), + self.proxy.clone() + ); + } + + fn update_tooltip(&self) { + if let Some(tray_icon) = &self.tray_icon { + let settings = self.settings.lock().unwrap(); + let sync_status = self.sync_status.lock().unwrap(); + let tooltip = create_tooltip(&settings, &sync_status); + let _ = tray_icon.set_tooltip(Some(&tooltip)); + } + } + + fn handle_menu_event(&mut self, event: MenuEvent, event_loop: &winit::event_loop::ActiveEventLoop) { + if let Some(menu_ids) = &self.menu_ids { + if event.id == menu_ids.settings_id { + info!("Settings menu clicked"); + self.launch_settings_window(); + } else if event.id == menu_ids.quit_id { + info!("Quitting KHM application"); + event_loop.exit(); + } else if event.id == menu_ids.sync_id { + info!("Starting manual sync operation"); + self.start_manual_sync(); + } + } + } + + fn launch_settings_window(&self) { + if let Ok(exe_path) = std::env::current_exe() { + std::thread::spawn(move || { + if let Err(e) = std::process::Command::new(&exe_path) + .arg("--gui") + .arg("--settings-ui") + .spawn() + { + error!("Failed to launch settings window: {}", e); + } + }); + } + } + + fn start_manual_sync(&self) { + let settings = self.settings.lock().unwrap().clone(); + let sync_status_clone: Arc> = Arc::clone(&self.sync_status); + let proxy_clone = self.proxy.clone(); + + // Check if settings are valid + if settings.host.is_empty() || settings.flow.is_empty() { + error!("Cannot sync: host or flow not configured"); + return; + } + + info!("Syncing with host: {}, flow: {}", settings.host, settings.flow); + + // Run sync in separate thread with its own tokio runtime + std::thread::spawn(move || { + let rt = tokio::runtime::Runtime::new().unwrap(); + rt.block_on(async { + match perform_sync(&settings).await { + Ok(keys_count) => { + info!("Sync completed successfully with {} keys", keys_count); + let mut status = sync_status_clone.lock().unwrap(); + status.last_sync_time = Some(std::time::Instant::now()); + status.last_sync_keys = Some(keys_count); + let _ = proxy_clone.send_event(crate::gui::UserEvent::UpdateMenu); + } + Err(e) => { + error!("Sync failed: {}", e); + } + } + }); + }); + } + + fn handle_update_menu(&mut self) { + let settings = self.settings.lock().unwrap(); + if !settings.host.is_empty() && !settings.flow.is_empty() && settings.in_place { + let mut sync_status = self.sync_status.lock().unwrap(); + update_sync_status(&settings, &mut sync_status); + } + drop(settings); + + self.update_tooltip(); + } +} + +impl ApplicationHandler for TrayApplication { + fn window_event( + &mut self, + _event_loop: &winit::event_loop::ActiveEventLoop, + _window_id: winit::window::WindowId, + _event: winit::event::WindowEvent, + ) {} + + fn resumed(&mut self, _event_loop: &winit::event_loop::ActiveEventLoop) { + if self.tray_icon.is_none() { + info!("Creating tray icon"); + let settings = self.settings.lock().unwrap(); + let sync_status = self.sync_status.lock().unwrap(); + let (tray_icon, menu_ids) = create_tray_icon(&settings, &sync_status); + drop(settings); + drop(sync_status); + + self.tray_icon = Some(tray_icon); + self.menu_ids = Some(menu_ids); + + self.setup_file_watcher(); + self.start_auto_sync(); + info!("KHM tray application ready"); + } + } + + fn user_event(&mut self, event_loop: &winit::event_loop::ActiveEventLoop, event: crate::gui::UserEvent) { + match event { + crate::gui::UserEvent::TrayIconEvent => {} + crate::gui::UserEvent::UpdateMenu => { + self.handle_update_menu(); + } + crate::gui::UserEvent::MenuEvent(event) => { + self.handle_menu_event(event, event_loop); + } + crate::gui::UserEvent::ConfigFileChanged => { + self.handle_config_change(); + } + } + } +} + +/// Run tray application +pub async fn run_tray_app() -> std::io::Result<()> { + #[cfg(target_os = "macos")] + let event_loop = { + use winit::platform::macos::ActivationPolicy; + EventLoop::::with_user_event() + .with_activation_policy(ActivationPolicy::Accessory) + .build() + .map_err(|e| std::io::Error::new(std::io::ErrorKind::Other, format!("Failed to create event loop: {}", e)))? + }; + + #[cfg(not(target_os = "macos"))] + let event_loop = EventLoop::::with_user_event().build() + .map_err(|e| std::io::Error::new(std::io::ErrorKind::Other, format!("Failed to create event loop: {}", e)))?; + + let proxy = event_loop.create_proxy(); + + // Setup event handlers + let proxy_clone = proxy.clone(); + tray_icon::TrayIconEvent::set_event_handler(Some(move |_event| { + let _ = proxy_clone.send_event(crate::gui::UserEvent::TrayIconEvent); + })); + + let proxy_clone = proxy.clone(); + MenuEvent::set_event_handler(Some(move |event: MenuEvent| { + let _ = proxy_clone.send_event(crate::gui::UserEvent::MenuEvent(event)); + })); + + let mut app = TrayApplication::new(proxy); + + event_loop.run_app(&mut app) + .map_err(|e| std::io::Error::new(std::io::ErrorKind::Other, format!("Event loop error: {:?}", e)))?; + + Ok(()) +} diff --git a/src/gui/tray/icon.rs b/src/gui/tray/icon.rs new file mode 100644 index 0000000..3d1c8de --- /dev/null +++ b/src/gui/tray/icon.rs @@ -0,0 +1,281 @@ +use log::{error, info}; +use std::sync::{Arc, Mutex}; +use tray_icon::{ + menu::{Menu, MenuItem, MenuId}, + TrayIcon, TrayIconBuilder, +}; +use crate::gui::common::{KhmSettings, perform_sync}; + +#[derive(Debug, Clone)] +pub struct SyncStatus { + pub last_sync_time: Option, + pub last_sync_keys: Option, + pub next_sync_in_seconds: Option, +} + +impl Default for SyncStatus { + fn default() -> Self { + Self { + last_sync_time: None, + last_sync_keys: None, + next_sync_in_seconds: None, + } + } +} + +pub struct TrayMenuIds { + pub settings_id: MenuId, + pub quit_id: MenuId, + pub sync_id: MenuId, +} + +/// Create tray icon with menu +pub fn create_tray_icon(settings: &KhmSettings, sync_status: &SyncStatus) -> (TrayIcon, TrayMenuIds) { + // Create simple blue icon + let icon_data: Vec = (0..32*32).flat_map(|i| { + let y = i / 32; + let x = i % 32; + if x < 2 || x >= 30 || y < 2 || y >= 30 { + [255, 255, 255, 255] // White border + } else { + [64, 128, 255, 255] // Blue center + } + }).collect(); + + let icon = tray_icon::Icon::from_rgba(icon_data, 32, 32).unwrap(); + let menu = Menu::new(); + + // Show current configuration status (static) + let host_text = if settings.host.is_empty() { + "Host: Not configured" + } else { + &format!("Host: {}", settings.host) + }; + menu.append(&MenuItem::new(host_text, false, None)).unwrap(); + + let flow_text = if settings.flow.is_empty() { + "Flow: Not configured" + } else { + &format!("Flow: {}", settings.flow) + }; + menu.append(&MenuItem::new(flow_text, false, None)).unwrap(); + + let is_auto_sync_enabled = !settings.host.is_empty() && !settings.flow.is_empty() && settings.in_place; + let sync_text = format!("Auto sync: {} ({}min)", + if is_auto_sync_enabled { "On" } else { "Off" }, + settings.auto_sync_interval_minutes); + menu.append(&MenuItem::new(&sync_text, false, None)).unwrap(); + + menu.append(&tray_icon::menu::PredefinedMenuItem::separator()).unwrap(); + + // Sync Now menu item + let sync_item = MenuItem::new("Sync Now", !settings.host.is_empty() && !settings.flow.is_empty(), None); + let sync_id = sync_item.id().clone(); + menu.append(&sync_item).unwrap(); + + menu.append(&tray_icon::menu::PredefinedMenuItem::separator()).unwrap(); + + // Settings menu item + let settings_item = MenuItem::new("Settings", true, None); + let settings_id = settings_item.id().clone(); + menu.append(&settings_item).unwrap(); + + menu.append(&tray_icon::menu::PredefinedMenuItem::separator()).unwrap(); + + // Quit menu item + let quit_item = MenuItem::new("Quit", true, None); + let quit_id = quit_item.id().clone(); + menu.append(&quit_item).unwrap(); + + // Create initial tooltip + let tooltip = create_tooltip(settings, sync_status); + + let tray_icon = TrayIconBuilder::new() + .with_tooltip(&tooltip) + .with_icon(icon) + .with_menu(Box::new(menu)) + .build() + .unwrap(); + + let menu_ids = TrayMenuIds { + settings_id, + quit_id, + sync_id, + }; + + (tray_icon, menu_ids) +} + +/// Update tray menu with new settings +pub fn update_tray_menu(tray_icon: &TrayIcon, settings: &KhmSettings) -> TrayMenuIds { + let menu = Menu::new(); + + // Show current configuration status (static) + let host_text = if settings.host.is_empty() { + "Host: Not configured" + } else { + &format!("Host: {}", settings.host) + }; + menu.append(&MenuItem::new(host_text, false, None)).unwrap(); + + let flow_text = if settings.flow.is_empty() { + "Flow: Not configured" + } else { + &format!("Flow: {}", settings.flow) + }; + menu.append(&MenuItem::new(flow_text, false, None)).unwrap(); + + let is_auto_sync_enabled = !settings.host.is_empty() && !settings.flow.is_empty() && settings.in_place; + let sync_text = format!("Auto sync: {} ({}min)", + if is_auto_sync_enabled { "On" } else { "Off" }, + settings.auto_sync_interval_minutes); + menu.append(&MenuItem::new(&sync_text, false, None)).unwrap(); + + menu.append(&tray_icon::menu::PredefinedMenuItem::separator()).unwrap(); + + // Sync Now menu item + let sync_item = MenuItem::new("Sync Now", !settings.host.is_empty() && !settings.flow.is_empty(), None); + let sync_id = sync_item.id().clone(); + menu.append(&sync_item).unwrap(); + + menu.append(&tray_icon::menu::PredefinedMenuItem::separator()).unwrap(); + + // Settings menu item + let settings_item = MenuItem::new("Settings", true, None); + let settings_id = settings_item.id().clone(); + menu.append(&settings_item).unwrap(); + + menu.append(&tray_icon::menu::PredefinedMenuItem::separator()).unwrap(); + + // Quit menu item + let quit_item = MenuItem::new("Quit", true, None); + let quit_id = quit_item.id().clone(); + menu.append(&quit_item).unwrap(); + + tray_icon.set_menu(Some(Box::new(menu))); + + TrayMenuIds { + settings_id, + quit_id, + sync_id, + } +} + +/// Create tooltip text for tray icon +pub fn create_tooltip(settings: &KhmSettings, sync_status: &SyncStatus) -> String { + let mut tooltip = format!("KHM - SSH Key Manager\nHost: {}\nFlow: {}", settings.host, settings.flow); + + if let Some(keys_count) = sync_status.last_sync_keys { + tooltip.push_str(&format!("\nLast sync: {} keys", keys_count)); + } else { + tooltip.push_str("\nLast sync: Never"); + } + + if let Some(seconds) = sync_status.next_sync_in_seconds { + if seconds > 60 { + tooltip.push_str(&format!("\nNext sync: {}m {}s", seconds / 60, seconds % 60)); + } else { + tooltip.push_str(&format!("\nNext sync: {}s", seconds)); + } + } + + tooltip +} + +/// Start auto sync background task +pub fn start_auto_sync_task( + settings: Arc>, + sync_status: Arc>, + event_sender: winit::event_loop::EventLoopProxy +) -> Option> { + let initial_settings = settings.lock().unwrap().clone(); + + // Only start auto sync if settings are valid and in_place is enabled + if initial_settings.host.is_empty() || initial_settings.flow.is_empty() || !initial_settings.in_place { + info!("Auto sync disabled or settings invalid"); + return None; + } + + info!("Starting auto sync with interval {} minutes", initial_settings.auto_sync_interval_minutes); + + let handle = std::thread::spawn(move || { + // Initial sync on startup + info!("Performing initial sync on startup"); + let current_settings = settings.lock().unwrap().clone(); + if !current_settings.host.is_empty() && !current_settings.flow.is_empty() { + let rt = tokio::runtime::Runtime::new().unwrap(); + rt.block_on(async { + match perform_sync(¤t_settings).await { + Ok(keys_count) => { + info!("Initial sync completed successfully with {} keys", keys_count); + let mut status = sync_status.lock().unwrap(); + status.last_sync_time = Some(std::time::Instant::now()); + status.last_sync_keys = Some(keys_count); + let _ = event_sender.send_event(crate::gui::UserEvent::UpdateMenu); + } + Err(e) => { + error!("Initial sync failed: {}", e); + } + } + }); + } + + // Start menu update timer + let timer_sender = event_sender.clone(); + std::thread::spawn(move || { + loop { + std::thread::sleep(std::time::Duration::from_secs(1)); + let _ = timer_sender.send_event(crate::gui::UserEvent::UpdateMenu); + } + }); + + // Periodic sync + loop { + let interval_minutes = current_settings.auto_sync_interval_minutes; + std::thread::sleep(std::time::Duration::from_secs(interval_minutes as u64 * 60)); + + let current_settings = settings.lock().unwrap().clone(); + if current_settings.host.is_empty() || current_settings.flow.is_empty() || !current_settings.in_place { + info!("Auto sync stopped due to invalid settings or disabled in_place"); + break; + } + + info!("Performing scheduled auto sync"); + let rt = tokio::runtime::Runtime::new().unwrap(); + rt.block_on(async { + match perform_sync(¤t_settings).await { + Ok(keys_count) => { + info!("Auto sync completed successfully with {} keys", keys_count); + let mut status = sync_status.lock().unwrap(); + status.last_sync_time = Some(std::time::Instant::now()); + status.last_sync_keys = Some(keys_count); + let _ = event_sender.send_event(crate::gui::UserEvent::UpdateMenu); + } + Err(e) => { + error!("Auto sync failed: {}", e); + } + } + }); + } + }); + + Some(handle) +} + +/// Update sync status for tooltip +pub fn update_sync_status(settings: &KhmSettings, sync_status: &mut SyncStatus) { + if !settings.host.is_empty() && !settings.flow.is_empty() && settings.in_place { + if let Some(last_sync) = sync_status.last_sync_time { + let elapsed = last_sync.elapsed().as_secs(); + let interval_seconds = settings.auto_sync_interval_minutes as u64 * 60; + + if elapsed < interval_seconds { + sync_status.next_sync_in_seconds = Some(interval_seconds - elapsed); + } else { + sync_status.next_sync_in_seconds = Some(0); + } + } else { + sync_status.next_sync_in_seconds = None; + } + } +} diff --git a/src/gui/tray/mod.rs b/src/gui/tray/mod.rs new file mode 100644 index 0000000..661c6bf --- /dev/null +++ b/src/gui/tray/mod.rs @@ -0,0 +1,6 @@ +mod app; +mod icon; + +pub use app::*; +pub use icon::{SyncStatus, TrayMenuIds, create_tray_icon, update_tray_menu, + create_tooltip, start_auto_sync_task, update_sync_status};