mirror of
https://github.com/house-of-vanity/khm.git
synced 2025-08-21 14:27:14 +00:00
web wasm ui
This commit is contained in:
2
.gitignore
vendored
2
.gitignore
vendored
@@ -1,3 +1,5 @@
|
||||
/target
|
||||
*.swp
|
||||
*.swo
|
||||
.claude/
|
||||
khm-wasm/target
|
65
Cargo.lock
generated
65
Cargo.lock
generated
@@ -1002,6 +1002,7 @@ dependencies = [
|
||||
"iana-time-zone",
|
||||
"js-sys",
|
||||
"num-traits",
|
||||
"serde",
|
||||
"wasm-bindgen",
|
||||
"windows-targets 0.52.6",
|
||||
]
|
||||
@@ -1121,6 +1122,16 @@ dependencies = [
|
||||
"crossbeam-utils",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "console_error_panic_hook"
|
||||
version = "0.1.7"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "a06aeb73f470f66dcdbf7223caeebb85984942f22f1adb2a088cf9668146bbbc"
|
||||
dependencies = [
|
||||
"cfg-if",
|
||||
"wasm-bindgen",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "convert_case"
|
||||
version = "0.4.0"
|
||||
@@ -1935,8 +1946,10 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "c4567c8db10ae91089c99af84c68c38da3ec2f087c3f82960bcdbf3656b6f4d7"
|
||||
dependencies = [
|
||||
"cfg-if",
|
||||
"js-sys",
|
||||
"libc",
|
||||
"wasi",
|
||||
"wasm-bindgen",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -2678,11 +2691,13 @@ dependencies = [
|
||||
"base64 0.21.7",
|
||||
"chrono",
|
||||
"clap",
|
||||
"console_error_panic_hook",
|
||||
"dirs 5.0.1",
|
||||
"eframe",
|
||||
"egui",
|
||||
"env_logger",
|
||||
"futures",
|
||||
"getrandom",
|
||||
"glib",
|
||||
"gtk",
|
||||
"hostname",
|
||||
@@ -2698,9 +2713,13 @@ dependencies = [
|
||||
"tokio",
|
||||
"tokio-postgres",
|
||||
"tokio-util",
|
||||
"tracing-wasm",
|
||||
"tray-icon",
|
||||
"trust-dns-resolver",
|
||||
"urlencoding",
|
||||
"wasm-bindgen",
|
||||
"wasm-bindgen-futures",
|
||||
"web-sys",
|
||||
"winit",
|
||||
]
|
||||
|
||||
@@ -2747,6 +2766,12 @@ version = "0.3.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "d4345964bb142484797b161f473a503a434de77149dd8c7427788c6e13379388"
|
||||
|
||||
[[package]]
|
||||
name = "lazy_static"
|
||||
version = "1.5.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "bbd2bcb4c963f2ddae06a2efc7e9f3591312473c50c6685e1f298068316e66fe"
|
||||
|
||||
[[package]]
|
||||
name = "libappindicator"
|
||||
version = "0.9.0"
|
||||
@@ -4352,6 +4377,15 @@ dependencies = [
|
||||
"digest",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "sharded-slab"
|
||||
version = "0.1.7"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "f40ca3c46823713e0d4209592e8d6e826aa57e928f09752619fc696c499637f6"
|
||||
dependencies = [
|
||||
"lazy_static",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "signal-hook-registry"
|
||||
version = "1.4.2"
|
||||
@@ -4631,6 +4665,15 @@ dependencies = [
|
||||
"syn 2.0.87",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "thread_local"
|
||||
version = "1.1.9"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "f60246a4944f24f6e018aa17cdeffb7818b76356965d03b07d6a9886e8962185"
|
||||
dependencies = [
|
||||
"cfg-if",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "time"
|
||||
version = "0.3.36"
|
||||
@@ -4907,6 +4950,28 @@ dependencies = [
|
||||
"once_cell",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "tracing-subscriber"
|
||||
version = "0.3.19"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "e8189decb5ac0fa7bc8b96b7cb9b2701d60d48805aca84a238004d665fcc4008"
|
||||
dependencies = [
|
||||
"sharded-slab",
|
||||
"thread_local",
|
||||
"tracing-core",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "tracing-wasm"
|
||||
version = "0.2.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "4575c663a174420fa2d78f4108ff68f65bf2fbb7dd89f33749b6e826b3626e07"
|
||||
dependencies = [
|
||||
"tracing",
|
||||
"tracing-subscriber",
|
||||
"wasm-bindgen",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "tray-icon"
|
||||
version = "0.21.0"
|
||||
|
47
Cargo.toml
47
Cargo.toml
@@ -10,6 +10,9 @@ license = "WTFPL"
|
||||
keywords = ["ssh", "known-hosts", "security", "system-admin", "automation"]
|
||||
categories = ["command-line-utilities", "network-programming"]
|
||||
|
||||
[lib]
|
||||
crate-type = ["cdylib", "rlib"]
|
||||
|
||||
[[bin]]
|
||||
name = "khm"
|
||||
path = "src/bin/cli.rs"
|
||||
@@ -20,28 +23,34 @@ path = "src/bin/desktop.rs"
|
||||
required-features = ["gui"]
|
||||
|
||||
[dependencies]
|
||||
actix-web = "4"
|
||||
actix-web = { version = "4", optional = true }
|
||||
serde = { version = "1.0", features = ["derive"] }
|
||||
serde_json = "1.0"
|
||||
log = "0.4"
|
||||
regex = "1.10.5"
|
||||
base64 = "0.21"
|
||||
tokio = { version = "1", features = ["full", "sync"] }
|
||||
tokio-postgres = { version = "0.7", features = ["with-chrono-0_4"] }
|
||||
tokio-util = { version = "0.7", features = ["codec"] }
|
||||
clap = { version = "4", features = ["derive"] }
|
||||
chrono = "0.4.38"
|
||||
reqwest = { version = "0.12", features = ["json"] }
|
||||
trust-dns-resolver = "0.23"
|
||||
futures = "0.3"
|
||||
hostname = "0.3"
|
||||
rust-embed = "8.0"
|
||||
regex = { version = "1.10.5", optional = true }
|
||||
base64 = { version = "0.21", optional = true }
|
||||
tokio = { version = "1", features = ["full", "sync"], optional = true }
|
||||
tokio-postgres = { version = "0.7", features = ["with-chrono-0_4"], optional = true }
|
||||
tokio-util = { version = "0.7", features = ["codec"], optional = true }
|
||||
clap = { version = "4", features = ["derive"], optional = true }
|
||||
chrono = { version = "0.4.38", features = ["serde"], optional = true }
|
||||
reqwest = { version = "0.12", features = ["json"], optional = true }
|
||||
trust-dns-resolver = { version = "0.23", optional = true }
|
||||
futures = { version = "0.3", optional = true }
|
||||
hostname = { version = "0.3", optional = true }
|
||||
rust-embed = { version = "8.0", optional = true }
|
||||
tray-icon = { version = "0.21", optional = true }
|
||||
notify = { version = "6.1", optional = true }
|
||||
notify-debouncer-mini = { version = "0.4", optional = true }
|
||||
dirs = "5.0"
|
||||
eframe = { version = "0.29", optional = true }
|
||||
egui = { version = "0.29", optional = true }
|
||||
wasm-bindgen-futures = { version = "0.4", optional = true }
|
||||
web-sys = { version = "0.3", optional = true }
|
||||
wasm-bindgen = { version = "0.2", optional = true }
|
||||
console_error_panic_hook = { version = "0.1", optional = true }
|
||||
tracing-wasm = { version = "0.2", optional = true }
|
||||
getrandom = { version = "0.2", features = ["js"], optional = true }
|
||||
winit = { version = "0.30", optional = true }
|
||||
env_logger = "0.11"
|
||||
urlencoding = "2.1"
|
||||
@@ -53,13 +62,19 @@ glib = { version = "0.18", optional = true }
|
||||
|
||||
[features]
|
||||
default = ["server", "web", "gui"]
|
||||
cli = ["server", "web"]
|
||||
cli = ["server", "web", "web-gui"]
|
||||
desktop = ["gui"]
|
||||
gui = ["tray-icon", "eframe", "egui", "winit", "notify", "notify-debouncer-mini", "gtk", "glib"]
|
||||
server = []
|
||||
web = []
|
||||
web-gui = ["egui", "eframe", "wasm-bindgen-futures", "web-sys", "wasm-bindgen", "console_error_panic_hook", "tracing-wasm", "getrandom"]
|
||||
web-gui-wasm = ["web-gui"]
|
||||
server = ["actix-web", "tokio", "tokio-postgres", "tokio-util", "clap", "chrono", "regex", "base64", "futures", "hostname", "rust-embed", "trust-dns-resolver", "reqwest"]
|
||||
web = ["server"]
|
||||
|
||||
# Target-specific dependencies for cross-compilation
|
||||
[target.aarch64-unknown-linux-gnu.dependencies]
|
||||
openssl = { version = "0.10", features = ["vendored"] }
|
||||
|
||||
# WASM-specific dependencies
|
||||
[target.'cfg(target_arch = "wasm32")'.dependencies]
|
||||
getrandom = { version = "0.2", features = ["js"] }
|
||||
|
||||
|
2715
khm-wasm/Cargo.lock
generated
Normal file
2715
khm-wasm/Cargo.lock
generated
Normal file
File diff suppressed because it is too large
Load Diff
24
khm-wasm/Cargo.toml
Normal file
24
khm-wasm/Cargo.toml
Normal file
@@ -0,0 +1,24 @@
|
||||
[package]
|
||||
name = "khm-wasm"
|
||||
version = "0.1.0"
|
||||
edition = "2021"
|
||||
|
||||
[lib]
|
||||
crate-type = ["cdylib"]
|
||||
|
||||
[dependencies]
|
||||
serde = { version = "1.0", features = ["derive"] }
|
||||
serde_json = "1.0"
|
||||
log = "0.4"
|
||||
eframe = { version = "0.29", default-features = false, features = ["glow"] }
|
||||
egui = "0.29"
|
||||
wasm-bindgen = "0.2"
|
||||
wasm-bindgen-futures = "0.4"
|
||||
web-sys = { version = "0.3", features = ["console", "Headers", "Request", "RequestInit", "RequestMode", "Response", "Window"] }
|
||||
console_error_panic_hook = "0.1"
|
||||
tracing-wasm = "0.2"
|
||||
getrandom = { version = "0.2", features = ["js"] }
|
||||
serde-wasm-bindgen = "0.6"
|
||||
|
||||
[features]
|
||||
default = []
|
1482
khm-wasm/src/lib.rs
Normal file
1482
khm-wasm/src/lib.rs
Normal file
File diff suppressed because it is too large
Load Diff
@@ -4,6 +4,8 @@ pub mod gui;
|
||||
pub mod server;
|
||||
#[cfg(feature = "web")]
|
||||
pub mod web;
|
||||
#[cfg(feature = "web-gui")]
|
||||
pub mod web_gui;
|
||||
|
||||
use clap::Parser;
|
||||
|
||||
@@ -110,4 +112,8 @@ pub struct Args {
|
||||
/// Basic auth string for client mode. Format: user:pass
|
||||
#[arg(long, default_value = "", help = "Client mode: Basic Auth credentials")]
|
||||
pub basic_auth: String,
|
||||
}
|
||||
}
|
||||
|
||||
// Re-export WASM functions for wasm-pack
|
||||
#[cfg(all(target_arch = "wasm32", feature = "web-gui"))]
|
||||
pub use web_gui::wasm::*;
|
@@ -356,4 +356,13 @@ fn configure_web_routes(cfg: &mut web::ServiceConfig) {
|
||||
"/static/{filename:.*}",
|
||||
web::get().to(crate::web::serve_static_file),
|
||||
);
|
||||
|
||||
// Web GUI routes
|
||||
cfg.route("/gui", web::get().to(crate::web_gui::serve_egui_interface))
|
||||
.route("/gui/", web::get().to(crate::web_gui::serve_egui_interface))
|
||||
.route("/gui/config", web::get().to(crate::web_gui::get_gui_config))
|
||||
.route("/gui/state", web::get().to(crate::web_gui::get_gui_state))
|
||||
.route("/gui/settings", web::post().to(crate::web_gui::update_gui_settings))
|
||||
.route("/wasm/{filename:.*}", web::get().to(crate::web_gui::serve_wasm_file));
|
||||
}
|
||||
|
||||
|
265
src/wasm_lib.rs
Normal file
265
src/wasm_lib.rs
Normal file
@@ -0,0 +1,265 @@
|
||||
// Минимальная WASM библиотека только для egui интерфейса
|
||||
use wasm_bindgen::prelude::*;
|
||||
use serde::{Deserialize, Serialize};
|
||||
use std::collections::HashMap;
|
||||
|
||||
// Основные структуры данных (копии из main lib)
|
||||
#[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 struct DnsResult {
|
||||
pub server: String,
|
||||
pub resolved: bool,
|
||||
pub error: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
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 {
|
||||
let server_url = {
|
||||
#[cfg(target_arch = "wasm32")]
|
||||
{
|
||||
web_sys::window()
|
||||
.and_then(|w| w.location().origin().ok())
|
||||
.unwrap_or_else(|| "http://localhost:8080".to_string())
|
||||
}
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
{
|
||||
"http://localhost:8080".to_string()
|
||||
}
|
||||
};
|
||||
|
||||
Self {
|
||||
server_url,
|
||||
basic_auth: String::new(),
|
||||
selected_flow: String::new(),
|
||||
auto_refresh: false,
|
||||
refresh_interval: 30,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
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: String::new(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl AdminState {
|
||||
pub fn filter_keys(&mut self) {
|
||||
self.filtered_keys = self.keys.iter()
|
||||
.filter(|key| {
|
||||
if self.show_deprecated_only && !key.deprecated {
|
||||
return false;
|
||||
}
|
||||
if !self.show_deprecated_only && key.deprecated {
|
||||
return false;
|
||||
}
|
||||
if !self.search_term.is_empty() {
|
||||
let search_lower = self.search_term.to_lowercase();
|
||||
return key.server.to_lowercase().contains(&search_lower) ||
|
||||
key.public_key.to_lowercase().contains(&search_lower);
|
||||
}
|
||||
true
|
||||
})
|
||||
.cloned()
|
||||
.collect();
|
||||
}
|
||||
}
|
||||
|
||||
// Простое egui приложение
|
||||
pub struct WebAdminApp {
|
||||
settings: AdminSettings,
|
||||
admin_state: AdminState,
|
||||
status_message: String,
|
||||
}
|
||||
|
||||
impl Default for WebAdminApp {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
settings: AdminSettings::default(),
|
||||
admin_state: AdminState::default(),
|
||||
status_message: "Ready".to_string(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl eframe::App for WebAdminApp {
|
||||
fn update(&mut self, ctx: &egui::Context, _frame: &mut eframe::Frame) {
|
||||
egui::CentralPanel::default().show(ctx, |ui| {
|
||||
ui.heading("🔑 KHM Web Admin Panel");
|
||||
ui.separator();
|
||||
|
||||
// Connection Settings
|
||||
egui::CollapsingHeader::new("⚙️ Connection Settings")
|
||||
.default_open(true)
|
||||
.show(ui, |ui| {
|
||||
ui.horizontal(|ui| {
|
||||
ui.label("Server URL:");
|
||||
ui.text_edit_singleline(&mut self.settings.server_url);
|
||||
});
|
||||
|
||||
ui.horizontal(|ui| {
|
||||
ui.label("Basic Auth:");
|
||||
ui.add(egui::TextEdit::singleline(&mut self.settings.basic_auth).password(true));
|
||||
});
|
||||
|
||||
ui.horizontal(|ui| {
|
||||
ui.label("Flow:");
|
||||
ui.text_edit_singleline(&mut self.settings.selected_flow);
|
||||
});
|
||||
|
||||
ui.horizontal(|ui| {
|
||||
if ui.button("Test Connection").clicked() {
|
||||
self.status_message = "Testing connection... (WASM demo mode)".to_string();
|
||||
}
|
||||
if ui.button("Load Keys").clicked() {
|
||||
// Add demo data
|
||||
self.admin_state.keys = vec![
|
||||
SshKey {
|
||||
server: "demo-server-1".to_string(),
|
||||
public_key: "ssh-rsa AAAAB3NzaC1yc2EAAAADAQABAAABgQC demo key 1".to_string(),
|
||||
deprecated: false,
|
||||
},
|
||||
SshKey {
|
||||
server: "demo-server-2".to_string(),
|
||||
public_key: "ssh-ed25519 AAAAC3NzaC1lZDI1NTE5 demo key 2".to_string(),
|
||||
deprecated: true,
|
||||
},
|
||||
];
|
||||
self.admin_state.filter_keys();
|
||||
self.status_message = format!("Loaded {} demo keys", self.admin_state.keys.len());
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
ui.add_space(10.0);
|
||||
|
||||
// Keys display
|
||||
if !self.admin_state.filtered_keys.is_empty() {
|
||||
egui::CollapsingHeader::new("🔑 SSH Keys")
|
||||
.default_open(true)
|
||||
.show(ui, |ui| {
|
||||
ui.horizontal(|ui| {
|
||||
ui.label("Search:");
|
||||
let search_response = ui.text_edit_singleline(&mut self.admin_state.search_term);
|
||||
if search_response.changed() {
|
||||
self.admin_state.filter_keys();
|
||||
}
|
||||
});
|
||||
|
||||
ui.horizontal(|ui| {
|
||||
if ui.selectable_label(!self.admin_state.show_deprecated_only, "✅ Active").clicked() {
|
||||
self.admin_state.show_deprecated_only = false;
|
||||
self.admin_state.filter_keys();
|
||||
}
|
||||
if ui.selectable_label(self.admin_state.show_deprecated_only, "❗ Deprecated").clicked() {
|
||||
self.admin_state.show_deprecated_only = true;
|
||||
self.admin_state.filter_keys();
|
||||
}
|
||||
});
|
||||
|
||||
ui.separator();
|
||||
|
||||
for key in &self.admin_state.filtered_keys {
|
||||
ui.group(|ui| {
|
||||
ui.horizontal(|ui| {
|
||||
if key.deprecated {
|
||||
ui.colored_label(egui::Color32::RED, "❗ DEPRECATED");
|
||||
} else {
|
||||
ui.colored_label(egui::Color32::GREEN, "✅ ACTIVE");
|
||||
}
|
||||
|
||||
ui.label(&key.server);
|
||||
ui.monospace(&key.public_key[..50.min(key.public_key.len())]);
|
||||
|
||||
if ui.small_button("Copy").clicked() {
|
||||
ui.output_mut(|o| o.copied_text = key.public_key.clone());
|
||||
}
|
||||
});
|
||||
});
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
ui.add_space(10.0);
|
||||
|
||||
// Status
|
||||
ui.horizontal(|ui| {
|
||||
ui.label("Status:");
|
||||
ui.colored_label(egui::Color32::LIGHT_BLUE, &self.status_message);
|
||||
});
|
||||
|
||||
// Info
|
||||
ui.separator();
|
||||
ui.label("ℹ️ This is a demo WASM version. For full functionality, the server API integration is needed.");
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
/// 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();
|
||||
|
||||
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(())
|
||||
}
|
||||
|
||||
#[wasm_bindgen(start)]
|
||||
pub fn wasm_main() {
|
||||
console_error_panic_hook::set_once();
|
||||
}
|
@@ -17,7 +17,7 @@ use crate::server::Flows;
|
||||
#[folder = "static/"]
|
||||
struct StaticAssets;
|
||||
|
||||
#[derive(Serialize, Deserialize, Debug)]
|
||||
#[derive(Serialize, Deserialize, Debug, Clone)]
|
||||
pub struct DnsResolutionResult {
|
||||
pub server: String,
|
||||
pub resolved: bool,
|
||||
|
288
src/web_gui.rs
Normal file
288
src/web_gui.rs
Normal file
@@ -0,0 +1,288 @@
|
||||
use actix_web::{HttpResponse, Result, web};
|
||||
use serde_json::json;
|
||||
use log::info;
|
||||
|
||||
#[cfg(feature = "web-gui")]
|
||||
pub mod app;
|
||||
#[cfg(feature = "web-gui")]
|
||||
pub mod state;
|
||||
#[cfg(feature = "web-gui")]
|
||||
pub mod ui;
|
||||
#[cfg(all(feature = "web-gui", not(target_arch = "wasm32")))]
|
||||
pub mod api;
|
||||
#[cfg(all(target_arch = "wasm32", feature = "web-gui"))]
|
||||
pub mod wasm_api;
|
||||
#[cfg(all(target_arch = "wasm32", feature = "web-gui"))]
|
||||
pub mod wasm;
|
||||
|
||||
|
||||
/// Serve the egui web GUI interface
|
||||
pub async fn serve_egui_interface() -> Result<HttpResponse> {
|
||||
#[cfg(feature = "web-gui")]
|
||||
{
|
||||
let html = r#"
|
||||
<!DOCTYPE html>
|
||||
<html>
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<title>KHM Admin Panel</title>
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<style>
|
||||
html, body {
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
background: #2b2b2b;
|
||||
font-family: system-ui, sans-serif;
|
||||
}
|
||||
|
||||
canvas {
|
||||
width: 100vw;
|
||||
height: 100vh;
|
||||
display: block;
|
||||
}
|
||||
|
||||
#loading {
|
||||
position: fixed;
|
||||
top: 50%;
|
||||
left: 50%;
|
||||
transform: translate(-50%, -50%);
|
||||
color: white;
|
||||
font-size: 18px;
|
||||
z-index: 1000;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.spinner {
|
||||
border: 3px solid rgba(255,255,255,0.3);
|
||||
border-radius: 50%;
|
||||
border-top: 3px solid #667eea;
|
||||
width: 40px;
|
||||
height: 40px;
|
||||
animation: spin 1s linear infinite;
|
||||
margin: 0 auto 20px;
|
||||
}
|
||||
|
||||
@keyframes spin {
|
||||
0% { transform: rotate(0deg); }
|
||||
100% { transform: rotate(360deg); }
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div id="loading">
|
||||
<div class="spinner"></div>
|
||||
Loading KHM Admin Panel...
|
||||
</div>
|
||||
|
||||
<canvas id="the_canvas_id"></canvas>
|
||||
|
||||
<script type="module">
|
||||
import init, { start_web_admin } from './wasm/khm_wasm.js';
|
||||
|
||||
async function run() {
|
||||
try {
|
||||
// Initialize WASM module
|
||||
await init();
|
||||
|
||||
// Hide loading indicator
|
||||
document.getElementById('loading').style.display = 'none';
|
||||
|
||||
// Start the egui web app
|
||||
start_web_admin('the_canvas_id');
|
||||
|
||||
console.log('KHM Web Admin Panel started successfully');
|
||||
} catch (error) {
|
||||
console.error('Failed to start KHM Web Admin Panel:', error);
|
||||
|
||||
// Show error message
|
||||
document.getElementById('loading').innerHTML = `
|
||||
<div style="color: #ff6b6b; text-align: center;">
|
||||
<h3>⚠️ WASM Module Not Available</h3>
|
||||
<p>The egui web interface requires WASM compilation.</p>
|
||||
<p style="font-size: 14px; color: #ccc; margin: 20px 0;">Build steps:</p>
|
||||
<div style="background: #333; padding: 15px; border-radius: 5px; font-family: monospace; text-align: left; max-width: 600px; margin: 0 auto;">
|
||||
<div style="color: #888; margin-bottom: 10px;"># Install wasm-pack</div>
|
||||
<div style="color: #fff;">cargo install wasm-pack</div>
|
||||
<div style="color: #888; margin: 10px 0;"># Build WASM module</div>
|
||||
<div style="color: #fff;">wasm-pack build --target web --out-dir wasm --features web-gui</div>
|
||||
<div style="color: #888; margin: 10px 0;"># Restart server</div>
|
||||
<div style="color: #fff;">cargo run --features "server,web,web-gui"</div>
|
||||
</div>
|
||||
<p style="font-size: 12px; color: #888; margin-top: 20px;">Error: ${error.message}</p>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
}
|
||||
|
||||
run();
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
"#;
|
||||
|
||||
Ok(HttpResponse::Ok()
|
||||
.content_type("text/html; charset=utf-8")
|
||||
.body(html))
|
||||
}
|
||||
|
||||
#[cfg(not(feature = "web-gui"))]
|
||||
{
|
||||
let html = r#"
|
||||
<!DOCTYPE html>
|
||||
<html>
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<title>KHM Admin Panel - Not Available</title>
|
||||
<style>
|
||||
body {
|
||||
font-family: system-ui, sans-serif;
|
||||
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
|
||||
color: white;
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
height: 100vh;
|
||||
margin: 0;
|
||||
text-align: center;
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div>
|
||||
<h1>⚠️ Web GUI Not Available</h1>
|
||||
<p>This server was compiled without web-gui support.</p>
|
||||
<p>Please rebuild with <code>--features web-gui</code> to enable the admin interface.</p>
|
||||
</div>
|
||||
</body>
|
||||
</html>
|
||||
"#;
|
||||
|
||||
Ok(HttpResponse::Ok()
|
||||
.content_type("text/html; charset=utf-8")
|
||||
.body(html))
|
||||
}
|
||||
}
|
||||
|
||||
/// API endpoint to get GUI configuration
|
||||
pub async fn get_gui_config(
|
||||
flows: web::Data<crate::server::Flows>,
|
||||
allowed_flows: web::Data<Vec<String>>,
|
||||
) -> Result<HttpResponse> {
|
||||
info!("Web GUI config requested");
|
||||
|
||||
let flows_guard = flows.lock().unwrap();
|
||||
let available_flows: Vec<String> = flows_guard.iter().map(|f| f.name.clone()).collect();
|
||||
|
||||
Ok(HttpResponse::Ok().json(json!({
|
||||
"version": env!("CARGO_PKG_VERSION"),
|
||||
"gui_ready": cfg!(feature = "web-gui"),
|
||||
"features": ["key_management", "bulk_operations", "real_time_updates"],
|
||||
"available_flows": available_flows,
|
||||
"allowed_flows": &**allowed_flows,
|
||||
"api_endpoints": {
|
||||
"flows": "/api/flows",
|
||||
"keys": "/{flow}/keys",
|
||||
"deprecate": "/{flow}/keys/{server}",
|
||||
"restore": "/{flow}/keys/{server}/restore",
|
||||
"delete": "/{flow}/keys/{server}/delete",
|
||||
"bulk_deprecate": "/{flow}/bulk-deprecate",
|
||||
"bulk_restore": "/{flow}/bulk-restore",
|
||||
"dns_scan": "/{flow}/scan-dns"
|
||||
}
|
||||
})))
|
||||
}
|
||||
|
||||
/// API endpoint for web GUI state management
|
||||
pub async fn get_gui_state(
|
||||
flows: web::Data<crate::server::Flows>,
|
||||
allowed_flows: web::Data<Vec<String>>,
|
||||
) -> Result<HttpResponse> {
|
||||
info!("Web GUI state requested");
|
||||
|
||||
let flows_guard = flows.lock().unwrap();
|
||||
let flow_data: Vec<_> = flows_guard.iter().map(|f| json!({
|
||||
"name": f.name,
|
||||
"servers_count": f.servers.len(),
|
||||
"active_keys": f.servers.iter().filter(|k| !k.deprecated).count(),
|
||||
"deprecated_keys": f.servers.iter().filter(|k| k.deprecated).count()
|
||||
})).collect();
|
||||
|
||||
Ok(HttpResponse::Ok().json(json!({
|
||||
"flows": flow_data,
|
||||
"allowed_flows": &**allowed_flows,
|
||||
"timestamp": chrono::Utc::now().to_rfc3339()
|
||||
})))
|
||||
}
|
||||
|
||||
/// API endpoint to update GUI settings
|
||||
pub async fn update_gui_settings(
|
||||
settings: web::Json<serde_json::Value>,
|
||||
) -> Result<HttpResponse> {
|
||||
info!("Web GUI settings updated: {:?}", settings);
|
||||
|
||||
Ok(HttpResponse::Ok().json(json!({
|
||||
"status": "success",
|
||||
"message": "Settings updated successfully",
|
||||
"timestamp": chrono::Utc::now().to_rfc3339()
|
||||
})))
|
||||
}
|
||||
|
||||
/// Serve WASM files for egui web application
|
||||
pub async fn serve_wasm_file(path: web::Path<String>) -> Result<HttpResponse> {
|
||||
let filename = path.into_inner();
|
||||
info!("WASM file requested: {}", filename);
|
||||
|
||||
// Try to read the actual WASM files from the wasm directory
|
||||
let wasm_dir = std::path::Path::new("wasm");
|
||||
let file_path = wasm_dir.join(&filename);
|
||||
|
||||
match std::fs::read(&file_path) {
|
||||
Ok(content) => {
|
||||
let content_type = if filename.ends_with(".js") {
|
||||
"application/javascript; charset=utf-8"
|
||||
} else if filename.ends_with(".wasm") {
|
||||
"application/wasm"
|
||||
} else {
|
||||
"application/octet-stream"
|
||||
};
|
||||
|
||||
info!("Serving WASM file: {} ({} bytes)", filename, content.len());
|
||||
Ok(HttpResponse::Ok()
|
||||
.content_type(content_type)
|
||||
.body(content))
|
||||
}
|
||||
Err(_) => {
|
||||
// Fallback to placeholder if files don't exist
|
||||
let content = match filename.as_str() {
|
||||
"khm_wasm.js" => {
|
||||
r#"
|
||||
// KHM WASM Module Not Found
|
||||
// Build the WASM module first:
|
||||
// cd khm-wasm && wasm-pack build --target web --out-dir ../wasm
|
||||
|
||||
export default function init() {
|
||||
return Promise.reject(new Error('WASM module not found. Run: cd khm-wasm && wasm-pack build --target web --out-dir ../wasm'));
|
||||
}
|
||||
|
||||
export function start_web_admin(canvas_id) {
|
||||
throw new Error('WASM module not found. Run: cd khm-wasm && wasm-pack build --target web --out-dir ../wasm');
|
||||
}
|
||||
"#
|
||||
}
|
||||
_ => {
|
||||
return Ok(HttpResponse::NotFound().json(json!({
|
||||
"error": "WASM file not found",
|
||||
"filename": filename,
|
||||
"message": "Run: cd khm-wasm && wasm-pack build --target web --out-dir ../wasm"
|
||||
})));
|
||||
}
|
||||
};
|
||||
|
||||
Ok(HttpResponse::Ok()
|
||||
.content_type("application/javascript; charset=utf-8")
|
||||
.body(content))
|
||||
}
|
||||
}
|
||||
}
|
399
src/web_gui/api.rs
Normal file
399
src/web_gui/api.rs
Normal 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
590
src/web_gui/app.rs
Normal 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
182
src/web_gui/state.rs
Normal 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
532
src/web_gui/ui.rs
Normal 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
43
src/web_gui/wasm.rs
Normal 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
131
src/web_gui/wasm_api.rs
Normal 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())
|
||||
}
|
Reference in New Issue
Block a user