use wasm_bindgen::prelude::*; use wasm_bindgen::JsCast; use wasm_bindgen_futures::JsFuture; use serde::{Deserialize, Serialize}; use std::collections::{HashMap, BTreeMap, HashSet}; use std::future::Future; use web_sys::{window, Request, RequestInit, RequestMode, Response}; #[derive(Debug, Clone, Serialize, Deserialize)] pub struct SshKey { pub server: String, pub public_key: String, #[serde(default)] pub deprecated: bool, } #[derive(Debug, Clone)] pub struct AdminSettings { pub selected_flow: String, } impl Default for AdminSettings { fn default() -> Self { Self { selected_flow: String::new(), } } } #[derive(Debug, Clone)] pub struct AdminState { pub keys: Vec, pub filtered_keys: Vec, pub search_term: String, pub show_deprecated_only: bool, pub show_active_only: bool, pub selected_servers: HashMap, pub expanded_servers: HashMap, pub current_operation: String, } #[derive(Debug, Clone)] pub struct AdminStatistics { pub total_keys: usize, pub active_keys: usize, pub deprecated_keys: usize, pub unique_servers: usize, } #[derive(Debug, Clone)] pub enum KeyAction { None, DeprecateKey(String), RestoreKey(String), DeleteKey(String), DeprecateServer(String), RestoreServer(String), } #[derive(Debug, Clone)] pub enum BulkAction { None, DeprecateSelected, RestoreSelected, ClearSelection, } impl Default for AdminState { fn default() -> Self { Self { keys: Vec::new(), filtered_keys: Vec::new(), search_term: String::new(), show_deprecated_only: false, show_active_only: false, selected_servers: HashMap::new(), expanded_servers: HashMap::new(), current_operation: String::new(), } } } impl AdminState { pub fn filter_keys(&mut self) { let mut filtered = self.keys.clone(); // Apply status filter if self.show_deprecated_only { filtered.retain(|key| key.deprecated); } else if self.show_active_only { filtered.retain(|key| !key.deprecated); } // By default, show all keys (both active and 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; } 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, } } pub fn get_selected_servers(&self) -> Vec { self.selected_servers .iter() .filter_map(|(server, &selected)| { if selected { Some(server.clone()) } else { None } }) .collect() } pub fn clear_selection(&mut self) { self.selected_servers.clear(); } } // Utility functions 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() } } 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())]) } } // API functions for WASM async fn fetch_api(url: &str) -> Result { let mut opts = RequestInit::new(); opts.method("GET"); opts.mode(RequestMode::Cors); let request = Request::new_with_str_and_init(url, &opts)?; let window = window().unwrap(); let resp_value = JsFuture::from(window.fetch_with_request(&request)).await?; let resp: Response = resp_value.dyn_into()?; Ok(resp) } async fn fetch_flows() -> Result, JsValue> { let resp = fetch_api("/api/flows").await?; let json = JsFuture::from(resp.json()?).await?; let flows: Vec = serde_wasm_bindgen::from_value(json)?; Ok(flows) } async fn fetch_keys(flow: &str) -> Result, JsValue> { let url = format!("/{}/keys", flow); let resp = fetch_api(&url).await?; let json = JsFuture::from(resp.json()?).await?; let keys: Vec = serde_wasm_bindgen::from_value(json)?; Ok(keys) } pub struct WebAdminApp { settings: AdminSettings, admin_state: AdminState, status_message: String, available_flows: Vec, loading: bool, flows_promise: Option, keys_promise: Option, json_promise: Option, operation_promise: Option, pending_operation: String, flows_loaded: bool, auto_load_keys: bool, } impl Default for WebAdminApp { fn default() -> Self { Self { settings: AdminSettings::default(), admin_state: AdminState::default(), status_message: "Loading flows...".to_string(), available_flows: Vec::new(), loading: true, flows_promise: None, keys_promise: None, json_promise: None, operation_promise: None, pending_operation: String::new(), flows_loaded: false, auto_load_keys: false, } } } impl eframe::App for WebAdminApp { fn update(&mut self, ctx: &egui::Context, _frame: &mut eframe::Frame) { // Check if we're on mobile/small screen let screen_width = ctx.screen_rect().width(); let is_mobile = screen_width < 600.0; // Set mobile-friendly spacing let base_spacing = if is_mobile { 8.0 } else { 10.0 }; let button_height = if is_mobile { 44.0 } else { 32.0 }; // Touch-friendly size // Auto-load flows on startup if !self.flows_loaded && self.flows_promise.is_none() { self.load_flows(); } // Auto-load keys when flow changes if self.auto_load_keys && !self.settings.selected_flow.is_empty() && self.keys_promise.is_none() && self.json_promise.is_none() { self.load_keys(); self.auto_load_keys = false; } // Check for completed promises if let Some(mut promise) = self.flows_promise.take() { use std::task::{Context, Poll, Waker}; use std::pin::Pin; struct DummyWaker; impl std::task::Wake for DummyWaker { fn wake(self: std::sync::Arc) {} } let waker = Waker::from(std::sync::Arc::new(DummyWaker)); let mut cx = Context::from_waker(&waker); match Pin::new(&mut promise).poll(&mut cx) { Poll::Ready(Ok(response_js)) => { if let Ok(response) = response_js.dyn_into::() { if response.ok() { let json_promise = response.json().unwrap(); self.json_promise = Some(wasm_bindgen_futures::JsFuture::from(json_promise)); self.pending_operation = "flows".to_string(); self.status_message = "Parsing flows response...".to_string(); } else { self.loading = false; self.status_message = "Failed to load flows".to_string(); } } } Poll::Ready(Err(_)) => { self.loading = false; self.status_message = "Error loading flows".to_string(); } Poll::Pending => { self.flows_promise = Some(promise); ctx.request_repaint(); } } } if let Some(mut promise) = self.keys_promise.take() { use std::task::{Context, Poll, Waker}; use std::pin::Pin; struct DummyWaker; impl std::task::Wake for DummyWaker { fn wake(self: std::sync::Arc) {} } let waker = Waker::from(std::sync::Arc::new(DummyWaker)); let mut cx = Context::from_waker(&waker); match Pin::new(&mut promise).poll(&mut cx) { Poll::Ready(Ok(response_js)) => { if let Ok(response) = response_js.dyn_into::() { if response.ok() { let json_promise = response.json().unwrap(); self.json_promise = Some(wasm_bindgen_futures::JsFuture::from(json_promise)); self.pending_operation = "keys".to_string(); self.status_message = "Parsing keys response...".to_string(); } else { self.loading = false; self.status_message = "Failed to load keys".to_string(); } } } Poll::Ready(Err(_)) => { self.loading = false; self.status_message = "Error loading keys".to_string(); } Poll::Pending => { self.keys_promise = Some(promise); ctx.request_repaint(); } } } // Check for completed operations if let Some(mut promise) = self.operation_promise.take() { use std::task::{Context, Poll, Waker}; use std::pin::Pin; struct DummyWaker; impl std::task::Wake for DummyWaker { fn wake(self: std::sync::Arc) {} } let waker = Waker::from(std::sync::Arc::new(DummyWaker)); let mut cx = Context::from_waker(&waker); match Pin::new(&mut promise).poll(&mut cx) { Poll::Ready(Ok(response_js)) => { self.loading = false; if let Ok(response) = response_js.dyn_into::() { if response.ok() { let parts: Vec<&str> = self.pending_operation.split(':').collect(); if parts.len() == 2 { let operation = parts[0]; let param = parts[1]; match operation { "deprecate" => { self.status_message = format!("Key deprecated for {}", param); self.load_keys(); // Reload to show changes } "restore" => { self.status_message = format!("Key restored for {}", param); self.load_keys(); // Reload to show changes } "delete" => { self.status_message = format!("Key deleted for {}", param); self.load_keys(); // Reload to show changes } "bulk-deprecate" => { self.status_message = format!("Deprecated {} servers", param); self.admin_state.clear_selection(); // Clear selection after bulk operation self.load_keys(); // Reload to show changes } "bulk-restore" => { self.status_message = format!("Restored {} servers", param); self.admin_state.clear_selection(); // Clear selection after bulk operation self.load_keys(); // Reload to show changes } _ => { self.status_message = "Operation completed".to_string(); } } } } else { self.status_message = "Operation failed".to_string(); } } self.pending_operation.clear(); } Poll::Ready(Err(_)) => { self.loading = false; self.status_message = "Operation error".to_string(); self.pending_operation.clear(); } Poll::Pending => { self.operation_promise = Some(promise); ctx.request_repaint(); } } } // Check for completed JSON parsing if let Some(mut promise) = self.json_promise.take() { use std::task::{Context, Poll, Waker}; use std::pin::Pin; struct DummyWaker; impl std::task::Wake for DummyWaker { fn wake(self: std::sync::Arc) {} } let waker = Waker::from(std::sync::Arc::new(DummyWaker)); let mut cx = Context::from_waker(&waker); match Pin::new(&mut promise).poll(&mut cx) { Poll::Ready(Ok(json_data)) => { self.loading = false; match self.pending_operation.as_str() { "flows" => { if let Ok(flows) = serde_wasm_bindgen::from_value::>(json_data) { self.available_flows = flows.clone(); self.flows_loaded = true; // Auto-select first flow if !flows.is_empty() && self.settings.selected_flow.is_empty() { self.settings.selected_flow = flows[0].clone(); self.auto_load_keys = true; } self.status_message = format!("Loaded {} flows", flows.len()); } else { self.status_message = "Failed to parse flows data".to_string(); } } "keys" => { if let Ok(keys) = serde_wasm_bindgen::from_value::>(json_data) { self.admin_state.keys = keys.clone(); self.admin_state.filter_keys(); self.status_message = format!("Loaded {} keys", keys.len()); } else { self.status_message = "Failed to parse keys data".to_string(); } } _ => { self.status_message = "Unknown operation completed".to_string(); } } self.pending_operation.clear(); } Poll::Ready(Err(_)) => { self.loading = false; self.status_message = "Error parsing JSON response".to_string(); self.pending_operation.clear(); } Poll::Pending => { self.json_promise = Some(promise); ctx.request_repaint(); } } } egui::CentralPanel::default().show(ctx, |ui| { let title_size = if is_mobile { 22.0 } else { 28.0 }; ui.add_space(base_spacing); ui.heading(egui::RichText::new("πŸ”‘ KHM Web Admin Panel").size(title_size)); ui.separator(); ui.add_space(base_spacing * 1.5); // Flow Selection ui.group(|ui| { ui.set_min_width(ui.available_width()); ui.vertical(|ui| { let section_title_size = if is_mobile { 16.0 } else { 18.0 }; ui.label(egui::RichText::new("πŸ“‚ Flow Selection").size(section_title_size).strong()); ui.add_space(base_spacing); // Use vertical layout on mobile for better space usage if is_mobile { ui.vertical(|ui| { ui.label(egui::RichText::new("Current Flow:").size(14.0)); ui.add_space(5.0); let mut flow_changed = false; let old_flow = self.settings.selected_flow.clone(); egui::ComboBox::from_id_salt("flow_selector") .selected_text(if self.settings.selected_flow.is_empty() { "Select flow..." } else { &self.settings.selected_flow }) .width(ui.available_width() - 20.0) .show_ui(ui, |ui| { for flow in &self.available_flows { if ui.selectable_value(&mut self.settings.selected_flow, flow.clone(), egui::RichText::new(flow).size(14.0)).clicked() { flow_changed = true; } } }); if flow_changed && old_flow != self.settings.selected_flow { self.auto_load_keys = true; } ui.add_space(base_spacing); if ui.add_sized([ui.available_width(), button_height], egui::Button::new(egui::RichText::new("πŸ”„ Refresh").size(14.0))).clicked() && !self.loading { if !self.settings.selected_flow.is_empty() { self.load_keys(); } } }); } else { ui.horizontal(|ui| { ui.label(egui::RichText::new("Current Flow:").size(16.0)); ui.add_space(10.0); let mut flow_changed = false; let old_flow = self.settings.selected_flow.clone(); egui::ComboBox::from_id_salt("flow_selector") .selected_text(if self.settings.selected_flow.is_empty() { "Select flow..." } else { &self.settings.selected_flow }) .width(300.0) .show_ui(ui, |ui| { for flow in &self.available_flows { if ui.selectable_value(&mut self.settings.selected_flow, flow.clone(), egui::RichText::new(flow).size(14.0)).clicked() { flow_changed = true; } } }); if flow_changed && old_flow != self.settings.selected_flow { self.auto_load_keys = true; } ui.add_space(20.0); if ui.add_sized([120.0, button_height], egui::Button::new(egui::RichText::new("πŸ”„ Refresh").size(14.0))).clicked() && !self.loading { if !self.settings.selected_flow.is_empty() { self.load_keys(); } } }); } }); }); ui.add_space(base_spacing); // Statistics if !self.admin_state.keys.is_empty() { self.render_statistics(ui, is_mobile); ui.add_space(base_spacing); } // Search and filters if !self.admin_state.keys.is_empty() { self.render_search_controls(ui, is_mobile); ui.add_space(base_spacing); } // Bulk actions let bulk_action = self.render_bulk_actions(ui, is_mobile, button_height); if bulk_action != BulkAction::None { self.handle_bulk_action(bulk_action); } // Keys display if !self.admin_state.filtered_keys.is_empty() { let key_action = self.render_keys_table(ui, is_mobile, button_height); if key_action != KeyAction::None { self.handle_key_action(key_action); } } else if !self.admin_state.keys.is_empty() { self.render_empty_state(ui); } ui.add_space(10.0); // Status bar ui.separator(); ui.horizontal(|ui| { ui.label("Status:"); ui.colored_label(egui::Color32::LIGHT_BLUE, &self.status_message); }); }); } } impl WebAdminApp { fn load_flows(&mut self) { self.status_message = "Loading flows...".to_string(); let window = web_sys::window().unwrap(); let opts = web_sys::RequestInit::new(); opts.set_method("GET"); opts.set_mode(web_sys::RequestMode::Cors); if let Ok(request) = web_sys::Request::new_with_str_and_init("/api/flows", &opts) { let promise = window.fetch_with_request(&request); self.flows_promise = Some(wasm_bindgen_futures::JsFuture::from(promise)); self.loading = true; } } fn load_keys(&mut self) { if self.settings.selected_flow.is_empty() { return; } self.status_message = format!("Loading keys for {}...", self.settings.selected_flow); // Add include_deprecated=true to show all keys (active and deprecated) let url = format!("/{}/keys?include_deprecated=true", self.settings.selected_flow); let window = web_sys::window().unwrap(); let opts = web_sys::RequestInit::new(); opts.set_method("GET"); opts.set_mode(web_sys::RequestMode::Cors); if let Ok(request) = web_sys::Request::new_with_str_and_init(&url, &opts) { let promise = window.fetch_with_request(&request); self.keys_promise = Some(wasm_bindgen_futures::JsFuture::from(promise)); self.loading = true; } } fn deprecate_key(&mut self, server: &str) { if self.settings.selected_flow.is_empty() { return; } self.status_message = format!("Deprecating key for {}...", server); let url = format!("/{}/keys/{}", self.settings.selected_flow, server); let window = web_sys::window().unwrap(); let opts = web_sys::RequestInit::new(); opts.set_method("DELETE"); // ΠŸΡ€Π°Π²ΠΈΠ»ΡŒΠ½Ρ‹ΠΉ ΠΌΠ΅Ρ‚ΠΎΠ΄ для deprecate opts.set_mode(web_sys::RequestMode::Cors); if let Ok(request) = web_sys::Request::new_with_str_and_init(&url, &opts) { let promise = window.fetch_with_request(&request); self.operation_promise = Some(wasm_bindgen_futures::JsFuture::from(promise)); self.pending_operation = format!("deprecate:{}", server); self.loading = true; } } fn restore_key(&mut self, server: &str) { if self.settings.selected_flow.is_empty() { return; } self.status_message = format!("Restoring key for {}...", server); let url = format!("/{}/keys/{}/restore", self.settings.selected_flow, server); let window = web_sys::window().unwrap(); let opts = web_sys::RequestInit::new(); opts.set_method("POST"); opts.set_mode(web_sys::RequestMode::Cors); if let Ok(request) = web_sys::Request::new_with_str_and_init(&url, &opts) { let promise = window.fetch_with_request(&request); self.operation_promise = Some(wasm_bindgen_futures::JsFuture::from(promise)); self.pending_operation = format!("restore:{}", server); self.loading = true; } } fn delete_key(&mut self, server: &str) { if self.settings.selected_flow.is_empty() { return; } self.status_message = format!("Deleting key for {}...", server); let url = format!("/{}/keys/{}/delete", self.settings.selected_flow, server); let window = web_sys::window().unwrap(); let opts = web_sys::RequestInit::new(); opts.set_method("DELETE"); // ΠŸΡ€Π°Π²ΠΈΠ»ΡŒΠ½Ρ‹ΠΉ ΠΌΠ΅Ρ‚ΠΎΠ΄ для delete opts.set_mode(web_sys::RequestMode::Cors); if let Ok(request) = web_sys::Request::new_with_str_and_init(&url, &opts) { let promise = window.fetch_with_request(&request); self.operation_promise = Some(wasm_bindgen_futures::JsFuture::from(promise)); self.pending_operation = format!("delete:{}", server); self.loading = true; } } fn bulk_deprecate_servers(&mut self, servers: Vec) { if self.settings.selected_flow.is_empty() { return; } self.status_message = format!("Deprecating {} servers...", servers.len()); let url = format!("/{}/bulk-deprecate", self.settings.selected_flow); let window = web_sys::window().unwrap(); let opts = web_sys::RequestInit::new(); opts.set_method("POST"); opts.set_mode(web_sys::RequestMode::Cors); // Create JSON body let body = serde_json::json!({ "servers": servers }); if let Ok(body_str) = serde_json::to_string(&body) { opts.set_body(&wasm_bindgen::JsValue::from_str(&body_str)); opts.set_headers(&{ let headers = web_sys::Headers::new().unwrap(); headers.set("Content-Type", "application/json").unwrap(); headers.into() }); if let Ok(request) = web_sys::Request::new_with_str_and_init(&url, &opts) { let promise = window.fetch_with_request(&request); self.operation_promise = Some(wasm_bindgen_futures::JsFuture::from(promise)); self.pending_operation = format!("bulk-deprecate:{}", servers.len()); self.loading = true; } } } fn bulk_restore_servers(&mut self, servers: Vec) { if self.settings.selected_flow.is_empty() { return; } self.status_message = format!("Restoring {} servers...", servers.len()); let url = format!("/{}/bulk-restore", self.settings.selected_flow); let window = web_sys::window().unwrap(); let opts = web_sys::RequestInit::new(); opts.set_method("POST"); opts.set_mode(web_sys::RequestMode::Cors); // Create JSON body let body = serde_json::json!({ "servers": servers }); if let Ok(body_str) = serde_json::to_string(&body) { opts.set_body(&wasm_bindgen::JsValue::from_str(&body_str)); opts.set_headers(&{ let headers = web_sys::Headers::new().unwrap(); headers.set("Content-Type", "application/json").unwrap(); headers.into() }); if let Ok(request) = web_sys::Request::new_with_str_and_init(&url, &opts) { let promise = window.fetch_with_request(&request); self.operation_promise = Some(wasm_bindgen_futures::JsFuture::from(promise)); self.pending_operation = format!("bulk-restore:{}", servers.len()); self.loading = true; } } } fn render_statistics(&self, ui: &mut egui::Ui, is_mobile: bool) { let stats = self.admin_state.get_statistics(); ui.group(|ui| { ui.set_min_width(ui.available_width()); ui.vertical(|ui| { let title_size = if is_mobile { 16.0 } else { 20.0 }; ui.label(egui::RichText::new("πŸ“Š Statistics").size(title_size).strong()); ui.add_space(if is_mobile { 10.0 } else { 15.0 }); // Use 2x2 grid on mobile for better readability if is_mobile { ui.columns(2, |cols| { // Total keys cols[0].vertical_centered_justified(|ui| { ui.label(egui::RichText::new("πŸ“Š").size(24.0)); ui.label( egui::RichText::new(stats.total_keys.to_string()) .size(28.0) .strong(), ); ui.label( egui::RichText::new("Total Keys") .size(12.0) .color(egui::Color32::GRAY), ); }); // Active keys - using original admin colors cols[1].vertical_centered_justified(|ui| { ui.label(egui::RichText::new("βœ…").size(24.0)); ui.label( egui::RichText::new(stats.active_keys.to_string()) .size(28.0) .strong() .color(egui::Color32::from_rgb(46, 204, 113)), ); ui.label( egui::RichText::new("Active") .size(12.0) .color(egui::Color32::GRAY), ); }); }); ui.add_space(10.0); ui.columns(2, |cols| { // Deprecated keys - using original admin colors cols[0].vertical_centered_justified(|ui| { ui.label(egui::RichText::new("❌").size(24.0)); ui.label( egui::RichText::new(stats.deprecated_keys.to_string()) .size(28.0) .strong() .color(egui::Color32::from_rgb(231, 76, 60)), ); ui.label( egui::RichText::new("Deprecated") .size(12.0) .color(egui::Color32::GRAY), ); }); // Servers - using original admin colors cols[1].vertical_centered_justified(|ui| { ui.label(egui::RichText::new("πŸ’»").size(24.0)); ui.label( egui::RichText::new(stats.unique_servers.to_string()) .size(28.0) .strong() .color(egui::Color32::from_rgb(52, 152, 219)), ); ui.label( egui::RichText::new("Servers") .size(12.0) .color(egui::Color32::GRAY), ); }); }); } else { ui.horizontal(|ui| { ui.columns(4, |cols| { // Total keys cols[0].vertical_centered_justified(|ui| { ui.label(egui::RichText::new("πŸ“Š").size(32.0)); ui.add_space(5.0); ui.label( egui::RichText::new(stats.total_keys.to_string()) .size(36.0) .strong(), ); ui.label( egui::RichText::new("Total Keys") .size(14.0) .color(egui::Color32::GRAY), ); }); // Active keys - using original admin colors cols[1].vertical_centered_justified(|ui| { ui.label(egui::RichText::new("βœ…").size(32.0)); ui.add_space(5.0); ui.label( egui::RichText::new(stats.active_keys.to_string()) .size(36.0) .strong() .color(egui::Color32::from_rgb(46, 204, 113)), ); ui.label( egui::RichText::new("Active") .size(14.0) .color(egui::Color32::GRAY), ); }); // Deprecated keys - using original admin colors cols[2].vertical_centered_justified(|ui| { ui.label(egui::RichText::new("❌").size(32.0)); ui.add_space(5.0); ui.label( egui::RichText::new(stats.deprecated_keys.to_string()) .size(36.0) .strong() .color(egui::Color32::from_rgb(231, 76, 60)), ); ui.label( egui::RichText::new("Deprecated") .size(14.0) .color(egui::Color32::GRAY), ); }); // Servers - using original admin colors cols[3].vertical_centered_justified(|ui| { ui.label(egui::RichText::new("πŸ’»").size(32.0)); ui.add_space(5.0); ui.label( egui::RichText::new(stats.unique_servers.to_string()) .size(36.0) .strong() .color(egui::Color32::from_rgb(52, 152, 219)), ); ui.label( egui::RichText::new("Servers") .size(14.0) .color(egui::Color32::GRAY), ); }); }); }); } }); }); } fn render_search_controls(&mut self, ui: &mut egui::Ui, is_mobile: bool) { ui.group(|ui| { ui.set_min_width(ui.available_width()); ui.vertical(|ui| { let title_size = if is_mobile { 16.0 } else { 20.0 }; ui.label(egui::RichText::new("πŸ” Search & Filter").size(title_size).strong()); ui.add_space(if is_mobile { 8.0 } else { 12.0 }); // Search field if is_mobile { ui.vertical(|ui| { ui.label(egui::RichText::new("πŸ” Search").size(14.0)); let search_response = ui.add_sized( [ui.available_width(), 36.0], // Larger touch target egui::TextEdit::singleline(&mut self.admin_state.search_term) .hint_text("Search servers or keys...") .font(egui::FontId::proportional(16.0)), ); ui.add_space(5.0); if self.admin_state.search_term.is_empty() { ui.label( egui::RichText::new("Type to search") .size(12.0) .color(egui::Color32::GRAY), ); } else { ui.horizontal(|ui| { ui.label( egui::RichText::new(format!("{} results", self.admin_state.filtered_keys.len())) .size(12.0), ); if ui.add_sized([60.0, 32.0], egui::Button::new(egui::RichText::new("❌ Clear").size(12.0))).clicked() { self.admin_state.search_term.clear(); self.admin_state.filter_keys(); } }); } if search_response.changed() { self.admin_state.filter_keys(); } }); } else { ui.horizontal(|ui| { ui.label(egui::RichText::new("πŸ”").size(18.0)); let search_response = ui.add_sized( [ui.available_width() * 0.6, 28.0], egui::TextEdit::singleline(&mut self.admin_state.search_term) .hint_text("Search servers or keys...") .font(egui::FontId::proportional(16.0)), ); if self.admin_state.search_term.is_empty() { ui.label( egui::RichText::new("Type to search") .size(14.0) .color(egui::Color32::GRAY), ); } else { ui.label( egui::RichText::new(format!("{} results", self.admin_state.filtered_keys.len())) .size(14.0), ); if ui.add_sized([35.0, 28.0], egui::Button::new(egui::RichText::new("❌").size(14.0))).on_hover_text("Clear search").clicked() { self.admin_state.search_term.clear(); self.admin_state.filter_keys(); } } if search_response.changed() { self.admin_state.filter_keys(); } }); } ui.add_space(if is_mobile { 8.0 } else { 10.0 }); // Filter buttons - using original admin colors let show_all = !self.admin_state.show_deprecated_only && !self.admin_state.show_active_only; let show_active = self.admin_state.show_active_only; let show_deprecated = self.admin_state.show_deprecated_only; if is_mobile { ui.vertical(|ui| { ui.label(egui::RichText::new("Filter:").size(14.0)); ui.add_space(5.0); if ui.add_sized([ui.available_width(), 40.0], egui::Button::new(egui::RichText::new("πŸ“‹ All Keys").size(14.0) .color(if show_all { egui::Color32::WHITE } else { egui::Color32::BLACK })) .fill(if show_all { egui::Color32::from_rgb(52, 152, 219) } else { egui::Color32::GRAY })).clicked() { self.admin_state.show_deprecated_only = false; self.admin_state.show_active_only = false; self.admin_state.filter_keys(); } ui.add_space(5.0); if ui.add_sized([ui.available_width(), 40.0], egui::Button::new(egui::RichText::new("βœ… Active Only").size(14.0) .color(if show_active { egui::Color32::WHITE } else { egui::Color32::BLACK })) .fill(if show_active { egui::Color32::from_rgb(46, 204, 113) } else { egui::Color32::GRAY })).clicked() { self.admin_state.show_deprecated_only = false; self.admin_state.show_active_only = true; self.admin_state.filter_keys(); } ui.add_space(5.0); if ui.add_sized([ui.available_width(), 40.0], egui::Button::new(egui::RichText::new("❗ Deprecated Only").size(14.0) .color(if show_deprecated { egui::Color32::WHITE } else { egui::Color32::BLACK })) .fill(if show_deprecated { egui::Color32::from_rgb(231, 76, 60) } else { egui::Color32::GRAY })).clicked() { self.admin_state.show_deprecated_only = true; self.admin_state.show_active_only = false; self.admin_state.filter_keys(); } }); } else { ui.horizontal(|ui| { ui.label(egui::RichText::new("Filter:").size(16.0)); ui.add_space(10.0); if ui.add_sized([80.0, 32.0], egui::Button::new(egui::RichText::new("πŸ“‹ All").size(14.0) .color(if show_all { egui::Color32::WHITE } else { egui::Color32::BLACK })) .fill(if show_all { egui::Color32::from_rgb(52, 152, 219) } else { egui::Color32::GRAY })).clicked() { self.admin_state.show_deprecated_only = false; self.admin_state.show_active_only = false; self.admin_state.filter_keys(); } if ui.add_sized([100.0, 32.0], egui::Button::new(egui::RichText::new("βœ… Active").size(14.0) .color(if show_active { egui::Color32::WHITE } else { egui::Color32::BLACK })) .fill(if show_active { egui::Color32::from_rgb(46, 204, 113) } else { egui::Color32::GRAY })).clicked() { self.admin_state.show_deprecated_only = false; self.admin_state.show_active_only = true; self.admin_state.filter_keys(); } if ui.add_sized([120.0, 32.0], egui::Button::new(egui::RichText::new("❗ Deprecated").size(14.0) .color(if show_deprecated { egui::Color32::WHITE } else { egui::Color32::BLACK })) .fill(if show_deprecated { egui::Color32::from_rgb(231, 76, 60) } else { egui::Color32::GRAY })).clicked() { self.admin_state.show_deprecated_only = true; self.admin_state.show_active_only = false; self.admin_state.filter_keys(); } }); } }); }); } fn render_bulk_actions(&mut self, ui: &mut egui::Ui, is_mobile: bool, button_height: f32) -> BulkAction { let selected_count = self.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); // Use original admin colors for buttons if is_mobile { ui.vertical(|ui| { if ui.add_sized([ui.available_width(), button_height], egui::Button::new(egui::RichText::new("❗ Deprecate Selected").size(14.0) .color(egui::Color32::BLACK)) .fill(egui::Color32::from_rgb(255, 200, 0))).clicked() { action = BulkAction::DeprecateSelected; } ui.add_space(5.0); if ui.add_sized([ui.available_width(), button_height], egui::Button::new(egui::RichText::new("βœ… Restore Selected").size(14.0) .color(egui::Color32::WHITE)) .fill(egui::Color32::from_rgb(101, 199, 40))).clicked() { action = BulkAction::RestoreSelected; } ui.add_space(5.0); if ui.add_sized([ui.available_width(), button_height], egui::Button::new(egui::RichText::new("❌ Clear Selection").size(14.0) .color(egui::Color32::WHITE)) .fill(egui::Color32::from_rgb(170, 170, 170))).clicked() { action = BulkAction::ClearSelection; } }); } else { ui.horizontal(|ui| { if ui.add_sized([160.0, button_height], egui::Button::new(egui::RichText::new("❗ Deprecate Selected").size(14.0) .color(egui::Color32::BLACK)) .fill(egui::Color32::from_rgb(255, 200, 0))).clicked() { action = BulkAction::DeprecateSelected; } ui.add_space(10.0); if ui.add_sized([140.0, button_height], egui::Button::new(egui::RichText::new("βœ… Restore Selected").size(14.0) .color(egui::Color32::WHITE)) .fill(egui::Color32::from_rgb(101, 199, 40))).clicked() { action = BulkAction::RestoreSelected; } ui.add_space(10.0); if ui.add_sized([120.0, button_height], egui::Button::new(egui::RichText::new("❌ Clear Selection").size(14.0) .color(egui::Color32::WHITE)) .fill(egui::Color32::from_rgb(170, 170, 170))).clicked() { action = BulkAction::ClearSelection; } }); } }); }); action } fn render_keys_table(&mut self, ui: &mut egui::Ui, is_mobile: bool, button_height: f32) -> KeyAction { let mut action = KeyAction::None; // Group keys by server let mut servers: BTreeMap> = BTreeMap::new(); for key in &self.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 = self.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 = self.admin_state .selected_servers .get(&server_name) .copied() .unwrap_or(false); if ui.checkbox(&mut selected, "").changed() { self.admin_state .selected_servers .insert(server_name.clone(), selected); } // Expand/collapse button let expand_icon = if is_expanded { "-" } else { "+" }; if ui.small_button(expand_icon).clicked() { self.admin_state .expanded_servers .insert(server_name.clone(), !is_expanded); } // Server info ui.label(egui::RichText::new("πŸ’»").size(16.0)); ui.label( egui::RichText::new(&server_name) .size(15.0) .strong() .color(egui::Color32::WHITE), ); ui.label(format!("{} keys", server_keys.len())); if deprecated_count > 0 { ui.label( egui::RichText::new(format!("{} depr", deprecated_count)) .color(egui::Color32::LIGHT_RED), ); } ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| { let server_button_size = if is_mobile { egui::vec2(80.0, 32.0) } else { egui::vec2(70.0, 24.0) }; if deprecated_count > 0 { if ui.add_sized(server_button_size, 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)))) .clicked() { action = KeyAction::RestoreServer(server_name.clone()); } } if active_count > 0 { if ui.add_sized(server_button_size, 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)))) .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) = self.render_key_item(ui, key, &server_name, is_mobile, button_height) { action = key_action; } } }); } ui.add_space(5.0); } action } fn render_key_item(&mut self, ui: &mut egui::Ui, key: &SshKey, server_name: &str, is_mobile: bool, _button_height: f32) -> Option { let mut action = None; ui.group(|ui| { ui.horizontal(|ui| { // Key type badge let key_type = get_key_type(&key.public_key); ui.label( egui::RichText::new(&key_type) .size(10.0) .color(egui::Color32::LIGHT_BLUE), ); 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("βœ…") .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 with original admin colors let button_size = if is_mobile { egui::vec2(50.0, 32.0) } else { egui::vec2(40.0, 24.0) }; if key.deprecated { if ui.add_sized(button_size, 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)))) .on_hover_text("Restore key").clicked() { action = Some(KeyAction::RestoreKey(server_name.to_string())); } if ui.add_sized(button_size, 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)))) .on_hover_text("Delete key").clicked() { action = Some(KeyAction::DeleteKey(server_name.to_string())); } } else { if ui.add_sized(button_size, 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)))) .on_hover_text("Deprecate key").clicked() { action = Some(KeyAction::DeprecateKey(server_name.to_string())); } } if ui.add_sized(button_size, 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)))) .on_hover_text("Copy to clipboard").clicked() { ui.output_mut(|o| o.copied_text = key.public_key.clone()); } }); }); }); action } fn render_empty_state(&self, ui: &mut egui::Ui) { 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 if !self.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: '{}'", self.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), ); } }); } fn handle_bulk_action(&mut self, action: BulkAction) { match action { BulkAction::DeprecateSelected => { let selected = self.admin_state.get_selected_servers(); if !selected.is_empty() { self.bulk_deprecate_servers(selected); } } BulkAction::RestoreSelected => { let selected = self.admin_state.get_selected_servers(); if !selected.is_empty() { self.bulk_restore_servers(selected); } } BulkAction::ClearSelection => { self.admin_state.clear_selection(); self.status_message = "Selection cleared".to_string(); } BulkAction::None => {} } } fn handle_key_action(&mut self, action: KeyAction) { match action { KeyAction::DeprecateKey(server) => { self.deprecate_key(&server); } KeyAction::RestoreKey(server) => { self.restore_key(&server); } KeyAction::DeleteKey(server) => { self.delete_key(&server); } KeyAction::DeprecateServer(server) => { self.deprecate_key(&server); } KeyAction::RestoreServer(server) => { self.restore_key(&server); } KeyAction::None => {} } } } impl PartialEq for KeyAction { fn eq(&self, other: &Self) -> bool { matches!((self, other), (KeyAction::None, KeyAction::None)) } } impl PartialEq for BulkAction { fn eq(&self, other: &Self) -> bool { matches!((self, other), (BulkAction::None, BulkAction::None)) } } /// WASM entry point #[wasm_bindgen] pub fn start_web_admin(canvas_id: &str) -> Result<(), JsValue> { console_error_panic_hook::set_once(); tracing_wasm::set_as_global_default(); let web_options = eframe::WebOptions::default(); let canvas_id = canvas_id.to_string(); wasm_bindgen_futures::spawn_local(async move { let app = WebAdminApp::default(); // Get the canvas element let document = web_sys::window() .unwrap() .document() .unwrap(); let canvas = document .get_element_by_id(&canvas_id) .unwrap() .dyn_into::() .unwrap(); let result = eframe::WebRunner::new() .start( canvas, web_options, Box::new(|_cc| Ok(Box::new(app))), ) .await; match result { Ok(_) => web_sys::console::log_1(&"KHM Web Admin started successfully".into()), Err(e) => web_sys::console::error_1(&format!("Failed to start KHM Web Admin: {:?}", e).into()), } }); Ok(()) } #[wasm_bindgen(start)] pub fn wasm_main() { console_error_panic_hook::set_once(); }