Files
khm/src/gui/settings/mod.rs

1898 lines
82 KiB
Rust
Raw Normal View History

2025-07-22 12:50:01 +03:00
use dirs::home_dir;
2025-07-22 13:44:39 +03:00
use eframe::egui;
2025-07-22 12:50:01 +03:00
use log::{debug, error, info};
use serde::{Deserialize, Serialize};
2025-07-22 14:48:47 +03:00
use std::collections::HashMap;
2025-07-22 12:50:01 +03:00
use std::fs;
use std::path::PathBuf;
2025-07-22 14:12:09 +03:00
use std::sync::mpsc;
2025-07-22 14:48:47 +03:00
use reqwest::Client;
2025-07-22 12:50:01 +03:00
#[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,
2025-07-22 13:02:22 +03:00
pub auto_sync_interval_minutes: u32,
2025-07-22 12:50:01 +03:00
}
2025-07-22 14:48:47 +03:00
#[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,
2025-07-22 16:20:39 +03:00
DeprecatingKey,
RestoringKey,
DeletingKey,
BulkDeprecating,
BulkRestoring,
2025-07-22 14:48:47 +03:00
None,
}
#[derive(Debug, Clone)]
struct AdminState {
keys: Vec<SshKey>,
filtered_keys: Vec<SshKey>,
search_term: String,
show_deprecated_only: bool,
selected_servers: HashMap<String, bool>,
expanded_servers: HashMap<String, bool>,
current_operation: AdminOperation,
last_load_time: Option<std::time::Instant>,
}
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<Vec<SshKey>, 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()
2025-07-22 17:27:19 +03:00
.timeout(std::time::Duration::from_secs(30))
.redirect(reqwest::redirect::Policy::none()); // Don't follow redirects
2025-07-22 14:48:47 +03:00
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))?;
2025-07-22 17:27:19 +03:00
// 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());
}
2025-07-22 14:48:47 +03:00
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))?;
2025-07-22 17:27:19 +03:00
// Check if response looks like HTML (login page)
if body.trim_start().starts_with("<!DOCTYPE") || body.trim_start().starts_with("<html") {
return Err("Server returned HTML page instead of JSON. This usually means authentication is required or the endpoint is incorrect.".to_string());
}
2025-07-22 14:48:47 +03:00
let keys: Vec<SshKey> = serde_json::from_str(&body)
.map_err(|e| format!("Failed to parse response: {}", e))?;
info!("Fetched {} SSH keys", keys.len());
Ok(keys)
}
2025-07-22 17:27:19 +03:00
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()
}
}
2025-07-22 12:50:01 +03:00
impl Default for KhmSettings {
fn default() -> Self {
Self {
host: String::new(),
flow: String::new(),
2025-07-22 17:27:19 +03:00
known_hosts: get_default_known_hosts_path(),
2025-07-22 12:50:01 +03:00
basic_auth: String::new(),
2025-07-22 13:44:39 +03:00
in_place: true,
auto_sync_interval_minutes: 60,
2025-07-22 12:50:01 +03:00
}
}
}
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) {
2025-07-22 17:27:19 +03:00
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
}
2025-07-22 12:50:01 +03:00
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()
}
2025-07-22 13:44:39 +03:00
struct KhmSettingsWindow {
settings: KhmSettings,
auto_sync_interval_str: String,
2025-07-22 14:12:09 +03:00
config_content: String,
connection_status: ConnectionStatus,
is_testing_connection: bool,
test_result_receiver: Option<mpsc::Receiver<Result<String, String>>>,
2025-07-22 14:48:47 +03:00
admin_state: AdminState,
admin_receiver: Option<mpsc::Receiver<Result<Vec<SshKey>, String>>>,
operation_receiver: Option<mpsc::Receiver<Result<String, String>>>,
current_tab: SettingsTab,
2025-07-22 17:27:19 +03:00
is_syncing: bool,
sync_result_receiver: Option<mpsc::Receiver<Result<String, String>>>,
sync_status: SyncStatus,
2025-07-22 14:48:47 +03:00
}
#[derive(Debug, Clone, PartialEq)]
enum SettingsTab {
Connection,
Admin,
2025-07-22 14:12:09 +03:00
}
#[derive(Debug, Clone)]
enum ConnectionStatus {
Unknown,
Connected { keys_count: usize, flow: String },
Error(String),
2025-07-22 13:44:39 +03:00
}
2025-07-22 17:27:19 +03:00
#[derive(Debug, Clone)]
enum SyncStatus {
Unknown,
Success { keys_count: usize },
Error(String),
}
2025-07-22 13:44:39 +03:00
impl eframe::App for KhmSettingsWindow {
fn update(&mut self, ctx: &egui::Context, _frame: &mut eframe::Frame) {
2025-07-22 14:12:09 +03:00
// 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::<usize>().unwrap_or(0)
} else { 0 }
} else { 0 };
let flow = self.settings.flow.clone();
self.connection_status = ConnectionStatus::Connected { keys_count, flow };
info!("Connection test successful: {}", message);
}
Err(error) => {
self.connection_status = ConnectionStatus::Error(error.clone());
error!("Connection test failed: {}", error);
2025-07-22 13:44:39 +03:00
}
}
2025-07-22 14:12:09 +03:00
self.test_result_receiver = None;
ctx.request_repaint();
}
}
2025-07-22 14:48:47 +03:00
// 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();
}
}
2025-07-22 17:27:19 +03:00
// 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::<usize>().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::<usize>().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::<usize>().unwrap_or(0)
} else {
0
}
} else {
0
}
};
info!("Parsed keys count: {}", keys_count);
self.sync_status = SyncStatus::Success { keys_count };
info!("Sync successful: {}", message);
}
Err(error) => {
self.sync_status = SyncStatus::Error(error.clone());
error!("Sync failed: {}", error);
}
}
self.sync_result_receiver = None;
ctx.request_repaint();
}
}
2025-07-22 14:48:47 +03:00
// 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);
2025-07-22 14:12:09 +03:00
egui::CentralPanel::default()
.frame(egui::Frame::none().inner_margin(egui::Margin::same(20.0)))
.show(ctx, |ui| {
// Header with title
ui.horizontal(|ui| {
2025-07-22 16:20:39 +03:00
ui.heading(egui::RichText::new("🔑 KHM Settings").size(24.0));
2025-07-22 14:12:09 +03:00
});
ui.add_space(10.0);
2025-07-22 14:48:47 +03:00
// Tab selector
ui.horizontal(|ui| {
2025-07-22 16:20:39 +03:00
ui.selectable_value(&mut self.current_tab, SettingsTab::Connection, "📃 Settings");
2025-07-22 15:21:30 +03:00
ui.selectable_value(&mut self.current_tab, SettingsTab::Admin, "🔧 Admin");
2025-07-22 14:12:09 +03:00
});
2025-07-22 14:48:47 +03:00
ui.separator();
2025-07-22 14:12:09 +03:00
ui.add_space(15.0);
2025-07-22 14:48:47 +03:00
match self.current_tab {
SettingsTab::Connection => self.render_connection_tab(ui, ctx),
SettingsTab::Admin => self.render_admin_tab(ui, ctx),
}
});
}
}
impl KhmSettingsWindow {
fn render_connection_tab(&mut self, ui: &mut egui::Ui, ctx: &egui::Context) {
2025-07-22 17:27:19 +03:00
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])
2025-07-22 14:48:47 +03:00
.show(ui, |ui| {
2025-07-22 17:27:19 +03:00
// 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());
} else {
match &self.connection_status {
ConnectionStatus::Unknown => {
ui.label(egui::RichText::new("Not tested").color(egui::Color32::GRAY));
}
ConnectionStatus::Connected { keys_count, flow } => {
ui.label(egui::RichText::new("").color(egui::Color32::GREEN));
ui.label(egui::RichText::new(format!("{} keys in '{}'", keys_count, flow))
.color(egui::Color32::LIGHT_GREEN));
}
ConnectionStatus::Error(err) => {
ui.label(egui::RichText::new("").color(egui::Color32::RED))
.on_hover_text(format!("Error: {}", err));
ui.label(egui::RichText::new("Failed").color(egui::Color32::RED));
}
}
}
});
});
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();
});
});
});
2025-07-22 14:12:09 +03:00
2025-07-22 17:27:19 +03:00
ui.add_space(10.0);
2025-07-22 14:12:09 +03:00
2025-07-22 17:27:19 +03:00
// 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::<u32>() {
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);
// Advanced settings (collapsible)
ui.collapsing("🔧 Advanced Settings", |ui| {
ui.indent("advanced", |ui| {
ui.label("Configuration details:");
ui.add_space(5.0);
ui.horizontal(|ui| {
ui.label("Config file:");
let config_path = get_config_path();
ui.label(egui::RichText::new(config_path.display().to_string())
.font(egui::FontId::monospace(12.0))
.color(egui::Color32::LIGHT_GRAY));
});
ui.add_space(8.0);
ui.label("Current configuration:");
ui.add_sized(
[ui.available_width(), 120.0],
egui::TextEdit::multiline(&mut self.config_content.clone())
.font(egui::FontId::monospace(11.0))
.interactive(false)
);
});
});
2025-07-22 14:48:47 +03:00
});
2025-07-22 17:27:19 +03:00
},
);
2025-07-22 14:48:47 +03:00
2025-07-22 17:27:19 +03:00
// 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| {
// Show sync status
match &self.sync_status {
SyncStatus::Success { keys_count } => {
ui.label(egui::RichText::new(format!("✅ Last sync successful: {} keys", keys_count))
.color(egui::Color32::GREEN));
}
SyncStatus::Error(err) => {
ui.label(egui::RichText::new(format!("❌ Sync failed: {}", err))
.color(egui::Color32::RED));
}
SyncStatus::Unknown => {}
}
2025-07-22 14:48:47 +03:00
2025-07-22 17:27:19 +03:00
// 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());
}
2025-07-22 14:48:47 +03:00
2025-07-22 17:27:19 +03:00
ui.add_space(5.0);
2025-07-22 14:48:47 +03:00
2025-07-22 17:27:19 +03:00
// Action buttons
2025-07-22 14:48:47 +03:00
ui.horizontal(|ui| {
2025-07-22 17:27:19 +03:00
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");
2025-07-22 14:48:47 +03:00
}
2025-07-22 17:27:19 +03:00
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);
2025-07-22 14:48:47 +03:00
}
ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| {
2025-07-22 17:27:19 +03:00
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);
2025-07-22 14:48:47 +03:00
}
});
});
2025-07-22 17:27:19 +03:00
},
);
2025-07-22 14:48:47 +03:00
}
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;
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();
});
}
2025-07-22 17:27:19 +03:00
fn start_manual_sync(&mut self, 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 = 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();
});
}
2025-07-22 14:48:47 +03:00
fn render_admin_tab(&mut self, ui: &mut egui::Ui, ctx: &egui::Context) {
// Admin tab header
ui.horizontal(|ui| {
2025-07-22 15:21:30 +03:00
ui.label(egui::RichText::new("🔧 Admin Panel").size(18.0).strong());
2025-07-22 14:48:47 +03:00
ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| {
2025-07-22 16:20:39 +03:00
if ui.button("🔁 Refresh").clicked() {
2025-07-22 14:48:47 +03:00
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| {
2025-07-22 16:20:39 +03:00
ui.label(egui::RichText::new("❗ Please configure connection settings first")
2025-07-22 14:48:47 +03:00
.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;
}
2025-07-22 15:21:30 +03:00
// 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::<std::collections::HashSet<_>>().len();
2025-07-22 16:20:39 +03:00
ui.horizontal(|ui| {
ui.columns(4, |cols| {
2025-07-22 15:21:30 +03:00
// Total keys
2025-07-22 16:20:39 +03:00
cols[0].vertical_centered_justified(|ui| {
2025-07-22 15:21:30 +03:00
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
2025-07-22 16:20:39 +03:00
cols[1].vertical_centered_justified(|ui| {
ui.label(egui::RichText::new("").size(20.0));
2025-07-22 15:21:30 +03:00
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
2025-07-22 16:20:39 +03:00
cols[2].vertical_centered_justified(|ui| {
ui.label(egui::RichText::new("").size(20.0));
2025-07-22 15:21:30 +03:00
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
2025-07-22 16:20:39 +03:00
cols[3].vertical_centered_justified(|ui| {
ui.label(egui::RichText::new("💻").size(20.0));
2025-07-22 15:21:30 +03:00
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));
});
});
2025-07-22 16:20:39 +03:00
});
2025-07-22 14:48:47 +03:00
});
});
ui.add_space(10.0);
2025-07-22 16:20:39 +03:00
// Enhanced search and filters - адаптивный подход как в блоках статистики
2025-07-22 14:48:47 +03:00
ui.group(|ui| {
2025-07-22 16:20:39 +03:00
ui.set_min_width(ui.available_width());
2025-07-22 15:21:30 +03:00
ui.vertical(|ui| {
2025-07-22 16:20:39 +03:00
ui.label(egui::RichText::new("🔍 Search").size(16.0).strong());
ui.add_space(8.0);
// Search field with full width like statistics blocks
2025-07-22 15:21:30 +03:00
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));
2025-07-22 16:20:39 +03:00
if ui.add(egui::Button::new(egui::RichText::new("").color(egui::Color32::WHITE))
2025-07-22 15:21:30 +03:00
.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() {
2025-07-22 14:48:47 +03:00
self.filter_admin_keys();
}
2025-07-22 15:21:30 +03:00
});
2025-07-22 14:48:47 +03:00
2025-07-22 15:21:30 +03:00
ui.add_space(5.0);
2025-07-22 14:12:09 +03:00
2025-07-22 15:21:30 +03:00
// Вторая строка - фильтры
2025-07-22 14:48:47 +03:00
ui.horizontal(|ui| {
ui.label("Filter:");
2025-07-22 15:21:30 +03:00
let show_deprecated = self.admin_state.show_deprecated_only;
2025-07-22 16:20:39 +03:00
if ui.selectable_label(!show_deprecated, "✅ Active").clicked() {
2025-07-22 14:48:47 +03:00
self.admin_state.show_deprecated_only = false;
self.filter_admin_keys();
}
2025-07-22 16:20:39 +03:00
if ui.selectable_label(show_deprecated, "❗ Deprecated").clicked() {
2025-07-22 14:48:47 +03:00
self.admin_state.show_deprecated_only = true;
self.filter_admin_keys();
}
});
});
});
ui.add_space(10.0);
2025-07-22 15:21:30 +03:00
// Enhanced bulk actions - лучшие цвета
2025-07-22 14:48:47 +03:00
let selected_count = self.admin_state.selected_servers.values().filter(|&&v| v).count();
if selected_count > 0 {
ui.group(|ui| {
2025-07-22 15:21:30 +03:00
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));
});
2025-07-22 14:12:09 +03:00
2025-07-22 15:21:30 +03:00
ui.add_space(5.0);
2025-07-22 14:48:47 +03:00
2025-07-22 15:21:30 +03:00
ui.horizontal(|ui| {
2025-07-22 16:20:39 +03:00
if ui.add(egui::Button::new(egui::RichText::new("❗ Deprecate Selected").color(egui::Color32::BLACK))
2025-07-22 15:21:30 +03:00
.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);
}
2025-07-22 16:20:39 +03:00
if ui.add(egui::Button::new(egui::RichText::new("✅ Restore Selected").color(egui::Color32::WHITE))
2025-07-22 15:21:30 +03:00
.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();
}
});
2025-07-22 14:48:47 +03:00
});
});
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 {
2025-07-22 16:20:39 +03:00
ui.label(egui::RichText::new("").size(48.0).color(egui::Color32::GRAY));
2025-07-22 14:48:47 +03:00
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<String, Vec<SshKey>> = 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
2025-07-22 17:27:19 +03:00
let expand_icon = if is_expanded { "" } else { "" };
2025-07-22 14:48:47 +03:00
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
2025-07-22 16:20:39 +03:00
ui.label(egui::RichText::new("💻").size(16.0));
2025-07-22 14:48:47 +03:00
ui.label(egui::RichText::new(&server_name)
.size(15.0)
.strong()
.color(egui::Color32::WHITE));
2025-07-22 15:21:30 +03:00
// Keys count badge - более компактный
2025-07-22 14:48:47 +03:00
let (rect, _) = ui.allocate_exact_size(
2025-07-22 15:21:30 +03:00
egui::vec2(50.0, 18.0),
2025-07-22 14:48:47 +03:00
egui::Sense::hover()
);
ui.painter().rect_filled(
rect,
2025-07-22 15:21:30 +03:00
egui::Rounding::same(8.0),
2025-07-22 14:48:47 +03:00
egui::Color32::from_rgb(52, 152, 219)
);
ui.painter().text(
rect.center(),
egui::Align2::CENTER_CENTER,
&format!("{} keys", server_keys.len()),
2025-07-22 15:21:30 +03:00
egui::FontId::proportional(10.0),
2025-07-22 14:48:47 +03:00
egui::Color32::WHITE,
);
2025-07-22 15:21:30 +03:00
ui.add_space(5.0);
2025-07-22 14:48:47 +03:00
2025-07-22 15:21:30 +03:00
// Status indicators - меньшие
2025-07-22 14:48:47 +03:00
if deprecated_count > 0 {
let (rect, _) = ui.allocate_exact_size(
2025-07-22 15:21:30 +03:00
egui::vec2(65.0, 18.0),
2025-07-22 14:48:47 +03:00
egui::Sense::hover()
);
ui.painter().rect_filled(
rect,
2025-07-22 15:21:30 +03:00
egui::Rounding::same(8.0),
2025-07-22 14:48:47 +03:00
egui::Color32::from_rgb(231, 76, 60)
);
ui.painter().text(
rect.center(),
egui::Align2::CENTER_CENTER,
2025-07-22 15:21:30 +03:00
&format!("{} depr", deprecated_count),
egui::FontId::proportional(9.0),
2025-07-22 14:48:47 +03:00
egui::Color32::WHITE,
);
2025-07-22 14:12:09 +03:00
}
ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| {
2025-07-22 15:21:30 +03:00
// Stylized action buttons - improved colors
2025-07-22 14:48:47 +03:00
if deprecated_count > 0 {
2025-07-22 16:20:39 +03:00
if ui.add(egui::Button::new(egui::RichText::new("✅ Restore").color(egui::Color32::WHITE))
2025-07-22 15:21:30 +03:00
.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))
2025-07-22 14:48:47 +03:00
).clicked() {
self.restore_server_keys(&server_name, ctx);
}
}
2025-07-22 14:12:09 +03:00
2025-07-22 14:48:47 +03:00
if active_count > 0 {
2025-07-22 16:20:39 +03:00
if ui.add(egui::Button::new(egui::RichText::new("❗ Deprecate").color(egui::Color32::BLACK))
2025-07-22 15:21:30 +03:00
.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))
2025-07-22 14:48:47 +03:00
).clicked() {
self.deprecate_server_keys(&server_name, ctx);
}
2025-07-22 14:12:09 +03:00
}
});
});
2025-07-22 13:44:39 +03:00
});
2025-07-22 14:48:47 +03:00
// 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),
};
2025-07-22 15:21:30 +03:00
// Custom badge rendering - меньшие
2025-07-22 14:48:47 +03:00
let (rect, _) = ui.allocate_exact_size(
2025-07-22 15:21:30 +03:00
egui::vec2(40.0, 16.0),
2025-07-22 14:48:47 +03:00
egui::Sense::hover()
);
ui.painter().rect_filled(
rect,
2025-07-22 15:21:30 +03:00
egui::Rounding::same(3.0),
2025-07-22 14:48:47 +03:00
badge_color
);
ui.painter().text(
rect.center(),
egui::Align2::CENTER_CENTER,
&key_type,
2025-07-22 15:21:30 +03:00
egui::FontId::proportional(9.0),
2025-07-22 14:48:47 +03:00
text_color,
);
2025-07-22 15:21:30 +03:00
ui.add_space(5.0);
2025-07-22 14:48:47 +03:00
2025-07-22 15:21:30 +03:00
// Status badge with icons - меньшие
2025-07-22 14:48:47 +03:00
if key.deprecated {
2025-07-22 16:20:39 +03:00
ui.label(egui::RichText::new("❗ DEPR")
2025-07-22 15:21:30 +03:00
.size(10.0)
2025-07-22 14:48:47 +03:00
.color(egui::Color32::from_rgb(231, 76, 60))
.strong());
} else {
2025-07-22 16:20:39 +03:00
ui.label(egui::RichText::new("[OK] ACTIVE")
2025-07-22 15:21:30 +03:00
.size(10.0)
2025-07-22 14:48:47 +03:00
.color(egui::Color32::from_rgb(46, 204, 113))
.strong());
}
2025-07-22 15:21:30 +03:00
ui.add_space(5.0);
2025-07-22 14:48:47 +03:00
2025-07-22 15:21:30 +03:00
// Key preview with monospace font - короче
2025-07-22 14:48:47 +03:00
ui.label(egui::RichText::new(self.get_key_preview(&key.public_key))
2025-07-22 15:21:30 +03:00
.font(egui::FontId::monospace(10.0))
2025-07-22 14:48:47 +03:00
.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| {
2025-07-22 15:21:30 +03:00
// Modern action buttons - improved colors
2025-07-22 14:48:47 +03:00
if key.deprecated {
2025-07-22 16:20:39 +03:00
if ui.add(egui::Button::new(egui::RichText::new("[R]").color(egui::Color32::WHITE))
2025-07-22 15:21:30 +03:00
.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() {
2025-07-22 14:48:47 +03:00
self.restore_key(&server_name_for_action, ctx);
}
2025-07-22 15:21:30 +03:00
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() {
2025-07-22 14:48:47 +03:00
self.delete_key(&server_name_for_action, ctx);
}
} else {
2025-07-22 16:20:39 +03:00
if ui.add(egui::Button::new(egui::RichText::new("").color(egui::Color32::BLACK))
2025-07-22 15:21:30 +03:00
.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() {
2025-07-22 14:48:47 +03:00
self.deprecate_key(&server_name_for_action, ctx);
}
}
2025-07-22 15:21:30 +03:00
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() {
2025-07-22 14:48:47 +03:00
ui.output_mut(|o| o.copied_text = key.public_key.clone());
}
});
});
});
}
});
}
ui.add_space(5.0);
}
}
2025-07-22 16:20:39 +03:00
fn deprecate_selected_servers(&mut self, ctx: &egui::Context) {
let selected: Vec<String> = 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();
});
2025-07-22 14:48:47 +03:00
}
2025-07-22 16:20:39 +03:00
fn restore_selected_servers(&mut self, ctx: &egui::Context) {
let selected: Vec<String> = 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();
});
2025-07-22 14:48:47 +03:00
}
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];
2025-07-22 15:21:30 +03:00
if key_part.len() > 12 {
format!("{}...", &key_part[..12])
2025-07-22 14:48:47 +03:00
} else {
key_part.to_string()
}
} else {
2025-07-22 15:21:30 +03:00
format!("{}...", &public_key[..std::cmp::min(12, public_key.len())])
2025-07-22 14:48:47 +03:00
}
}
2025-07-22 16:20:39 +03:00
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();
});
2025-07-22 14:48:47 +03:00
}
2025-07-22 16:20:39 +03:00
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();
});
2025-07-22 14:48:47 +03:00
}
2025-07-22 16:20:39 +03:00
fn deprecate_key(&mut self, server: &str, ctx: &egui::Context) {
self.deprecate_server_keys(server, ctx);
2025-07-22 14:48:47 +03:00
}
2025-07-22 16:20:39 +03:00
fn restore_key(&mut self, server: &str, ctx: &egui::Context) {
self.restore_server_keys(server, ctx);
2025-07-22 14:48:47 +03:00
}
2025-07-22 16:20:39 +03:00
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();
});
2025-07-22 13:44:39 +03:00
}
}
pub fn run_settings_window() {
let settings = load_settings();
let auto_sync_interval_str = settings.auto_sync_interval_minutes.to_string();
2025-07-22 14:12:09 +03:00
// 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(),
};
2025-07-22 13:44:39 +03:00
let options = eframe::NativeOptions {
viewport: egui::ViewportBuilder::default()
.with_title("KHM Settings")
2025-07-22 15:21:30 +03:00
.with_inner_size([600.0, 800.0])
.with_min_inner_size([500.0, 650.0])
2025-07-22 14:12:09 +03:00
.with_resizable(true)
.with_icon(create_window_icon()),
2025-07-22 13:44:39 +03:00
..Default::default()
};
let _ = eframe::run_native(
"KHM Settings",
options,
Box::new(|_cc| Ok(Box::new(KhmSettingsWindow {
settings,
auto_sync_interval_str,
2025-07-22 14:12:09 +03:00
config_content,
connection_status: ConnectionStatus::Unknown,
is_testing_connection: false,
test_result_receiver: None,
2025-07-22 14:48:47 +03:00
admin_state: AdminState::default(),
admin_receiver: None,
operation_receiver: None,
current_tab: SettingsTab::Connection,
2025-07-22 17:27:19 +03:00
is_syncing: false,
sync_result_receiver: None,
sync_status: SyncStatus::Unknown,
2025-07-22 13:44:39 +03:00
}))),
);
}
2025-07-22 14:12:09 +03:00
fn create_window_icon() -> egui::IconData {
// Create a simple programmatic icon (blue square with white border)
let icon_size = 32;
let icon_data: Vec<u8> = (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,
}
}
2025-07-22 16:20:39 +03:00
// Admin API functions
async fn bulk_deprecate_servers(host: String, flow: String, basic_auth: String, servers: Vec<String>) -> Result<String, String> {
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()
2025-07-22 17:27:19 +03:00
.timeout(std::time::Duration::from_secs(30))
.redirect(reqwest::redirect::Policy::none());
2025-07-22 16:20:39 +03:00
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))?;
2025-07-22 17:27:19 +03:00
// 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());
}
2025-07-22 16:20:39 +03:00
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::<serde_json::Value>(&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<String>) -> Result<String, String> {
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()
2025-07-22 17:27:19 +03:00
.timeout(std::time::Duration::from_secs(30))
.redirect(reqwest::redirect::Policy::none());
2025-07-22 16:20:39 +03:00
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))?;
2025-07-22 17:27:19 +03:00
// 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());
}
2025-07-22 16:20:39 +03:00
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::<serde_json::Value>(&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<String, String> {
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()
2025-07-22 17:27:19 +03:00
.timeout(std::time::Duration::from_secs(30))
.redirect(reqwest::redirect::Policy::none());
2025-07-22 16:20:39 +03:00
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))?;
2025-07-22 17:27:19 +03:00
// 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());
}
2025-07-22 16:20:39 +03:00
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::<serde_json::Value>(&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<String, String> {
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()
2025-07-22 17:27:19 +03:00
.timeout(std::time::Duration::from_secs(30))
.redirect(reqwest::redirect::Policy::none());
2025-07-22 16:20:39 +03:00
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))?;
2025-07-22 17:27:19 +03:00
// 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());
}
2025-07-22 16:20:39 +03:00
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::<serde_json::Value>(&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<String, String> {
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()
2025-07-22 17:27:19 +03:00
.timeout(std::time::Duration::from_secs(30))
.redirect(reqwest::redirect::Policy::none());
2025-07-22 16:20:39 +03:00
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))?;
2025-07-22 17:27:19 +03:00
// 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());
}
2025-07-22 16:20:39 +03:00
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::<serde_json::Value>(&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))
}
}
2025-07-22 14:12:09 +03:00
async fn test_khm_connection(host: String, flow: String, basic_auth: String) -> Result<String, String> {
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()
2025-07-22 17:27:19 +03:00
.timeout(std::time::Duration::from_secs(10))
.redirect(reqwest::redirect::Policy::none()); // Don't follow redirects
2025-07-22 14:12:09 +03:00
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))?;
2025-07-22 17:27:19 +03:00
// 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());
}
2025-07-22 14:12:09 +03:00
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<Vec<serde_json::Value>, _> = 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(_) => {
2025-07-22 17:27:19 +03:00
// Check if response looks like HTML (login page)
if body.trim_start().starts_with("<!DOCTYPE") || body.trim_start().starts_with("<html") {
return Err("Server returned HTML page instead of JSON. This usually means authentication is required or the endpoint is incorrect.".to_string());
}
2025-07-22 14:12:09 +03:00
// Fallback: try to parse as plain text (old format)
let lines: Vec<&str> = body.lines().collect();
let ssh_key_count = lines.iter()
.filter(|line| !line.trim().is_empty() && !line.starts_with('#'))
.count();
2025-07-22 17:27:19 +03:00
if ssh_key_count == 0 && !body.trim().is_empty() {
2025-07-22 14:12:09 +03:00
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))
}
}
}
2025-07-22 17:27:19 +03:00
async fn perform_manual_sync(settings: KhmSettings) -> Result<String, String> {
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)
}