web wasm ui

This commit is contained in:
Alexandr Bogomiakov
2025-07-24 08:24:51 +03:00
parent 626b152227
commit 8af35dca4f
17 changed files with 6766 additions and 18 deletions

399
src/web_gui/api.rs Normal file
View File

@@ -0,0 +1,399 @@
use super::state::{SshKey, DnsResult, AdminSettings};
use log::info;
use reqwest::Client;
use std::time::Duration;
/// Create HTTP client for API requests
fn create_http_client() -> Result<Client, String> {
Client::builder()
.timeout(Duration::from_secs(30))
.redirect(reqwest::redirect::Policy::none())
.build()
.map_err(|e| format!("Failed to create HTTP client: {}", e))
}
/// Add basic auth to request if provided
fn add_auth_if_needed(
request: reqwest::RequestBuilder,
basic_auth: &str,
) -> Result<reqwest::RequestBuilder, String> {
if basic_auth.is_empty() {
return Ok(request);
}
let auth_parts: Vec<&str> = basic_auth.splitn(2, ':').collect();
if auth_parts.len() == 2 {
Ok(request.basic_auth(auth_parts[0], Some(auth_parts[1])))
} else {
Err("Basic auth format should be 'username:password'".to_string())
}
}
/// Check response status for errors
fn check_response_status(response: &reqwest::Response) -> Result<(), String> {
let status = response.status().as_u16();
if status == 401 {
return Err("Authentication required. Please provide valid basic auth credentials.".to_string());
}
if status >= 300 && status < 400 {
return Err("Server redirects to login page. Authentication may be required.".to_string());
}
if !response.status().is_success() {
return Err(format!(
"Server returned error: {} {}",
status,
response.status().canonical_reason().unwrap_or("Unknown")
));
}
Ok(())
}
/// Check if response is HTML instead of JSON
fn check_html_response(body: &str) -> Result<(), String> {
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());
}
Ok(())
}
/// Get application version from API
pub async fn get_version(settings: &AdminSettings) -> Result<String, String> {
if settings.server_url.is_empty() {
return Err("Server URL must be specified".to_string());
}
let url = format!("{}/api/version", settings.server_url.trim_end_matches('/'));
info!("Getting version from: {}", url);
let client = create_http_client()?;
let mut request = client.get(&url);
request = add_auth_if_needed(request, &settings.basic_auth)?;
let response = request
.send()
.await
.map_err(|e| format!("Request failed: {}", e))?;
check_response_status(&response)?;
let body = response
.text()
.await
.map_err(|e| format!("Failed to read response: {}", e))?;
check_html_response(&body)?;
let version_response: serde_json::Value = serde_json::from_str(&body)
.map_err(|e| format!("Failed to parse version: {}", e))?;
let version = version_response
.get("version")
.and_then(|v| v.as_str())
.unwrap_or("unknown")
.to_string();
info!("KHM server version: {}", version);
Ok(version)
}
/// Test connection to KHM server using existing API endpoint
pub async fn test_connection(settings: &AdminSettings) -> Result<String, String> {
if settings.server_url.is_empty() || settings.selected_flow.is_empty() {
return Err("Server URL and flow must be specified".to_string());
}
let url = format!(
"{}/{}/keys",
settings.server_url.trim_end_matches('/'),
settings.selected_flow
);
info!("Testing connection to: {}", url);
let client = create_http_client()?;
let mut request = client.get(&url);
request = add_auth_if_needed(request, &settings.basic_auth)?;
let response = request
.send()
.await
.map_err(|e| format!("Request failed: {}", e))?;
check_response_status(&response)?;
let body = response
.text()
.await
.map_err(|e| format!("Failed to read response: {}", e))?;
check_html_response(&body)?;
let keys: Vec<SshKey> = serde_json::from_str(&body)
.map_err(|e| format!("Failed to parse response: {}", e))?;
let message = format!("Connection successful! Found {} SSH keys from flow '{}'", keys.len(), settings.selected_flow);
info!("{}", message);
Ok(message)
}
/// Load available flows from server
pub async fn load_flows(settings: &AdminSettings) -> Result<Vec<String>, String> {
if settings.server_url.is_empty() {
return Err("Server URL must be specified".to_string());
}
let url = format!("{}/api/flows", settings.server_url.trim_end_matches('/'));
info!("Loading flows from: {}", url);
let client = create_http_client()?;
let mut request = client.get(&url);
request = add_auth_if_needed(request, &settings.basic_auth)?;
let response = request
.send()
.await
.map_err(|e| format!("Request failed: {}", e))?;
check_response_status(&response)?;
let body = response
.text()
.await
.map_err(|e| format!("Failed to read response: {}", e))?;
check_html_response(&body)?;
let flows: Vec<String> = serde_json::from_str(&body)
.map_err(|e| format!("Failed to parse flows: {}", e))?;
info!("Loaded {} flows", flows.len());
Ok(flows)
}
/// Fetch all SSH keys including deprecated ones using existing API endpoint
pub async fn fetch_keys(settings: &AdminSettings) -> Result<Vec<SshKey>, String> {
if settings.server_url.is_empty() || settings.selected_flow.is_empty() {
return Err("Server URL and flow must be specified".to_string());
}
let url = format!(
"{}/{}/keys",
settings.server_url.trim_end_matches('/'),
settings.selected_flow
);
info!("Fetching keys from: {}", url);
let client = create_http_client()?;
let mut request = client.get(&url);
request = add_auth_if_needed(request, &settings.basic_auth)?;
let response = request
.send()
.await
.map_err(|e| format!("Request failed: {}", e))?;
check_response_status(&response)?;
let body = response
.text()
.await
.map_err(|e| format!("Failed to read response: {}", e))?;
check_html_response(&body)?;
let keys: Vec<SshKey> = serde_json::from_str(&body)
.map_err(|e| format!("Failed to parse keys: {}", e))?;
info!("Fetched {} SSH keys", keys.len());
Ok(keys)
}
/// Deprecate a key for a specific server
pub async fn deprecate_key(
settings: &AdminSettings,
server: &str,
) -> Result<String, String> {
let url = format!(
"{}/{}/keys/{}",
settings.server_url.trim_end_matches('/'),
settings.selected_flow,
urlencoding::encode(server)
);
info!("Deprecating key for server '{}' at: {}", server, url);
let client = create_http_client()?;
let mut request = client.delete(&url);
request = add_auth_if_needed(request, &settings.basic_auth)?;
let response = request
.send()
.await
.map_err(|e| format!("Request failed: {}", e))?;
check_response_status(&response)?;
Ok(format!("Successfully deprecated key for server '{}'", server))
}
/// Restore a key for a specific server
pub async fn restore_key(
settings: &AdminSettings,
server: &str,
) -> Result<String, String> {
let url = format!(
"{}/{}/keys/{}/restore",
settings.server_url.trim_end_matches('/'),
settings.selected_flow,
urlencoding::encode(server)
);
info!("Restoring key for server '{}' at: {}", server, url);
let client = create_http_client()?;
let mut request = client.post(&url);
request = add_auth_if_needed(request, &settings.basic_auth)?;
let response = request
.send()
.await
.map_err(|e| format!("Request failed: {}", e))?;
check_response_status(&response)?;
Ok(format!("Successfully restored key for server '{}'", server))
}
/// Delete a key permanently for a specific server
pub async fn delete_key(
settings: &AdminSettings,
server: &str,
) -> Result<String, String> {
let url = format!(
"{}/{}/keys/{}/delete",
settings.server_url.trim_end_matches('/'),
settings.selected_flow,
urlencoding::encode(server)
);
info!("Permanently deleting key for server '{}' at: {}", server, url);
let client = create_http_client()?;
let mut request = client.delete(&url);
request = add_auth_if_needed(request, &settings.basic_auth)?;
let response = request
.send()
.await
.map_err(|e| format!("Request failed: {}", e))?;
check_response_status(&response)?;
Ok(format!("Successfully deleted key for server '{}'", server))
}
/// Bulk deprecate multiple servers
pub async fn bulk_deprecate_servers(
settings: &AdminSettings,
servers: Vec<String>,
) -> Result<String, String> {
let url = format!(
"{}/{}/bulk-deprecate",
settings.server_url.trim_end_matches('/'),
settings.selected_flow
);
info!("Bulk deprecating {} servers at: {}", servers.len(), url);
let client = create_http_client()?;
let mut request = client.post(&url).json(&serde_json::json!({
"servers": servers
}));
request = add_auth_if_needed(request, &settings.basic_auth)?;
let response = request
.send()
.await
.map_err(|e| format!("Request failed: {}", e))?;
check_response_status(&response)?;
Ok("Successfully deprecated selected servers".to_string())
}
/// Bulk restore multiple servers
pub async fn bulk_restore_servers(
settings: &AdminSettings,
servers: Vec<String>,
) -> Result<String, String> {
let url = format!(
"{}/{}/bulk-restore",
settings.server_url.trim_end_matches('/'),
settings.selected_flow
);
info!("Bulk restoring {} servers at: {}", servers.len(), url);
let client = create_http_client()?;
let mut request = client.post(&url).json(&serde_json::json!({
"servers": servers
}));
request = add_auth_if_needed(request, &settings.basic_auth)?;
let response = request
.send()
.await
.map_err(|e| format!("Request failed: {}", e))?;
check_response_status(&response)?;
Ok("Successfully restored selected servers".to_string())
}
/// Scan DNS resolution for servers using existing API endpoint
pub async fn scan_dns_resolution(
settings: &AdminSettings,
) -> Result<Vec<DnsResult>, String> {
let url = format!(
"{}/{}/scan-dns",
settings.server_url.trim_end_matches('/'),
settings.selected_flow
);
info!("Scanning DNS resolution at: {}", url);
let client = create_http_client()?;
let mut request = client.post(&url);
request = add_auth_if_needed(request, &settings.basic_auth)?;
let response = request
.send()
.await
.map_err(|e| format!("Request failed: {}", e))?;
check_response_status(&response)?;
let body = response
.text()
.await
.map_err(|e| format!("Failed to read response: {}", e))?;
// Parse the response format from existing API: {"results": [...], "total": N, "unresolved": N}
let api_response: serde_json::Value = serde_json::from_str(&body)
.map_err(|e| format!("Failed to parse DNS response: {}", e))?;
let results = api_response
.get("results")
.and_then(|r| serde_json::from_value(r.clone()).ok())
.unwrap_or_else(Vec::new);
info!("DNS scan completed for {} servers", results.len());
Ok(results)
}

590
src/web_gui/app.rs Normal file
View File

@@ -0,0 +1,590 @@
use super::state::{AdminSettings, AdminState, ConnectionStatus, AdminOperation};
use super::ui::{self, ConnectionAction, KeyAction, BulkAction};
#[cfg(not(target_arch = "wasm32"))]
use super::api;
#[cfg(target_arch = "wasm32")]
use super::wasm_api as api;
use eframe::egui;
use std::sync::mpsc;
pub struct WebAdminApp {
settings: AdminSettings,
admin_state: AdminState,
flows: Vec<String>,
connection_status: ConnectionStatus,
operation_receiver: Option<mpsc::Receiver<AdminOperation>>,
last_operation: String,
server_version: Option<String>,
}
impl Default for WebAdminApp {
fn default() -> Self {
// Get server URL from current location if possible
let server_url = {
#[cfg(all(target_arch = "wasm32", feature = "web-gui"))]
{
web_sys::window()
.and_then(|w| w.location().origin().ok())
.unwrap_or_else(|| "http://localhost:8080".to_string())
}
#[cfg(not(all(target_arch = "wasm32", feature = "web-gui")))]
{
"http://localhost:8080".to_string()
}
};
Self {
settings: AdminSettings {
server_url,
..Default::default()
},
admin_state: AdminState::default(),
flows: Vec::new(),
connection_status: ConnectionStatus::Disconnected,
operation_receiver: None,
last_operation: "Application started".to_string(),
server_version: None,
}
}
}
impl eframe::App for WebAdminApp {
fn update(&mut self, ctx: &egui::Context, _frame: &mut eframe::Frame) {
// Handle async operations
if let Some(receiver) = &self.operation_receiver {
if let Ok(operation) = receiver.try_recv() {
self.handle_operation_result(operation);
ctx.request_repaint();
}
}
// Use the same UI structure as desktop version
egui::CentralPanel::default().show(ctx, |ui| {
ui.heading("🔑 KHM Web Admin Panel");
ui.separator();
// Connection Settings (always visible for web version)
egui::CollapsingHeader::new("⚙️ Connection Settings")
.default_open(matches!(self.connection_status, ConnectionStatus::Disconnected))
.show(ui, |ui| {
let connection_action = ui::render_connection_settings(
ui,
&mut self.settings,
&self.connection_status,
&self.flows,
&self.server_version,
);
match connection_action {
ConnectionAction::LoadFlows => self.load_flows(ctx),
ConnectionAction::TestConnection => self.test_connection(ctx),
ConnectionAction::LoadKeys => self.load_keys(ctx),
ConnectionAction::LoadVersion => self.load_version(ctx),
ConnectionAction::None => {}
}
});
ui.add_space(10.0);
// Statistics (from desktop version)
if !self.admin_state.keys.is_empty() {
ui::render_statistics(ui, &self.admin_state);
ui.add_space(10.0);
}
// Key Management (from desktop version)
if !self.admin_state.keys.is_empty() {
egui::CollapsingHeader::new("🔑 Key Management")
.default_open(true)
.show(ui, |ui| {
// Search and filter controls (from desktop version)
ui::render_search_controls(ui, &mut self.admin_state);
ui.add_space(5.0);
// Bulk actions (from desktop version)
let bulk_action = ui::render_bulk_actions(ui, &mut self.admin_state);
match bulk_action {
BulkAction::DeprecateSelected => self.bulk_deprecate(ctx),
BulkAction::RestoreSelected => self.bulk_restore(ctx),
BulkAction::ClearSelection => {
self.admin_state.clear_selection();
}
BulkAction::None => {}
}
ui.add_space(5.0);
// Keys table (from desktop version)
let key_action = ui::render_keys_table(ui, &mut self.admin_state);
match key_action {
KeyAction::DeprecateKey(server) => self.deprecate_key(server, ctx),
KeyAction::RestoreKey(server) => self.restore_key(server, ctx),
KeyAction::DeleteKey(server) => self.delete_key(server, ctx),
KeyAction::DeprecateServer(server) => self.deprecate_server(server, ctx),
KeyAction::RestoreServer(server) => self.restore_server(server, ctx),
KeyAction::None => {}
}
});
ui.add_space(10.0);
}
// Additional web-specific actions
if matches!(self.connection_status, ConnectionStatus::Connected) && !self.settings.selected_flow.is_empty() {
ui.horizontal(|ui| {
if ui.button("🔍 Scan DNS").clicked() {
self.scan_dns(ctx);
}
if ui.button("🔄 Refresh Keys").clicked() {
self.load_keys(ctx);
}
ui.checkbox(&mut self.settings.auto_refresh, "Auto-refresh");
});
ui.add_space(10.0);
}
// Status bar (from desktop version)
ui.horizontal(|ui| {
ui.label("Status:");
match &self.connection_status {
ConnectionStatus::Connected => {
ui.colored_label(egui::Color32::GREEN, "● Connected");
}
ConnectionStatus::Connecting => {
ui.colored_label(egui::Color32::YELLOW, "● Connecting...");
}
ConnectionStatus::Disconnected => {
ui.colored_label(egui::Color32::GRAY, "● Disconnected");
}
ConnectionStatus::Error(msg) => {
ui.colored_label(egui::Color32::RED, format!("● Error: {}", msg));
}
}
ui.separator();
ui.label(&self.last_operation);
});
});
// Auto-refresh like desktop version
if self.settings.auto_refresh && matches!(self.connection_status, ConnectionStatus::Connected) {
ctx.request_repaint_after(std::time::Duration::from_secs(self.settings.refresh_interval as u64));
}
}
}
impl WebAdminApp {
fn handle_operation_result(&mut self, operation: AdminOperation) {
match operation {
AdminOperation::LoadFlows(result) => {
match result {
Ok(flows) => {
self.flows = flows;
if !self.flows.is_empty() && self.settings.selected_flow.is_empty() {
self.settings.selected_flow = self.flows[0].clone();
}
self.last_operation = format!("Loaded {} flows", self.flows.len());
}
Err(err) => {
self.connection_status = ConnectionStatus::Error(err.clone());
self.last_operation = format!("Failed to load flows: {}", err);
}
}
}
AdminOperation::LoadKeys(result) => {
match result {
Ok(keys) => {
self.admin_state.keys = keys;
self.admin_state.filter_keys();
self.connection_status = ConnectionStatus::Connected;
self.last_operation = format!("Loaded {} keys", self.admin_state.keys.len());
}
Err(err) => {
self.connection_status = ConnectionStatus::Error(err.clone());
self.last_operation = format!("Failed to load keys: {}", err);
}
}
}
AdminOperation::TestConnection(result) => {
match result {
Ok(msg) => {
self.connection_status = ConnectionStatus::Connected;
self.last_operation = msg;
}
Err(err) => {
self.connection_status = ConnectionStatus::Error(err.clone());
self.last_operation = format!("Connection failed: {}", err);
}
}
}
AdminOperation::DeprecateKey(server, result) => {
match result {
Ok(msg) => {
self.last_operation = msg;
self.load_keys_silent();
}
Err(err) => {
self.last_operation = format!("Failed to deprecate key for {}: {}", server, err);
}
}
}
AdminOperation::RestoreKey(server, result) => {
match result {
Ok(msg) => {
self.last_operation = msg;
self.load_keys_silent();
}
Err(err) => {
self.last_operation = format!("Failed to restore key for {}: {}", server, err);
}
}
}
AdminOperation::DeleteKey(server, result) => {
match result {
Ok(msg) => {
self.last_operation = msg;
self.load_keys_silent();
}
Err(err) => {
self.last_operation = format!("Failed to delete key for {}: {}", server, err);
}
}
}
AdminOperation::BulkDeprecate(result) | AdminOperation::BulkRestore(result) => {
match result {
Ok(msg) => {
self.last_operation = msg;
self.admin_state.clear_selection();
self.load_keys_silent();
}
Err(err) => {
self.last_operation = format!("Bulk operation failed: {}", err);
}
}
}
AdminOperation::ScanDns(result) => {
match result {
Ok(results) => {
let resolved = results.iter().filter(|r| r.resolved).count();
let total = results.len();
self.last_operation = format!("DNS scan completed: {}/{} servers resolved", resolved, total);
}
Err(err) => {
self.last_operation = format!("DNS scan failed: {}", err);
}
}
}
AdminOperation::LoadVersion(result) => {
match result {
Ok(version) => {
self.server_version = Some(version.clone());
self.last_operation = format!("Server version: {}", version);
}
Err(err) => {
self.last_operation = format!("Failed to get server version: {}", err);
}
}
}
}
}
// Async operation methods - adapted from desktop version
fn load_flows(&mut self, _ctx: &egui::Context) {
self.last_operation = "Loading flows...".to_string();
let settings = self.settings.clone();
let (tx, rx) = mpsc::channel();
self.operation_receiver = Some(rx);
#[cfg(not(target_arch = "wasm32"))]
{
std::thread::spawn(move || {
let rt = tokio::runtime::Runtime::new().unwrap();
let result = rt.block_on(api::load_flows(&settings));
let _ = tx.send(AdminOperation::LoadFlows(result));
});
}
#[cfg(all(target_arch = "wasm32", feature = "web-gui"))]
{
wasm_bindgen_futures::spawn_local(async move {
let result = api::load_flows(&settings).await;
let _ = tx.send(AdminOperation::LoadFlows(result));
});
}
}
fn test_connection(&mut self, _ctx: &egui::Context) {
self.connection_status = ConnectionStatus::Connecting;
self.last_operation = "Testing connection...".to_string();
let settings = self.settings.clone();
let (tx, rx) = mpsc::channel();
self.operation_receiver = Some(rx);
#[cfg(not(target_arch = "wasm32"))]
{
std::thread::spawn(move || {
let rt = tokio::runtime::Runtime::new().unwrap();
let result = rt.block_on(api::test_connection(&settings));
let _ = tx.send(AdminOperation::TestConnection(result));
});
}
#[cfg(all(target_arch = "wasm32", feature = "web-gui"))]
{
wasm_bindgen_futures::spawn_local(async move {
let result = api::test_connection(&settings).await;
let _ = tx.send(AdminOperation::TestConnection(result));
});
}
}
fn load_keys(&mut self, _ctx: &egui::Context) {
self.admin_state.current_operation = "Loading keys...".to_string();
self.last_operation = "Loading keys...".to_string();
let settings = self.settings.clone();
let (tx, rx) = mpsc::channel();
self.operation_receiver = Some(rx);
#[cfg(not(target_arch = "wasm32"))]
{
std::thread::spawn(move || {
let rt = tokio::runtime::Runtime::new().unwrap();
let result = rt.block_on(api::fetch_keys(&settings));
let _ = tx.send(AdminOperation::LoadKeys(result));
});
}
#[cfg(all(target_arch = "wasm32", feature = "web-gui"))]
{
wasm_bindgen_futures::spawn_local(async move {
let result = api::fetch_keys(&settings).await;
let _ = tx.send(AdminOperation::LoadKeys(result));
});
}
}
fn load_keys_silent(&mut self) {
let settings = self.settings.clone();
let (tx, rx) = mpsc::channel();
self.operation_receiver = Some(rx);
#[cfg(not(target_arch = "wasm32"))]
{
std::thread::spawn(move || {
let rt = tokio::runtime::Runtime::new().unwrap();
let result = rt.block_on(api::fetch_keys(&settings));
let _ = tx.send(AdminOperation::LoadKeys(result));
});
}
#[cfg(all(target_arch = "wasm32", feature = "web-gui"))]
{
wasm_bindgen_futures::spawn_local(async move {
let result = api::fetch_keys(&settings).await;
let _ = tx.send(AdminOperation::LoadKeys(result));
});
}
}
fn deprecate_key(&mut self, server: String, _ctx: &egui::Context) {
self.last_operation = format!("Deprecating key for {}...", server);
let settings = self.settings.clone();
let server_clone = server.clone();
let (tx, rx) = mpsc::channel();
self.operation_receiver = Some(rx);
#[cfg(not(target_arch = "wasm32"))]
{
std::thread::spawn(move || {
let rt = tokio::runtime::Runtime::new().unwrap();
let result = rt.block_on(api::deprecate_key(&settings, &server));
let _ = tx.send(AdminOperation::DeprecateKey(server_clone, result));
});
}
#[cfg(all(target_arch = "wasm32", feature = "web-gui"))]
{
wasm_bindgen_futures::spawn_local(async move {
let result = api::deprecate_key(&settings, &server).await;
let _ = tx.send(AdminOperation::DeprecateKey(server_clone, result));
});
}
}
fn restore_key(&mut self, server: String, _ctx: &egui::Context) {
self.last_operation = format!("Restoring key for {}...", server);
let settings = self.settings.clone();
let server_clone = server.clone();
let (tx, rx) = mpsc::channel();
self.operation_receiver = Some(rx);
#[cfg(not(target_arch = "wasm32"))]
{
std::thread::spawn(move || {
let rt = tokio::runtime::Runtime::new().unwrap();
let result = rt.block_on(api::restore_key(&settings, &server));
let _ = tx.send(AdminOperation::RestoreKey(server_clone, result));
});
}
#[cfg(all(target_arch = "wasm32", feature = "web-gui"))]
{
wasm_bindgen_futures::spawn_local(async move {
let result = api::restore_key(&settings, &server).await;
let _ = tx.send(AdminOperation::RestoreKey(server_clone, result));
});
}
}
fn delete_key(&mut self, server: String, _ctx: &egui::Context) {
self.last_operation = format!("Deleting key for {}...", server);
let settings = self.settings.clone();
let server_clone = server.clone();
let (tx, rx) = mpsc::channel();
self.operation_receiver = Some(rx);
#[cfg(not(target_arch = "wasm32"))]
{
std::thread::spawn(move || {
let rt = tokio::runtime::Runtime::new().unwrap();
let result = rt.block_on(api::delete_key(&settings, &server));
let _ = tx.send(AdminOperation::DeleteKey(server_clone, result));
});
}
#[cfg(all(target_arch = "wasm32", feature = "web-gui"))]
{
wasm_bindgen_futures::spawn_local(async move {
let result = api::delete_key(&settings, &server).await;
let _ = tx.send(AdminOperation::DeleteKey(server_clone, result));
});
}
}
fn deprecate_server(&mut self, server: String, ctx: &egui::Context) {
self.deprecate_key(server, ctx);
}
fn restore_server(&mut self, server: String, ctx: &egui::Context) {
self.restore_key(server, ctx);
}
fn bulk_deprecate(&mut self, _ctx: &egui::Context) {
let servers = self.admin_state.get_selected_servers();
if servers.is_empty() {
return;
}
self.last_operation = format!("Bulk deprecating {} servers...", servers.len());
let settings = self.settings.clone();
let (tx, rx) = mpsc::channel();
self.operation_receiver = Some(rx);
#[cfg(not(target_arch = "wasm32"))]
{
std::thread::spawn(move || {
let rt = tokio::runtime::Runtime::new().unwrap();
let result = rt.block_on(api::bulk_deprecate_servers(&settings, servers));
let _ = tx.send(AdminOperation::BulkDeprecate(result));
});
}
#[cfg(all(target_arch = "wasm32", feature = "web-gui"))]
{
wasm_bindgen_futures::spawn_local(async move {
let result = api::bulk_deprecate_servers(&settings, servers).await;
let _ = tx.send(AdminOperation::BulkDeprecate(result));
});
}
}
fn bulk_restore(&mut self, _ctx: &egui::Context) {
let servers = self.admin_state.get_selected_servers();
if servers.is_empty() {
return;
}
self.last_operation = format!("Bulk restoring {} servers...", servers.len());
let settings = self.settings.clone();
let (tx, rx) = mpsc::channel();
self.operation_receiver = Some(rx);
#[cfg(not(target_arch = "wasm32"))]
{
std::thread::spawn(move || {
let rt = tokio::runtime::Runtime::new().unwrap();
let result = rt.block_on(api::bulk_restore_servers(&settings, servers));
let _ = tx.send(AdminOperation::BulkRestore(result));
});
}
#[cfg(all(target_arch = "wasm32", feature = "web-gui"))]
{
wasm_bindgen_futures::spawn_local(async move {
let result = api::bulk_restore_servers(&settings, servers).await;
let _ = tx.send(AdminOperation::BulkRestore(result));
});
}
}
fn scan_dns(&mut self, _ctx: &egui::Context) {
self.last_operation = "Scanning DNS resolution...".to_string();
let settings = self.settings.clone();
let (tx, rx) = mpsc::channel();
self.operation_receiver = Some(rx);
#[cfg(not(target_arch = "wasm32"))]
{
std::thread::spawn(move || {
let rt = tokio::runtime::Runtime::new().unwrap();
let result = rt.block_on(api::scan_dns_resolution(&settings));
let _ = tx.send(AdminOperation::ScanDns(result));
});
}
#[cfg(all(target_arch = "wasm32", feature = "web-gui"))]
{
wasm_bindgen_futures::spawn_local(async move {
let result = api::scan_dns_resolution(&settings).await;
let _ = tx.send(AdminOperation::ScanDns(result));
});
}
}
fn load_version(&mut self, _ctx: &egui::Context) {
self.last_operation = "Loading server version...".to_string();
let settings = self.settings.clone();
let (tx, rx) = mpsc::channel();
self.operation_receiver = Some(rx);
#[cfg(not(target_arch = "wasm32"))]
{
std::thread::spawn(move || {
let rt = tokio::runtime::Runtime::new().unwrap();
let result = rt.block_on(api::get_version(&settings));
let _ = tx.send(AdminOperation::LoadVersion(result));
});
}
#[cfg(all(target_arch = "wasm32", feature = "web-gui"))]
{
wasm_bindgen_futures::spawn_local(async move {
let result = api::get_version(&settings).await;
let _ = tx.send(AdminOperation::LoadVersion(result));
});
}
}
}

182
src/web_gui/state.rs Normal file
View File

@@ -0,0 +1,182 @@
use serde::{Deserialize, Serialize};
use std::collections::HashMap;
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct AdminSettings {
pub server_url: String,
pub basic_auth: String,
pub selected_flow: String,
pub auto_refresh: bool,
pub refresh_interval: u32,
}
impl Default for AdminSettings {
fn default() -> Self {
Self {
server_url: String::new(),
basic_auth: String::new(),
selected_flow: String::new(),
auto_refresh: false,
refresh_interval: 30,
}
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct AdminState {
pub keys: Vec<SshKey>,
pub filtered_keys: Vec<SshKey>,
pub search_term: String,
pub show_deprecated_only: bool,
pub selected_servers: HashMap<String, bool>,
pub expanded_servers: HashMap<String, bool>,
pub current_operation: String,
}
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: "Ready".to_string(),
}
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct SshKey {
pub server: String,
pub public_key: String,
#[serde(default)]
pub deprecated: bool,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub enum ConnectionStatus {
Disconnected,
Connecting,
Connected,
Error(String),
}
impl PartialEq for ConnectionStatus {
fn eq(&self, other: &Self) -> bool {
std::mem::discriminant(self) == std::mem::discriminant(other)
}
}
#[derive(Debug, Clone)]
pub enum AdminOperation {
LoadKeys(Result<Vec<SshKey>, String>),
LoadFlows(Result<Vec<String>, String>),
DeprecateKey(String, Result<String, String>),
RestoreKey(String, Result<String, String>),
DeleteKey(String, Result<String, String>),
BulkDeprecate(Result<String, String>),
BulkRestore(Result<String, String>),
TestConnection(Result<String, String>),
ScanDns(Result<Vec<DnsResult>, String>),
LoadVersion(Result<String, String>),
}
// Re-export DnsResolutionResult from web.rs for consistency
pub use crate::web::DnsResolutionResult as DnsResult;
impl AdminState {
/// Filter keys based on search term and deprecated filter
pub fn filter_keys(&mut self) {
let mut filtered = self.keys.clone();
// Apply deprecated filter
if self.show_deprecated_only {
filtered.retain(|key| key.deprecated);
}
// Apply search filter
if !self.search_term.is_empty() {
let search_term = self.search_term.to_lowercase();
filtered.retain(|key| {
key.server.to_lowercase().contains(&search_term)
|| key.public_key.to_lowercase().contains(&search_term)
});
}
self.filtered_keys = filtered;
}
/// Get selected servers list
pub fn get_selected_servers(&self) -> Vec<String> {
self.selected_servers
.iter()
.filter_map(|(server, &selected)| {
if selected { Some(server.clone()) } else { None }
})
.collect()
}
/// Clear selection
pub fn clear_selection(&mut self) {
self.selected_servers.clear();
}
/// Get statistics
pub fn get_statistics(&self) -> AdminStatistics {
let total_keys = self.keys.len();
let active_keys = self.keys.iter().filter(|k| !k.deprecated).count();
let deprecated_keys = total_keys - active_keys;
let unique_servers = self.keys
.iter()
.map(|k| &k.server)
.collect::<std::collections::HashSet<_>>()
.len();
AdminStatistics {
total_keys,
active_keys,
deprecated_keys,
unique_servers,
}
}
}
#[derive(Debug, Clone)]
pub struct AdminStatistics {
pub total_keys: usize,
pub active_keys: usize,
pub deprecated_keys: usize,
pub unique_servers: usize,
}
/// Get SSH key type from public key string
pub fn get_key_type(public_key: &str) -> String {
if public_key.starts_with("ssh-rsa") {
"RSA".to_string()
} else if public_key.starts_with("ssh-ed25519") {
"ED25519".to_string()
} else if public_key.starts_with("ecdsa-sha2-nistp") {
"ECDSA".to_string()
} else if public_key.starts_with("ssh-dss") {
"DSA".to_string()
} else {
"Unknown".to_string()
}
}
/// Get preview of SSH key (first 16 characters of key part)
pub fn get_key_preview(public_key: &str) -> String {
let parts: Vec<&str> = public_key.split_whitespace().collect();
if parts.len() >= 2 {
let key_part = parts[1];
if key_part.len() > 16 {
format!("{}...", &key_part[..16])
} else {
key_part.to_string()
}
} else {
format!("{}...", &public_key[..std::cmp::min(16, public_key.len())])
}
}

532
src/web_gui/ui.rs Normal file
View File

@@ -0,0 +1,532 @@
use super::state::{AdminState, AdminSettings, ConnectionStatus, get_key_type, get_key_preview};
use eframe::egui;
use std::collections::BTreeMap;
#[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,
}
/// Render connection settings panel
pub fn render_connection_settings(
ui: &mut egui::Ui,
settings: &mut AdminSettings,
connection_status: &ConnectionStatus,
flows: &[String],
server_version: &Option<String>,
) -> ConnectionAction {
let mut action = ConnectionAction::None;
ui.group(|ui| {
ui.set_min_width(ui.available_width());
ui.vertical(|ui| {
ui.label(egui::RichText::new("⚙️ Connection Settings").size(16.0).strong());
ui.add_space(8.0);
// Server URL
ui.horizontal(|ui| {
ui.label("Server URL:");
ui.text_edit_singleline(&mut settings.server_url);
});
// Basic Auth
ui.horizontal(|ui| {
ui.label("Basic Auth:");
ui.add(egui::TextEdit::singleline(&mut settings.basic_auth).password(true));
});
// Flow selection
ui.horizontal(|ui| {
ui.label("Flow:");
egui::ComboBox::from_id_salt("flow_select")
.selected_text(&settings.selected_flow)
.show_ui(ui, |ui| {
for flow in flows {
ui.selectable_value(&mut settings.selected_flow, flow.clone(), flow);
}
});
});
// Connection status
ui.horizontal(|ui| {
ui.label("Status:");
match connection_status {
ConnectionStatus::Connected => {
ui.colored_label(egui::Color32::GREEN, "● Connected");
}
ConnectionStatus::Connecting => {
ui.colored_label(egui::Color32::YELLOW, "● Connecting...");
}
ConnectionStatus::Disconnected => {
ui.colored_label(egui::Color32::GRAY, "● Disconnected");
}
ConnectionStatus::Error(msg) => {
ui.colored_label(egui::Color32::RED, format!("● Error: {}", msg));
}
}
});
// Server version display
if let Some(version) = server_version {
ui.horizontal(|ui| {
ui.label("Server Version:");
ui.colored_label(egui::Color32::LIGHT_BLUE, version);
});
}
ui.add_space(8.0);
// Action buttons
ui.horizontal(|ui| {
if ui.button("Load Flows").clicked() {
action = ConnectionAction::LoadFlows;
}
if ui.button("Test Connection").clicked() {
action = ConnectionAction::TestConnection;
}
if ui.button("Get Version").clicked() {
action = ConnectionAction::LoadVersion;
}
if !settings.selected_flow.is_empty() && ui.button("Load Keys").clicked() {
action = ConnectionAction::LoadKeys;
}
});
});
});
action
}
/// Render statistics cards
pub fn render_statistics(ui: &mut egui::Ui, admin_state: &AdminState) {
let stats = admin_state.get_statistics();
ui.group(|ui| {
ui.set_min_width(ui.available_width());
ui.vertical(|ui| {
ui.label(egui::RichText::new("📊 Statistics").size(16.0).strong());
ui.add_space(8.0);
ui.horizontal(|ui| {
ui.columns(4, |cols| {
// Total keys
cols[0].vertical_centered_justified(|ui| {
ui.label(egui::RichText::new("📊").size(20.0));
ui.label(
egui::RichText::new(stats.total_keys.to_string())
.size(24.0)
.strong(),
);
ui.label(
egui::RichText::new("Total Keys")
.size(11.0)
.color(egui::Color32::GRAY),
);
});
// Active keys
cols[1].vertical_centered_justified(|ui| {
ui.label(egui::RichText::new("").size(20.0));
ui.label(
egui::RichText::new(stats.active_keys.to_string())
.size(24.0)
.strong()
.color(egui::Color32::LIGHT_GREEN),
);
ui.label(
egui::RichText::new("Active")
.size(11.0)
.color(egui::Color32::GRAY),
);
});
// Deprecated keys
cols[2].vertical_centered_justified(|ui| {
ui.label(egui::RichText::new("").size(20.0));
ui.label(
egui::RichText::new(stats.deprecated_keys.to_string())
.size(24.0)
.strong()
.color(egui::Color32::LIGHT_RED),
);
ui.label(
egui::RichText::new("Deprecated")
.size(11.0)
.color(egui::Color32::GRAY),
);
});
// Servers
cols[3].vertical_centered_justified(|ui| {
ui.label(egui::RichText::new("💻").size(20.0));
ui.label(
egui::RichText::new(stats.unique_servers.to_string())
.size(24.0)
.strong()
.color(egui::Color32::LIGHT_BLUE),
);
ui.label(
egui::RichText::new("Servers")
.size(11.0)
.color(egui::Color32::GRAY),
);
});
});
});
});
});
}
/// Render search and filter controls
pub fn render_search_controls(ui: &mut egui::Ui, admin_state: &mut AdminState) -> bool {
let mut changed = false;
ui.group(|ui| {
ui.set_min_width(ui.available_width());
ui.vertical(|ui| {
ui.label(egui::RichText::new("🔍 Search & Filter").size(16.0).strong());
ui.add_space(8.0);
// Search field
ui.horizontal(|ui| {
ui.label("Search:");
let search_response = ui.add_sized(
[ui.available_width() * 0.6, 20.0],
egui::TextEdit::singleline(&mut admin_state.search_term)
.hint_text("Search servers or keys..."),
);
if search_response.changed() {
changed = true;
}
if !admin_state.search_term.is_empty() {
if ui.small_button("Clear").clicked() {
admin_state.search_term.clear();
changed = true;
}
}
});
ui.add_space(5.0);
// Filter controls
ui.horizontal(|ui| {
ui.label("Filter:");
let show_deprecated = admin_state.show_deprecated_only;
if ui.selectable_label(!show_deprecated, "✅ Active").clicked() {
admin_state.show_deprecated_only = false;
changed = true;
}
if ui.selectable_label(show_deprecated, "❗ Deprecated").clicked() {
admin_state.show_deprecated_only = true;
changed = true;
}
});
});
});
if changed {
admin_state.filter_keys();
}
changed
}
/// Render bulk actions controls
pub fn render_bulk_actions(ui: &mut egui::Ui, admin_state: &mut AdminState) -> BulkAction {
let selected_count = admin_state
.selected_servers
.values()
.filter(|&&v| v)
.count();
if selected_count == 0 {
return BulkAction::None;
}
let mut action = BulkAction::None;
ui.group(|ui| {
ui.set_min_width(ui.available_width());
ui.vertical(|ui| {
ui.horizontal(|ui| {
ui.label(egui::RichText::new("📋").size(14.0));
ui.label(
egui::RichText::new(format!("Selected {} servers", selected_count))
.size(14.0)
.strong()
.color(egui::Color32::LIGHT_BLUE),
);
});
ui.add_space(5.0);
ui.horizontal(|ui| {
if ui.button("❗ Deprecate Selected").clicked() {
action = BulkAction::DeprecateSelected;
}
if ui.button("✅ Restore Selected").clicked() {
action = BulkAction::RestoreSelected;
}
if ui.button("Clear Selection").clicked() {
action = BulkAction::ClearSelection;
}
});
});
});
action
}
/// Render keys table grouped by servers
pub fn render_keys_table(ui: &mut egui::Ui, admin_state: &mut AdminState) -> KeyAction {
if admin_state.filtered_keys.is_empty() {
render_empty_state(ui, admin_state);
return KeyAction::None;
}
let mut action = KeyAction::None;
// Group keys by server
let mut servers: BTreeMap<String, Vec<&crate::web_gui::state::SshKey>> = BTreeMap::new();
for key in &admin_state.filtered_keys {
servers
.entry(key.server.clone())
.or_insert_with(Vec::new)
.push(key);
}
// Render each server group
egui::ScrollArea::vertical().show(ui, |ui| {
for (server_name, server_keys) in servers {
let is_expanded = admin_state
.expanded_servers
.get(&server_name)
.copied()
.unwrap_or(false);
let active_count = server_keys.iter().filter(|k| !k.deprecated).count();
let deprecated_count = server_keys.len() - active_count;
// Server header
ui.group(|ui| {
ui.horizontal(|ui| {
// Server selection checkbox
let mut selected = admin_state
.selected_servers
.get(&server_name)
.copied()
.unwrap_or(false);
if ui.checkbox(&mut selected, "").changed() {
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() {
admin_state
.expanded_servers
.insert(server_name.clone(), !is_expanded);
}
// Server icon and name
ui.label(egui::RichText::new("💻").size(16.0));
ui.label(
egui::RichText::new(&server_name)
.size(15.0)
.strong(),
);
// Keys count badge
ui.label(format!("({} keys)", server_keys.len()));
// Deprecated count badge
if deprecated_count > 0 {
ui.colored_label(
egui::Color32::RED,
format!("{} deprecated", deprecated_count)
);
}
ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| {
// Server action buttons
if deprecated_count > 0 {
if ui.small_button("✅ Restore").clicked() {
action = KeyAction::RestoreServer(server_name.clone());
}
}
if active_count > 0 {
if ui.small_button("❗ Deprecate").clicked() {
action = KeyAction::DeprecateServer(server_name.clone());
}
}
});
});
});
// Expanded key details
if is_expanded {
ui.indent(&server_name, |ui| {
for key in &server_keys {
if let Some(key_action) = render_key_item(ui, key, &server_name) {
action = key_action;
}
}
});
}
ui.add_space(5.0);
}
});
action
}
/// Render empty state when no keys are available
fn render_empty_state(ui: &mut egui::Ui, admin_state: &AdminState) {
ui.vertical_centered(|ui| {
ui.add_space(60.0);
if admin_state.keys.is_empty() {
ui.label(
egui::RichText::new("🔑")
.size(48.0)
.color(egui::Color32::GRAY),
);
ui.label(
egui::RichText::new("No SSH keys available")
.size(18.0)
.color(egui::Color32::GRAY),
);
ui.label(
egui::RichText::new("Keys will appear here once loaded from the server")
.size(14.0)
.color(egui::Color32::DARK_GRAY),
);
} else if !admin_state.search_term.is_empty() {
ui.label(
egui::RichText::new("🔍")
.size(48.0)
.color(egui::Color32::GRAY),
);
ui.label(
egui::RichText::new("No results found")
.size(18.0)
.color(egui::Color32::GRAY),
);
ui.label(
egui::RichText::new(format!(
"Try adjusting your search: '{}'",
admin_state.search_term
))
.size(14.0)
.color(egui::Color32::DARK_GRAY),
);
} else {
ui.label(
egui::RichText::new("")
.size(48.0)
.color(egui::Color32::GRAY),
);
ui.label(
egui::RichText::new("No keys match current filters")
.size(18.0)
.color(egui::Color32::GRAY),
);
ui.label(
egui::RichText::new("Try adjusting your search or filter settings")
.size(14.0)
.color(egui::Color32::DARK_GRAY),
);
}
});
}
/// Render individual key item
fn render_key_item(
ui: &mut egui::Ui,
key: &crate::web_gui::state::SshKey,
server_name: &str,
) -> Option<KeyAction> {
let mut action = None;
ui.group(|ui| {
ui.horizontal(|ui| {
// Key type badge
let key_type = get_key_type(&key.public_key);
let badge_color = match key_type.as_str() {
"RSA" => egui::Color32::from_rgb(52, 144, 220),
"ED25519" => egui::Color32::from_rgb(46, 204, 113),
"ECDSA" => egui::Color32::from_rgb(241, 196, 15),
"DSA" => egui::Color32::from_rgb(230, 126, 34),
_ => egui::Color32::GRAY,
};
ui.colored_label(badge_color, &key_type);
ui.add_space(5.0);
// Status badge
if key.deprecated {
ui.colored_label(egui::Color32::RED, "❗ DEPRECATED");
} else {
ui.colored_label(egui::Color32::GREEN, "✅ ACTIVE");
}
ui.add_space(5.0);
// Key preview
ui.monospace(get_key_preview(&key.public_key));
ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| {
// Key action buttons
if key.deprecated {
if ui.small_button("Restore").clicked() {
action = Some(KeyAction::RestoreKey(server_name.to_string()));
}
if ui.small_button("Delete").clicked() {
action = Some(KeyAction::DeleteKey(server_name.to_string()));
}
} else {
if ui.small_button("Deprecate").clicked() {
action = Some(KeyAction::DeprecateKey(server_name.to_string()));
}
}
if ui.small_button("Copy").clicked() {
ui.output_mut(|o| o.copied_text = key.public_key.clone());
}
});
});
});
action
}
#[derive(Debug, Clone)]
pub enum ConnectionAction {
None,
LoadFlows,
TestConnection,
LoadKeys,
LoadVersion,
}

43
src/web_gui/wasm.rs Normal file
View File

@@ -0,0 +1,43 @@
#[cfg(all(target_arch = "wasm32", feature = "web-gui"))]
use wasm_bindgen::prelude::*;
#[cfg(all(target_arch = "wasm32", feature = "web-gui"))]
use super::app::WebAdminApp;
/// WASM entry point for the web admin application
#[cfg(all(target_arch = "wasm32", feature = "web-gui"))]
#[wasm_bindgen]
pub fn start_web_admin(canvas_id: &str) -> Result<(), JsValue> {
// Setup console logging for WASM
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();
let result = eframe::WebRunner::new()
.start(
&canvas_id,
web_options,
Box::new(|_cc| Ok(Box::new(app))),
)
.await;
match result {
Ok(_) => web_sys::console::log_1(&"eframe started successfully".into()),
Err(e) => web_sys::console::error_1(&format!("Failed to start eframe: {:?}", e).into()),
}
});
Ok(())
}
/// Initialize the WASM module
#[cfg(all(target_arch = "wasm32", feature = "web-gui"))]
#[wasm_bindgen(start)]
pub fn wasm_main() {
console_error_panic_hook::set_once();
}

131
src/web_gui/wasm_api.rs Normal file
View File

@@ -0,0 +1,131 @@
#[cfg(all(target_arch = "wasm32", feature = "web-gui"))]
use super::state::{SshKey, DnsResult, AdminSettings};
#[cfg(all(target_arch = "wasm32", feature = "web-gui"))]
use wasm_bindgen::prelude::*;
#[cfg(all(target_arch = "wasm32", feature = "web-gui"))]
use wasm_bindgen_futures::JsFuture;
#[cfg(all(target_arch = "wasm32", feature = "web-gui"))]
use web_sys::{Request, RequestInit, RequestMode, Response};
/// Simplified API for WASM - uses browser fetch API
#[cfg(all(target_arch = "wasm32", feature = "web-gui"))]
pub async fn test_connection(settings: &AdminSettings) -> Result<String, String> {
let url = format!("{}/{}/keys", settings.server_url.trim_end_matches('/'), settings.selected_flow);
let response = fetch_json(&url).await?;
let keys: Result<Vec<SshKey>, _> = serde_json::from_str(&response);
match keys {
Ok(keys) => Ok(format!("Connection successful! Found {} SSH keys from flow '{}'", keys.len(), settings.selected_flow)),
Err(e) => Err(format!("Failed to parse response: {}", e)),
}
}
#[cfg(all(target_arch = "wasm32", feature = "web-gui"))]
pub async fn load_flows(settings: &AdminSettings) -> Result<Vec<String>, String> {
let url = format!("{}/api/flows", settings.server_url.trim_end_matches('/'));
let response = fetch_json(&url).await?;
let flows: Result<Vec<String>, _> = serde_json::from_str(&response);
flows.map_err(|e| format!("Failed to parse flows: {}", e))
}
#[cfg(all(target_arch = "wasm32", feature = "web-gui"))]
pub async fn fetch_keys(settings: &AdminSettings) -> Result<Vec<SshKey>, String> {
let url = format!("{}/{}/keys", settings.server_url.trim_end_matches('/'), settings.selected_flow);
let response = fetch_json(&url).await?;
let keys: Result<Vec<SshKey>, _> = serde_json::from_str(&response);
keys.map_err(|e| format!("Failed to parse keys: {}", e))
}
#[cfg(all(target_arch = "wasm32", feature = "web-gui"))]
pub async fn get_version(settings: &AdminSettings) -> Result<String, String> {
let url = format!("{}/api/version", settings.server_url.trim_end_matches('/'));
let response = fetch_json(&url).await?;
let version_response: Result<serde_json::Value, _> = serde_json::from_str(&response);
match version_response {
Ok(data) => {
let version = data.get("version")
.and_then(|v| v.as_str())
.unwrap_or("unknown")
.to_string();
Ok(version)
}
Err(e) => Err(format!("Failed to parse version: {}", e)),
}
}
#[cfg(all(target_arch = "wasm32", feature = "web-gui"))]
pub async fn deprecate_key(_settings: &AdminSettings, server: &str) -> Result<String, String> {
Ok(format!("WASM: Would deprecate key for {}", server))
}
#[cfg(all(target_arch = "wasm32", feature = "web-gui"))]
pub async fn restore_key(_settings: &AdminSettings, server: &str) -> Result<String, String> {
Ok(format!("WASM: Would restore key for {}", server))
}
#[cfg(all(target_arch = "wasm32", feature = "web-gui"))]
pub async fn delete_key(_settings: &AdminSettings, server: &str) -> Result<String, String> {
Ok(format!("WASM: Would delete key for {}", server))
}
#[cfg(all(target_arch = "wasm32", feature = "web-gui"))]
pub async fn bulk_deprecate_servers(_settings: &AdminSettings, servers: Vec<String>) -> Result<String, String> {
Ok(format!("WASM: Would bulk deprecate {} servers", servers.len()))
}
#[cfg(all(target_arch = "wasm32", feature = "web-gui"))]
pub async fn bulk_restore_servers(_settings: &AdminSettings, servers: Vec<String>) -> Result<String, String> {
Ok(format!("WASM: Would bulk restore {} servers", servers.len()))
}
#[cfg(all(target_arch = "wasm32", feature = "web-gui"))]
pub async fn scan_dns_resolution(_settings: &AdminSettings) -> Result<Vec<DnsResult>, String> {
Ok(vec![
DnsResult {
server: "demo-server".to_string(),
resolved: true,
error: None,
}
])
}
/// Helper function to make HTTP requests using browser's fetch API
#[cfg(all(target_arch = "wasm32", feature = "web-gui"))]
async fn fetch_json(url: &str) -> Result<String, String> {
let window = web_sys::window().ok_or("No window object")?;
let mut opts = RequestInit::new();
opts.method("GET");
opts.mode(RequestMode::Cors);
let request = Request::new_with_str_and_init(url, &opts)
.map_err(|e| format!("Failed to create request: {:?}", e))?;
let resp_value = JsFuture::from(window.fetch_with_request(&request))
.await
.map_err(|e| format!("Request failed: {:?}", e))?;
let resp: Response = resp_value.dyn_into()
.map_err(|e| format!("Failed to cast response: {:?}", e))?;
if !resp.ok() {
return Err(format!("HTTP error: {} {}", resp.status(), resp.status_text()));
}
let text_promise = resp.text()
.map_err(|e| format!("Failed to get text promise: {:?}", e))?;
let text_value = JsFuture::from(text_promise)
.await
.map_err(|e| format!("Failed to get text: {:?}", e))?;
text_value.as_string()
.ok_or("Response is not a string".to_string())
}