diff --git a/.github/workflows/main.yml b/.github/workflows/main.yml index 5d5eff8..cff65d0 100644 --- a/.github/workflows/main.yml +++ b/.github/workflows/main.yml @@ -20,13 +20,20 @@ jobs: include: - os: ubuntu-latest build_target: x86_64-unknown-linux-musl + platform_name: linux-amd64-musl + build_type: musl + - os: ubuntu-latest + build_target: x86_64-unknown-linux-gnu platform_name: linux-amd64 + build_type: dynamic - os: windows-latest build_target: x86_64-pc-windows-msvc platform_name: windows-amd64 + build_type: default - os: macos-latest build_target: aarch64-apple-darwin platform_name: macos-arm64 + build_type: default permissions: contents: write steps: @@ -61,21 +68,28 @@ jobs: - name: Install rust targets run: rustup target add ${{ matrix.build_target }} - - name: Build Linux MUSL - if: matrix.os == 'ubuntu-latest' + - name: Install Linux dependencies + if: matrix.os == 'ubuntu-latest' && matrix.build_type == 'dynamic' + run: | + sudo apt-get update + sudo apt-get install -y libssl-dev pkg-config libgtk-3-dev libglib2.0-dev libcairo2-dev libpango1.0-dev libatk1.0-dev libgdk-pixbuf2.0-dev libxdo-dev + + - name: Build Linux Dynamic + if: matrix.os == 'ubuntu-latest' && matrix.build_type == 'dynamic' + run: cargo build --target ${{ matrix.build_target }} --release + + - name: Build Linux MUSL (no GUI) + if: matrix.os == 'ubuntu-latest' && matrix.build_type == 'musl' uses: gmiam/rust-musl-action@master with: args: | sed -i 's/deb.debian.org/archive.debian.org/g' /etc/apt/sources.list sed -i 's/security.debian.org/archive.debian.org/g' /etc/apt/sources.list sed -i '/updates/d' /etc/apt/sources.list - apt-get update && apt-get install -y pkg-config libglib2.0-dev libatk1.0-dev libpango1.0-dev libgdk-pixbuf2.0-dev libgtk-3-dev libxdo-dev libcairo2-dev libgio2.0-cil libxml2-dev libfontconfig1-dev libfreetype6-dev libharfbuzz-dev libfribidi-dev libdatrie-dev libthai-dev libpixman-1-dev libxcb1-dev libxcb-render0-dev libxcb-shm0-dev libx11-dev libxext-dev libxrender-dev libgobject-2.0-dev gcc g++ musl-dev + apt-get update && apt-get install -y pkg-config libssl-dev musl-dev export PKG_CONFIG_ALLOW_CROSS=1 - export PKG_CONFIG_PATH=/usr/lib/x86_64-linux-gnu/pkgconfig:/usr/share/pkgconfig - export PKG_CONFIG_SYSROOT_DIR=/ - export PKG_CONFIG_ALL_STATIC=1 export RUSTFLAGS="-C target-feature=+crt-static" - cargo build --target ${{ matrix.build_target }} --release + cargo build --target ${{ matrix.build_target }} --release --no-default-features --features server - name: Build MacOS if: matrix.os == 'macos-latest' @@ -93,6 +107,7 @@ jobs: release: name: Create Release Page + if: always() # Always run even if some builds fail needs: build runs-on: ubuntu-latest outputs: @@ -114,13 +129,15 @@ jobs: upload: name: Upload Release Assets + if: always() # Always run even if some builds fail needs: release runs-on: ${{ matrix.os }} strategy: fail-fast: false matrix: - os: [ubuntu-latest, windows-latest, macos-latest] include: + - os: ubuntu-latest + platform_name: linux-amd64-musl - os: ubuntu-latest platform_name: linux-amd64 - os: windows-latest @@ -135,8 +152,19 @@ jobs: with: name: ${{ env.BINARY_NAME }}_${{ matrix.platform_name }} path: ${{ env.BINARY_NAME }}_${{ matrix.platform_name }} + continue-on-error: true # Continue if artifact doesn't exist + + - name: Check if artifact exists + id: check_artifact + run: | + if [ -f "${{ env.BINARY_NAME }}_${{ matrix.platform_name }}/${{ env.BINARY_NAME }}${{ matrix.platform_name == 'windows-amd64' && '.exe' || '' }}" ]; then + echo "artifact_exists=true" >> $GITHUB_OUTPUT + else + echo "artifact_exists=false" >> $GITHUB_OUTPUT + fi - name: Upload Release Asset + if: steps.check_artifact.outputs.artifact_exists == 'true' uses: actions/upload-release-asset@v1 env: GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} @@ -154,9 +182,9 @@ jobs: - uses: actions/checkout@v4 - uses: actions/download-artifact@v4 - name: Download Linux artifact + name: Download Linux MUSL artifact with: - name: ${{ env.BINARY_NAME }}_linux-amd64 + name: ${{ env.BINARY_NAME }}_linux-amd64-musl path: . - name: ls diff --git a/src/db.rs b/src/db.rs index 626e1be..243a726 100644 --- a/src/db.rs +++ b/src/db.rs @@ -69,12 +69,12 @@ impl DbClient { .client .query( "SELECT EXISTS ( - SELECT FROM information_schema.tables - WHERE table_schema = 'public' + SELECT FROM information_schema.tables + WHERE table_schema = 'public' AND table_name = 'keys' ) AND EXISTS ( - SELECT FROM information_schema.tables - WHERE table_schema = 'public' + SELECT FROM information_schema.tables + WHERE table_schema = 'public' AND table_name = 'flows' )", &[], @@ -144,9 +144,9 @@ impl DbClient { .client .query( "SELECT EXISTS ( - SELECT FROM information_schema.columns - WHERE table_schema = 'public' - AND table_name = 'keys' + SELECT FROM information_schema.columns + WHERE table_schema = 'public' + AND table_name = 'keys' AND column_name = 'deprecated' )", &[], @@ -448,9 +448,9 @@ impl DbClient { let result = self .client .execute( - "UPDATE public.keys - SET deprecated = TRUE, updated = NOW() - WHERE host = $1 + "UPDATE public.keys + SET deprecated = TRUE, updated = NOW() + WHERE host = $1 AND key_id IN ( SELECT key_id FROM public.flows WHERE name = $2 )", @@ -480,9 +480,9 @@ impl DbClient { let result = self .client .execute( - "UPDATE public.keys - SET deprecated = TRUE, updated = NOW() - WHERE host = ANY($1) + "UPDATE public.keys + SET deprecated = TRUE, updated = NOW() + WHERE host = ANY($1) AND key_id IN ( SELECT key_id FROM public.flows WHERE name = $2 )", @@ -493,7 +493,9 @@ impl DbClient { info!( "Bulk deprecated {} key(s) for {} servers in flow '{}'", - affected, server_names.len(), flow_name + affected, + server_names.len(), + flow_name ); Ok(affected) @@ -512,9 +514,9 @@ impl DbClient { let result = self .client .execute( - "UPDATE public.keys - SET deprecated = FALSE, updated = NOW() - WHERE host = ANY($1) + "UPDATE public.keys + SET deprecated = FALSE, updated = NOW() + WHERE host = ANY($1) AND deprecated = TRUE AND key_id IN ( SELECT key_id FROM public.flows WHERE name = $2 @@ -526,7 +528,9 @@ impl DbClient { info!( "Bulk restored {} key(s) for {} servers in flow '{}'", - affected, server_names.len(), flow_name + affected, + server_names.len(), + flow_name ); Ok(affected) @@ -541,9 +545,9 @@ impl DbClient { let result = self .client .execute( - "UPDATE public.keys - SET deprecated = FALSE, updated = NOW() - WHERE host = $1 + "UPDATE public.keys + SET deprecated = FALSE, updated = NOW() + WHERE host = $1 AND deprecated = TRUE AND key_id IN ( SELECT key_id FROM public.flows WHERE name = $2 @@ -570,8 +574,8 @@ impl DbClient { let result = self .client .query( - "SELECT k.key_id FROM public.keys k - INNER JOIN public.flows f ON k.key_id = f.key_id + "SELECT k.key_id FROM public.keys k + INNER JOIN public.flows f ON k.key_id = f.key_id WHERE k.host = $1 AND f.name = $2", &[&server_name, &flow_name], ) diff --git a/src/gui/admin/state.rs b/src/gui/admin/state.rs index 357d019..da9862a 100644 --- a/src/gui/admin/state.rs +++ b/src/gui/admin/state.rs @@ -1,15 +1,15 @@ +use crate::gui::api::{fetch_keys, SshKey}; +use crate::gui::common::KhmSettings; use eframe::egui; use log::{error, info}; use std::collections::HashMap; use std::sync::mpsc; -use crate::gui::api::{SshKey, fetch_keys}; -use crate::gui::common::KhmSettings; #[derive(Debug, Clone)] pub enum AdminOperation { LoadingKeys, DeprecatingKey, - RestoringKey, + RestoringKey, DeletingKey, BulkDeprecating, BulkRestoring, @@ -47,52 +47,54 @@ impl AdminState { /// Filter keys based on current search term and deprecated filter pub fn filter_keys(&mut self) { let mut filtered = self.keys.clone(); - + // Apply deprecated filter if self.show_deprecated_only { filtered.retain(|key| key.deprecated); } - + // Apply search filter if !self.search_term.is_empty() { let search_term = self.search_term.to_lowercase(); filtered.retain(|key| { - key.server.to_lowercase().contains(&search_term) || - key.public_key.to_lowercase().contains(&search_term) + key.server.to_lowercase().contains(&search_term) + || key.public_key.to_lowercase().contains(&search_term) }); } - + self.filtered_keys = filtered; } - + /// Load keys from server - pub fn load_keys(&mut self, settings: &KhmSettings, ctx: &egui::Context) -> Option, String>>> { + pub fn load_keys( + &mut self, + settings: &KhmSettings, + ctx: &egui::Context, + ) -> Option, String>>> { if settings.host.is_empty() || settings.flow.is_empty() { return None; } - + self.current_operation = AdminOperation::LoadingKeys; - + let (tx, rx) = mpsc::channel(); - + let host = settings.host.clone(); let flow = settings.flow.clone(); let basic_auth = settings.basic_auth.clone(); let ctx_clone = ctx.clone(); - + std::thread::spawn(move || { let rt = tokio::runtime::Runtime::new().unwrap(); - let result = rt.block_on(async { - fetch_keys(host, flow, basic_auth).await - }); - + let result = rt.block_on(async { fetch_keys(host, flow, basic_auth).await }); + let _ = tx.send(result); ctx_clone.request_repaint(); }); - + Some(rx) } - + /// Handle keys load result pub fn handle_keys_loaded(&mut self, result: Result, String>) { match result { @@ -109,7 +111,7 @@ impl AdminState { } } } - + /// Get selected servers list pub fn get_selected_servers(&self) -> Vec { self.selected_servers @@ -117,19 +119,24 @@ impl AdminState { .filter_map(|(server, &selected)| if selected { Some(server.clone()) } else { None }) .collect() } - + /// Clear selected servers pub fn clear_selection(&mut self) { self.selected_servers.clear(); } - + /// Get statistics pub fn get_statistics(&self) -> AdminStatistics { let total_keys = self.keys.len(); let active_keys = self.keys.iter().filter(|k| !k.deprecated).count(); let deprecated_keys = total_keys - active_keys; - let unique_servers = self.keys.iter().map(|k| &k.server).collect::>().len(); - + let unique_servers = self + .keys + .iter() + .map(|k| &k.server) + .collect::>() + .len(); + AdminStatistics { total_keys, active_keys, diff --git a/src/gui/admin/ui.rs b/src/gui/admin/ui.rs index bed66fe..e4cb00c 100644 --- a/src/gui/admin/ui.rs +++ b/src/gui/admin/ui.rs @@ -1,46 +1,81 @@ +use super::state::{get_key_preview, get_key_type, AdminState}; +use crate::gui::api::SshKey; use eframe::egui; use std::collections::BTreeMap; -use super::state::{AdminState, get_key_type, get_key_preview}; -use crate::gui::api::SshKey; /// Render statistics cards pub fn render_statistics(ui: &mut egui::Ui, admin_state: &AdminState) { let stats = admin_state.get_statistics(); - + ui.group(|ui| { ui.set_min_width(ui.available_width()); ui.vertical(|ui| { ui.label(egui::RichText::new("šŸ“Š Statistics").size(16.0).strong()); ui.add_space(8.0); - + ui.horizontal(|ui| { ui.columns(4, |cols| { // Total keys cols[0].vertical_centered_justified(|ui| { ui.label(egui::RichText::new("šŸ“Š").size(20.0)); - ui.label(egui::RichText::new(stats.total_keys.to_string()).size(24.0).strong()); - ui.label(egui::RichText::new("Total Keys").size(11.0).color(egui::Color32::GRAY)); + 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)); + 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)); + 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)); + 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), + ); }); }); }); @@ -51,45 +86,59 @@ pub fn render_statistics(ui: &mut egui::Ui, admin_state: &AdminState) { /// Render search and filter controls pub fn render_search_controls(ui: &mut egui::Ui, admin_state: &mut AdminState) -> bool { let mut changed = false; - + ui.group(|ui| { ui.set_min_width(ui.available_width()); ui.vertical(|ui| { ui.label(egui::RichText::new("šŸ” Search").size(16.0).strong()); ui.add_space(8.0); - + // Search field with full width ui.horizontal(|ui| { ui.label(egui::RichText::new("šŸ”").size(14.0)); let search_response = ui.add_sized( [ui.available_width() * 0.6, 20.0], egui::TextEdit::singleline(&mut admin_state.search_term) - .hint_text("Search servers or keys...") + .hint_text("Search servers or keys..."), ); - + if admin_state.search_term.is_empty() { - ui.label(egui::RichText::new("Type to search").size(11.0).color(egui::Color32::GRAY)); + ui.label( + egui::RichText::new("Type to search") + .size(11.0) + .color(egui::Color32::GRAY), + ); } else { - ui.label(egui::RichText::new(format!("{} results", admin_state.filtered_keys.len())).size(11.0)); - if ui.add(egui::Button::new(egui::RichText::new("āŒ").color(egui::Color32::WHITE)) - .fill(egui::Color32::from_rgb(170, 170, 170)) - .stroke(egui::Stroke::new(1.0, egui::Color32::from_rgb(89, 89, 89))) - .rounding(egui::Rounding::same(3.0)) - .min_size(egui::vec2(18.0, 18.0)) - ).on_hover_text("Clear search").clicked() { + ui.label( + egui::RichText::new(format!("{} results", admin_state.filtered_keys.len())) + .size(11.0), + ); + if ui + .add( + egui::Button::new( + egui::RichText::new("āŒ").color(egui::Color32::WHITE), + ) + .fill(egui::Color32::from_rgb(170, 170, 170)) + .stroke(egui::Stroke::new(1.0, egui::Color32::from_rgb(89, 89, 89))) + .rounding(egui::Rounding::same(3.0)) + .min_size(egui::vec2(18.0, 18.0)), + ) + .on_hover_text("Clear search") + .clicked() + { admin_state.search_term.clear(); changed = true; } } - + // Handle search text changes if search_response.changed() { changed = true; } }); - + ui.add_space(5.0); - + // Filter controls ui.horizontal(|ui| { ui.label("Filter:"); @@ -98,76 +147,104 @@ pub fn render_search_controls(ui: &mut egui::Ui, admin_state: &mut AdminState) - admin_state.show_deprecated_only = false; changed = true; } - if ui.selectable_label(show_deprecated, "ā— Deprecated").clicked() { + 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(); - + 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.label( + egui::RichText::new(format!("Selected {} servers", selected_count)) + .size(14.0) + .strong() + .color(egui::Color32::LIGHT_BLUE), + ); }); - + ui.add_space(5.0); - + ui.horizontal(|ui| { - if ui.add(egui::Button::new(egui::RichText::new("ā— Deprecate Selected").color(egui::Color32::BLACK)) - .fill(egui::Color32::from_rgb(255, 200, 0)) - .stroke(egui::Stroke::new(1.0, egui::Color32::from_rgb(102, 94, 72))) - .rounding(egui::Rounding::same(6.0)) - .min_size(egui::vec2(130.0, 28.0)) - ).clicked() { + if ui + .add( + egui::Button::new( + egui::RichText::new("ā— Deprecate Selected") + .color(egui::Color32::BLACK), + ) + .fill(egui::Color32::from_rgb(255, 200, 0)) + .stroke(egui::Stroke::new(1.0, egui::Color32::from_rgb(102, 94, 72))) + .rounding(egui::Rounding::same(6.0)) + .min_size(egui::vec2(130.0, 28.0)), + ) + .clicked() + { action = BulkAction::DeprecateSelected; } - - if ui.add(egui::Button::new(egui::RichText::new("āœ… Restore Selected").color(egui::Color32::WHITE)) - .fill(egui::Color32::from_rgb(101, 199, 40)) - .stroke(egui::Stroke::new(1.0, egui::Color32::from_rgb(94, 105, 25))) - .rounding(egui::Rounding::same(6.0)) - .min_size(egui::vec2(120.0, 28.0)) - ).clicked() { + + if ui + .add( + egui::Button::new( + egui::RichText::new("āœ… Restore Selected").color(egui::Color32::WHITE), + ) + .fill(egui::Color32::from_rgb(101, 199, 40)) + .stroke(egui::Stroke::new(1.0, egui::Color32::from_rgb(94, 105, 25))) + .rounding(egui::Rounding::same(6.0)) + .min_size(egui::vec2(120.0, 28.0)), + ) + .clicked() + { action = BulkAction::RestoreSelected; } - - if ui.add(egui::Button::new(egui::RichText::new("X Clear Selection").color(egui::Color32::WHITE)) - .fill(egui::Color32::from_rgb(170, 170, 170)) - .stroke(egui::Stroke::new(1.0, egui::Color32::from_rgb(89, 89, 89))) - .rounding(egui::Rounding::same(6.0)) - .min_size(egui::vec2(110.0, 28.0)) - ).clicked() { + + if ui + .add( + egui::Button::new( + egui::RichText::new("X Clear Selection").color(egui::Color32::WHITE), + ) + .fill(egui::Color32::from_rgb(170, 170, 170)) + .stroke(egui::Stroke::new(1.0, egui::Color32::from_rgb(89, 89, 89))) + .rounding(egui::Rounding::same(6.0)) + .min_size(egui::vec2(110.0, 28.0)), + ) + .clicked() + { admin_state.clear_selection(); action = BulkAction::ClearSelection; } }); }); }); - + action } @@ -177,86 +254,136 @@ pub fn render_keys_table(ui: &mut egui::Ui, admin_state: &mut AdminState) -> Key render_empty_state(ui, admin_state); return KeyAction::None; } - + let mut action = KeyAction::None; - + // Group keys by server let mut servers: BTreeMap> = BTreeMap::new(); for key in &admin_state.filtered_keys { - servers.entry(key.server.clone()).or_insert_with(Vec::new).push(key.clone()); + servers + .entry(key.server.clone()) + .or_insert_with(Vec::new) + .push(key.clone()); } - + // Render each server group for (server_name, server_keys) in servers { - let is_expanded = admin_state.expanded_servers.get(&server_name).copied().unwrap_or(false); + let is_expanded = admin_state + .expanded_servers + .get(&server_name) + .copied() + .unwrap_or(false); let active_count = server_keys.iter().filter(|k| !k.deprecated).count(); let deprecated_count = server_keys.len() - active_count; - + // Server header ui.group(|ui| { ui.horizontal(|ui| { // Server selection checkbox - let mut selected = admin_state.selected_servers.get(&server_name).copied().unwrap_or(false); - if ui.add(egui::Checkbox::new(&mut selected, "") - .indeterminate(false) - ).changed() { - admin_state.selected_servers.insert(server_name.clone(), selected); + let mut selected = admin_state + .selected_servers + .get(&server_name) + .copied() + .unwrap_or(false); + if ui + .add(egui::Checkbox::new(&mut selected, "").indeterminate(false)) + .changed() + { + admin_state + .selected_servers + .insert(server_name.clone(), selected); } - + // Expand/collapse button - let expand_icon = if is_expanded { "ā–¼" } else { "ā–¶" }; - if ui.add(egui::Button::new(expand_icon) - .fill(egui::Color32::TRANSPARENT) - .stroke(egui::Stroke::NONE) - .min_size(egui::vec2(20.0, 20.0)) - ).clicked() { - admin_state.expanded_servers.insert(server_name.clone(), !is_expanded); + let expand_icon = if is_expanded { "-" } else { "+" }; + if ui + .add( + egui::Button::new(expand_icon) + .fill(egui::Color32::TRANSPARENT) + .stroke(egui::Stroke::NONE) + .min_size(egui::vec2(20.0, 20.0)), + ) + .clicked() + { + admin_state + .expanded_servers + .insert(server_name.clone(), !is_expanded); } - + // Server icon and name ui.label(egui::RichText::new("šŸ’»").size(16.0)); - ui.label(egui::RichText::new(&server_name) - .size(15.0) - .strong() - .color(egui::Color32::WHITE)); - + ui.label( + egui::RichText::new(&server_name) + .size(15.0) + .strong() + .color(egui::Color32::WHITE), + ); + // Keys count badge - render_badge(ui, &format!("{} keys", server_keys.len()), egui::Color32::from_rgb(52, 152, 219), egui::Color32::WHITE); - + render_badge( + ui, + &format!("{} keys", server_keys.len()), + egui::Color32::from_rgb(52, 152, 219), + egui::Color32::WHITE, + ); + ui.add_space(5.0); - + // Deprecated count badge if deprecated_count > 0 { - render_badge(ui, &format!("{} depr", deprecated_count), egui::Color32::from_rgb(231, 76, 60), egui::Color32::WHITE); + render_badge( + ui, + &format!("{} depr", deprecated_count), + egui::Color32::from_rgb(231, 76, 60), + egui::Color32::WHITE, + ); } - + ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| { // Server action buttons if deprecated_count > 0 { - if ui.add(egui::Button::new(egui::RichText::new("āœ… Restore").color(egui::Color32::WHITE)) - .fill(egui::Color32::from_rgb(101, 199, 40)) - .stroke(egui::Stroke::new(1.0, egui::Color32::from_rgb(94, 105, 25))) - .rounding(egui::Rounding::same(4.0)) - .min_size(egui::vec2(70.0, 24.0)) - ).clicked() { + if ui + .add( + egui::Button::new( + egui::RichText::new("āœ… Restore").color(egui::Color32::WHITE), + ) + .fill(egui::Color32::from_rgb(101, 199, 40)) + .stroke(egui::Stroke::new( + 1.0, + egui::Color32::from_rgb(94, 105, 25), + )) + .rounding(egui::Rounding::same(4.0)) + .min_size(egui::vec2(70.0, 24.0)), + ) + .clicked() + { action = KeyAction::RestoreServer(server_name.clone()); } } - + if active_count > 0 { - if ui.add(egui::Button::new(egui::RichText::new("ā— Deprecate").color(egui::Color32::BLACK)) - .fill(egui::Color32::from_rgb(255, 200, 0)) - .stroke(egui::Stroke::new(1.0, egui::Color32::from_rgb(102, 94, 72))) - .rounding(egui::Rounding::same(4.0)) - .min_size(egui::vec2(85.0, 24.0)) - ).clicked() { + if ui + .add( + egui::Button::new( + egui::RichText::new("ā— Deprecate").color(egui::Color32::BLACK), + ) + .fill(egui::Color32::from_rgb(255, 200, 0)) + .stroke(egui::Stroke::new( + 1.0, + egui::Color32::from_rgb(102, 94, 72), + )) + .rounding(egui::Rounding::same(4.0)) + .min_size(egui::vec2(85.0, 24.0)), + ) + .clicked() + { action = KeyAction::DeprecateServer(server_name.clone()); } } }); }); }); - + // Expanded key details if is_expanded { ui.indent("server_keys", |ui| { @@ -267,10 +394,10 @@ pub fn render_keys_table(ui: &mut egui::Ui, admin_state: &mut AdminState) -> Key } }); } - + ui.add_space(5.0); } - + action } @@ -279,29 +406,56 @@ 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)); + 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)) + 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)); + .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)); + 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), + ); } }); } @@ -309,7 +463,7 @@ fn render_empty_state(ui: &mut egui::Ui, admin_state: &AdminState) { /// Render individual key item fn render_key_item(ui: &mut egui::Ui, key: &SshKey, server_name: &str) -> Option { let mut action = None; - + ui.group(|ui| { ui.horizontal(|ui| { // Key type badge @@ -321,86 +475,112 @@ fn render_key_item(ui: &mut egui::Ui, key: &SshKey, server_name: &str) -> Option "DSA" => (egui::Color32::from_rgb(230, 126, 34), egui::Color32::WHITE), _ => (egui::Color32::GRAY, egui::Color32::WHITE), }; - + render_small_badge(ui, &key_type, badge_color, text_color); ui.add_space(5.0); - + // Status badge if key.deprecated { - ui.label(egui::RichText::new("ā— DEPR") - .size(10.0) - .color(egui::Color32::from_rgb(231, 76, 60)) - .strong()); + ui.label( + egui::RichText::new("ā— DEPR") + .size(10.0) + .color(egui::Color32::from_rgb(231, 76, 60)) + .strong(), + ); } else { - ui.label(egui::RichText::new("[OK] ACTIVE") - .size(10.0) - .color(egui::Color32::from_rgb(46, 204, 113)) - .strong()); + ui.label( + egui::RichText::new("āœ…") + .size(10.0) + .color(egui::Color32::from_rgb(46, 204, 113)) + .strong(), + ); } - + ui.add_space(5.0); - + // Key preview - ui.label(egui::RichText::new(get_key_preview(&key.public_key)) - .font(egui::FontId::monospace(10.0)) - .color(egui::Color32::LIGHT_GRAY)); - + ui.label( + egui::RichText::new(get_key_preview(&key.public_key)) + .font(egui::FontId::monospace(10.0)) + .color(egui::Color32::LIGHT_GRAY), + ); + ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| { // Key action buttons if key.deprecated { - if ui.add(egui::Button::new(egui::RichText::new("[R]").color(egui::Color32::WHITE)) - .fill(egui::Color32::from_rgb(101, 199, 40)) - .stroke(egui::Stroke::new(1.0, egui::Color32::from_rgb(94, 105, 25))) - .rounding(egui::Rounding::same(3.0)) - .min_size(egui::vec2(22.0, 18.0)) - ).on_hover_text("Restore key").clicked() { + if ui + .add( + egui::Button::new( + egui::RichText::new("[R]").color(egui::Color32::WHITE), + ) + .fill(egui::Color32::from_rgb(101, 199, 40)) + .stroke(egui::Stroke::new(1.0, egui::Color32::from_rgb(94, 105, 25))) + .rounding(egui::Rounding::same(3.0)) + .min_size(egui::vec2(22.0, 18.0)), + ) + .on_hover_text("Restore key") + .clicked() + { action = Some(KeyAction::RestoreKey(server_name.to_string())); } - if ui.add(egui::Button::new(egui::RichText::new("Del").color(egui::Color32::WHITE)) - .fill(egui::Color32::from_rgb(246, 36, 71)) - .stroke(egui::Stroke::new(1.0, egui::Color32::from_rgb(129, 18, 17))) - .rounding(egui::Rounding::same(3.0)) - .min_size(egui::vec2(26.0, 18.0)) - ).on_hover_text("Delete key").clicked() { + if ui + .add( + egui::Button::new( + egui::RichText::new("Del").color(egui::Color32::WHITE), + ) + .fill(egui::Color32::from_rgb(246, 36, 71)) + .stroke(egui::Stroke::new(1.0, egui::Color32::from_rgb(129, 18, 17))) + .rounding(egui::Rounding::same(3.0)) + .min_size(egui::vec2(26.0, 18.0)), + ) + .on_hover_text("Delete key") + .clicked() + { action = Some(KeyAction::DeleteKey(server_name.to_string())); } } else { - if ui.add(egui::Button::new(egui::RichText::new("ā—").color(egui::Color32::BLACK)) - .fill(egui::Color32::from_rgb(255, 200, 0)) - .stroke(egui::Stroke::new(1.0, egui::Color32::from_rgb(102, 94, 72))) - .rounding(egui::Rounding::same(3.0)) - .min_size(egui::vec2(22.0, 18.0)) - ).on_hover_text("Deprecate key").clicked() { + if ui + .add( + egui::Button::new( + egui::RichText::new("ā—").color(egui::Color32::BLACK), + ) + .fill(egui::Color32::from_rgb(255, 200, 0)) + .stroke(egui::Stroke::new(1.0, egui::Color32::from_rgb(102, 94, 72))) + .rounding(egui::Rounding::same(3.0)) + .min_size(egui::vec2(22.0, 18.0)), + ) + .on_hover_text("Deprecate key") + .clicked() + { action = Some(KeyAction::DeprecateKey(server_name.to_string())); } } - - if ui.add(egui::Button::new(egui::RichText::new("Copy").color(egui::Color32::WHITE)) - .fill(egui::Color32::from_rgb(0, 111, 230)) - .stroke(egui::Stroke::new(1.0, egui::Color32::from_rgb(35, 84, 97))) - .rounding(egui::Rounding::same(3.0)) - .min_size(egui::vec2(30.0, 18.0)) - ).on_hover_text("Copy to clipboard").clicked() { + + if ui + .add( + egui::Button::new(egui::RichText::new("Copy").color(egui::Color32::WHITE)) + .fill(egui::Color32::from_rgb(0, 111, 230)) + .stroke(egui::Stroke::new(1.0, egui::Color32::from_rgb(35, 84, 97))) + .rounding(egui::Rounding::same(3.0)) + .min_size(egui::vec2(30.0, 18.0)), + ) + .on_hover_text("Copy to clipboard") + .clicked() + { ui.output_mut(|o| o.copied_text = key.public_key.clone()); } }); }); }); - + action } /// Render a badge with text fn render_badge(ui: &mut egui::Ui, text: &str, bg_color: egui::Color32, text_color: egui::Color32) { - let (rect, _) = ui.allocate_exact_size( - egui::vec2(50.0, 18.0), - egui::Sense::hover() - ); - ui.painter().rect_filled( - rect, - egui::Rounding::same(8.0), - bg_color - ); + let (rect, _) = ui.allocate_exact_size(egui::vec2(50.0, 18.0), egui::Sense::hover()); + ui.painter() + .rect_filled(rect, egui::Rounding::same(8.0), bg_color); ui.painter().text( rect.center(), egui::Align2::CENTER_CENTER, @@ -411,16 +591,15 @@ fn render_badge(ui: &mut egui::Ui, text: &str, bg_color: egui::Color32, text_col } /// Render a small badge with text -fn render_small_badge(ui: &mut egui::Ui, text: &str, bg_color: egui::Color32, text_color: egui::Color32) { - let (rect, _) = ui.allocate_exact_size( - egui::vec2(40.0, 16.0), - egui::Sense::hover() - ); - ui.painter().rect_filled( - rect, - egui::Rounding::same(3.0), - bg_color - ); +fn render_small_badge( + ui: &mut egui::Ui, + text: &str, + bg_color: egui::Color32, + text_color: egui::Color32, +) { + let (rect, _) = ui.allocate_exact_size(egui::vec2(40.0, 16.0), egui::Sense::hover()); + ui.painter() + .rect_filled(rect, egui::Rounding::same(3.0), bg_color); ui.painter().text( rect.center(), egui::Align2::CENTER_CENTER, diff --git a/src/gui/api/client.rs b/src/gui/api/client.rs index ea0ed50..9dd0e4e 100644 --- a/src/gui/api/client.rs +++ b/src/gui/api/client.rs @@ -1,7 +1,7 @@ -use reqwest::Client; +use crate::gui::common::{perform_sync, KhmSettings}; use log::info; +use reqwest::Client; use serde::{Deserialize, Serialize}; -use crate::gui::common::{KhmSettings, perform_sync}; #[derive(Debug, Clone, Serialize, Deserialize)] pub struct SshKey { @@ -12,183 +12,276 @@ pub struct SshKey { } /// Test connection to KHM server -pub async fn test_connection(host: String, flow: String, basic_auth: String) -> Result { +pub async fn test_connection( + host: String, + flow: String, + basic_auth: String, +) -> Result { if host.is_empty() || flow.is_empty() { return Err("Host and flow must be specified".to_string()); } - + let url = format!("{}/{}/keys", host.trim_end_matches('/'), flow); info!("Testing connection to: {}", url); - + let client = create_http_client()?; let mut request = client.get(&url); - + request = add_auth_if_needed(request, &basic_auth)?; - - let response = request.send().await + + let response = request + .send() + .await .map_err(|e| format!("Request failed: {}", e))?; - + check_response_status(&response)?; - - let body = response.text().await + + let body = response + .text() + .await .map_err(|e| format!("Failed to read response: {}", e))?; - + check_html_response(&body)?; - - let keys: Vec = serde_json::from_str(&body) - .map_err(|e| format!("Failed to parse response: {}", e))?; - + + let keys: Vec = + serde_json::from_str(&body).map_err(|e| format!("Failed to parse response: {}", e))?; + let message = format!("Found {} SSH keys from flow '{}'", keys.len(), flow); info!("Connection test successful: {}", message); Ok(message) } /// Fetch all SSH keys including deprecated ones -pub async fn fetch_keys(host: String, flow: String, basic_auth: String) -> Result, String> { +pub async fn fetch_keys( + host: String, + flow: String, + basic_auth: String, +) -> Result, String> { if host.is_empty() || flow.is_empty() { return Err("Host and flow must be specified".to_string()); } - - let url = format!("{}/{}/keys?include_deprecated=true", host.trim_end_matches('/'), flow); + + let url = format!( + "{}/{}/keys?include_deprecated=true", + host.trim_end_matches('/'), + flow + ); info!("Fetching keys from: {}", url); - + let client = create_http_client()?; let mut request = client.get(&url); - + request = add_auth_if_needed(request, &basic_auth)?; - - let response = request.send().await + + let response = request + .send() + .await .map_err(|e| format!("Request failed: {}", e))?; - + check_response_status(&response)?; - - let body = response.text().await + + let body = response + .text() + .await .map_err(|e| format!("Failed to read response: {}", e))?; - + check_html_response(&body)?; - - let keys: Vec = serde_json::from_str(&body) - .map_err(|e| format!("Failed to parse response: {}", e))?; - + + let keys: Vec = + serde_json::from_str(&body).map_err(|e| format!("Failed to parse response: {}", e))?; + info!("Fetched {} SSH keys", keys.len()); Ok(keys) } /// Deprecate a key for a specific server -pub async fn deprecate_key(host: String, flow: String, basic_auth: String, server: String) -> Result { - let url = format!("{}/{}/keys/{}", host.trim_end_matches('/'), flow, urlencoding::encode(&server)); +pub async fn deprecate_key( + host: String, + flow: String, + basic_auth: String, + server: String, +) -> Result { + let url = format!( + "{}/{}/keys/{}", + host.trim_end_matches('/'), + flow, + urlencoding::encode(&server) + ); info!("Deprecating key for server '{}' at: {}", server, url); - + let client = create_http_client()?; let mut request = client.delete(&url); - + request = add_auth_if_needed(request, &basic_auth)?; - - let response = request.send().await + + let response = request + .send() + .await .map_err(|e| format!("Request failed: {}", e))?; - + check_response_status(&response)?; - - let body = response.text().await + + let body = response + .text() + .await .map_err(|e| format!("Failed to read response: {}", e))?; - - parse_api_response(&body, &format!("Successfully deprecated key for server '{}'", server)) + + parse_api_response( + &body, + &format!("Successfully deprecated key for server '{}'", server), + ) } /// Restore a key for a specific server -pub async fn restore_key(host: String, flow: String, basic_auth: String, server: String) -> Result { - let url = format!("{}/{}/keys/{}/restore", host.trim_end_matches('/'), flow, urlencoding::encode(&server)); +pub async fn restore_key( + host: String, + flow: String, + basic_auth: String, + server: String, +) -> Result { + let url = format!( + "{}/{}/keys/{}/restore", + host.trim_end_matches('/'), + flow, + urlencoding::encode(&server) + ); info!("Restoring key for server '{}' at: {}", server, url); - + let client = create_http_client()?; let mut request = client.post(&url); - + request = add_auth_if_needed(request, &basic_auth)?; - - let response = request.send().await + + let response = request + .send() + .await .map_err(|e| format!("Request failed: {}", e))?; - + check_response_status(&response)?; - - let body = response.text().await + + let body = response + .text() + .await .map_err(|e| format!("Failed to read response: {}", e))?; - - parse_api_response(&body, &format!("Successfully restored key for server '{}'", server)) + + parse_api_response( + &body, + &format!("Successfully restored key for server '{}'", server), + ) } /// Delete a key permanently for a specific server -pub async fn delete_key(host: String, flow: String, basic_auth: String, server: String) -> Result { - let url = format!("{}/{}/keys/{}/delete", host.trim_end_matches('/'), flow, urlencoding::encode(&server)); - info!("Permanently deleting key for server '{}' at: {}", server, url); - +pub async fn delete_key( + host: String, + flow: String, + basic_auth: String, + server: String, +) -> Result { + let url = format!( + "{}/{}/keys/{}/delete", + host.trim_end_matches('/'), + flow, + urlencoding::encode(&server) + ); + info!( + "Permanently deleting key for server '{}' at: {}", + server, url + ); + let client = create_http_client()?; let mut request = client.delete(&url); - + request = add_auth_if_needed(request, &basic_auth)?; - - let response = request.send().await + + let response = request + .send() + .await .map_err(|e| format!("Request failed: {}", e))?; - + check_response_status(&response)?; - - let body = response.text().await + + let body = response + .text() + .await .map_err(|e| format!("Failed to read response: {}", e))?; - - parse_api_response(&body, &format!("Successfully deleted key for server '{}'", server)) + + parse_api_response( + &body, + &format!("Successfully deleted key for server '{}'", server), + ) } /// Bulk deprecate multiple servers -pub async fn bulk_deprecate_servers(host: String, flow: String, basic_auth: String, servers: Vec) -> Result { +pub async fn bulk_deprecate_servers( + host: String, + flow: String, + basic_auth: String, + servers: Vec, +) -> Result { let url = format!("{}/{}/bulk-deprecate", host.trim_end_matches('/'), flow); info!("Bulk deprecating {} servers at: {}", servers.len(), url); - + let client = create_http_client()?; - let mut request = client.post(&url) - .json(&serde_json::json!({ - "servers": servers - })); - + let mut request = client.post(&url).json(&serde_json::json!({ + "servers": servers + })); + request = add_auth_if_needed(request, &basic_auth)?; - - let response = request.send().await + + let response = request + .send() + .await .map_err(|e| format!("Request failed: {}", e))?; - + check_response_status(&response)?; - - let body = response.text().await + + let body = response + .text() + .await .map_err(|e| format!("Failed to read response: {}", e))?; - + parse_api_response(&body, "Successfully deprecated servers") } /// Bulk restore multiple servers -pub async fn bulk_restore_servers(host: String, flow: String, basic_auth: String, servers: Vec) -> Result { +pub async fn bulk_restore_servers( + host: String, + flow: String, + basic_auth: String, + servers: Vec, +) -> Result { let url = format!("{}/{}/bulk-restore", host.trim_end_matches('/'), flow); info!("Bulk restoring {} servers at: {}", servers.len(), url); - + let client = create_http_client()?; - let mut request = client.post(&url) - .json(&serde_json::json!({ - "servers": servers - })); - + let mut request = client.post(&url).json(&serde_json::json!({ + "servers": servers + })); + request = add_auth_if_needed(request, &basic_auth)?; - - let response = request.send().await + + let response = request + .send() + .await .map_err(|e| format!("Request failed: {}", e))?; - + check_response_status(&response)?; - - let body = response.text().await + + let body = response + .text() + .await .map_err(|e| format!("Failed to read response: {}", e))?; - + parse_api_response(&body, "Successfully restored servers") } /// Perform manual sync operation pub async fn perform_manual_sync(settings: KhmSettings) -> Result { match perform_sync(&settings).await { - Ok(keys_count) => Ok(format!("Sync completed successfully with {} keys", keys_count)), + Ok(keys_count) => Ok(format!( + "Sync completed successfully with {} keys", + keys_count + )), Err(e) => Err(e.to_string()), } } @@ -203,11 +296,14 @@ fn create_http_client() -> Result { .map_err(|e| format!("Failed to create HTTP client: {}", e)) } -fn add_auth_if_needed(request: reqwest::RequestBuilder, basic_auth: &str) -> Result { +fn add_auth_if_needed( + request: reqwest::RequestBuilder, + basic_auth: &str, +) -> Result { if basic_auth.is_empty() { return Ok(request); } - + let auth_parts: Vec<&str> = basic_auth.splitn(2, ':').collect(); if auth_parts.len() == 2 { Ok(request.basic_auth(auth_parts[0], Some(auth_parts[1]))) @@ -218,19 +314,25 @@ fn add_auth_if_needed(request: reqwest::RequestBuilder, basic_auth: &str) -> Res 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()); + 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"))); + return Err(format!( + "Server returned error: {} {}", + status, + response.status().canonical_reason().unwrap_or("Unknown") + )); } - + Ok(()) } diff --git a/src/gui/common/settings.rs b/src/gui/common/settings.rs index c30f34e..a902a2f 100644 --- a/src/gui/common/settings.rs +++ b/src/gui/common/settings.rs @@ -29,16 +29,11 @@ impl Default for KhmSettings { /// Get default known_hosts file path based on OS fn get_default_known_hosts_path() -> String { - #[cfg(target_os = "windows")] - { - if let Ok(user_profile) = std::env::var("USERPROFILE") { - format!("{}/.ssh/known_hosts", user_profile) - } else { - "~/.ssh/known_hosts".to_string() - } - } - #[cfg(not(target_os = "windows"))] - { + if let Some(home) = home_dir() { + let ssh_dir = home.join(".ssh"); + let known_hosts_file = ssh_dir.join("known_hosts"); + known_hosts_file.to_string_lossy().to_string() + } else { "~/.ssh/known_hosts".to_string() } } @@ -61,12 +56,12 @@ pub fn load_settings() -> KhmSettings { error!("Failed to parse KHM config: {}", e); KhmSettings::default() }); - + // Fill in default known_hosts path if empty if settings.known_hosts.is_empty() { settings.known_hosts = get_default_known_hosts_path(); } - + settings } Err(_) => { @@ -98,10 +93,12 @@ pub fn expand_path(path: &str) -> String { /// Perform sync operation using KHM client logic pub async fn perform_sync(settings: &KhmSettings) -> Result { use crate::Args; - - info!("Starting sync with settings: host={}, flow={}, known_hosts={}, in_place={}", - settings.host, settings.flow, settings.known_hosts, settings.in_place); - + + info!( + "Starting sync with settings: host={}, flow={}, known_hosts={}, in_place={}", + settings.host, settings.flow, settings.known_hosts, settings.in_place + ); + // Convert KhmSettings to Args for client module let args = Args { server: false, @@ -109,27 +106,27 @@ pub async fn perform_sync(settings: &KhmSettings) -> Result Result std::io::Result<()> { pub async fn run_gui() -> std::io::Result<()> { return Err(std::io::Error::new( std::io::ErrorKind::Unsupported, - "GUI features not compiled. Install system dependencies and rebuild with --features gui" + "GUI features not compiled. Install system dependencies and rebuild with --features gui", )); } diff --git a/src/gui/settings/connection.rs b/src/gui/settings/connection.rs index 83fc6be..162de98 100644 --- a/src/gui/settings/connection.rs +++ b/src/gui/settings/connection.rs @@ -1,8 +1,8 @@ +use crate::gui::api::{perform_manual_sync, test_connection}; +use crate::gui::common::{save_settings, KhmSettings}; use eframe::egui; use log::{error, info}; use std::sync::mpsc; -use crate::gui::api::{test_connection, perform_manual_sync}; -use crate::gui::common::{KhmSettings, save_settings}; #[derive(Debug, Clone)] pub enum ConnectionStatus { @@ -31,6 +31,7 @@ pub struct ConnectionTab { pub is_syncing: bool, pub sync_result_receiver: Option>>, pub sync_status: SyncStatus, + pub should_auto_test: bool, } impl Default for ConnectionTab { @@ -42,6 +43,7 @@ impl Default for ConnectionTab { is_syncing: false, sync_result_receiver: None, sync_status: SyncStatus::Unknown, + should_auto_test: false, } } } @@ -52,57 +54,63 @@ impl ConnectionTab { if self.is_testing_connection { return; } - + self.is_testing_connection = true; self.connection_status = ConnectionStatus::Unknown; - + let (tx, rx) = mpsc::channel(); self.test_result_receiver = Some(rx); - + let host = settings.host.clone(); let flow = settings.flow.clone(); let basic_auth = settings.basic_auth.clone(); let ctx_clone = ctx.clone(); - + std::thread::spawn(move || { let rt = tokio::runtime::Runtime::new().unwrap(); - let result = rt.block_on(async { - test_connection(host, flow, basic_auth).await - }); - + let result = rt.block_on(async { test_connection(host, flow, basic_auth).await }); + let _ = tx.send(result); ctx_clone.request_repaint(); }); } - + /// Start manual sync pub fn start_sync(&mut self, settings: &KhmSettings, ctx: &egui::Context) { if self.is_syncing { return; } - + self.is_syncing = true; self.sync_status = SyncStatus::Unknown; - + let (tx, rx) = mpsc::channel(); self.sync_result_receiver = Some(rx); - + let settings = settings.clone(); let ctx_clone = ctx.clone(); - + std::thread::spawn(move || { let rt = tokio::runtime::Runtime::new().unwrap(); - let result = rt.block_on(async { - perform_manual_sync(settings).await - }); - + let result = rt.block_on(async { perform_manual_sync(settings).await }); + let _ = tx.send(result); ctx_clone.request_repaint(); }); } - - /// Check for test/sync results - pub fn check_results(&mut self, ctx: &egui::Context, settings: &KhmSettings, operation_log: &mut Vec) { + + /// Check for test/sync results and handle auto-test + pub fn check_results( + &mut self, + ctx: &egui::Context, + settings: &KhmSettings, + operation_log: &mut Vec, + ) { + // Handle auto-test on first frame if needed + if self.should_auto_test && !self.is_testing_connection { + self.should_auto_test = false; + self.start_test(settings, ctx); + } // Check for test connection result if let Some(receiver) = &self.test_result_receiver { if let Ok(result) = receiver.try_recv() { @@ -112,32 +120,44 @@ impl ConnectionTab { // Parse keys count from message let keys_count = if let Some(start) = message.find("Found ") { if let Some(end) = message[start + 6..].find(" SSH keys") { - message[start + 6..start + 6 + end].parse::().unwrap_or(0) - } else { 0 } - } else { 0 }; - - self.connection_status = ConnectionStatus::Connected { - keys_count, - flow: settings.flow.clone() + message[start + 6..start + 6 + end] + .parse::() + .unwrap_or(0) + } else { + 0 + } + } else { + 0 + }; + + self.connection_status = ConnectionStatus::Connected { + keys_count, + flow: settings.flow.clone(), }; info!("Connection test successful: {}", message); - + // Add to UI log - super::ui::add_log_entry(operation_log, format!("āœ… Connection test successful: {}", message)); + super::ui::add_log_entry( + operation_log, + format!("āœ… Connection test successful: {}", message), + ); } Err(error) => { self.connection_status = ConnectionStatus::Error(error.clone()); error!("Connection test failed"); - + // Add to UI log - super::ui::add_log_entry(operation_log, format!("āŒ Connection test failed: {}", error)); + super::ui::add_log_entry( + operation_log, + format!("āŒ Connection test failed: {}", error), + ); } } self.test_result_receiver = None; ctx.request_repaint(); } } - + // Check for sync result if let Some(receiver) = &self.sync_result_receiver { if let Ok(result) = receiver.try_recv() { @@ -148,16 +168,22 @@ impl ConnectionTab { let keys_count = parse_keys_count(&message); self.sync_status = SyncStatus::Success { keys_count }; info!("Sync successful: {}", message); - + // Add to UI log - super::ui::add_log_entry(operation_log, format!("āœ… Sync completed: {}", message)); + super::ui::add_log_entry( + operation_log, + format!("āœ… Sync completed: {}", message), + ); } Err(error) => { self.sync_status = SyncStatus::Error(error.clone()); error!("Sync failed"); - + // Add to UI log - super::ui::add_log_entry(operation_log, format!("āŒ Sync failed: {}", error)); + super::ui::add_log_entry( + operation_log, + format!("āŒ Sync failed: {}", error), + ); } } self.sync_result_receiver = None; @@ -188,7 +214,7 @@ fn parse_keys_count(message: &str) -> usize { return number_str.parse::().unwrap_or(0); } } - + 0 } @@ -197,6 +223,6 @@ pub fn save_settings_validated(settings: &KhmSettings) -> Result<(), String> { if settings.host.is_empty() || settings.flow.is_empty() { return Err("Host URL and Flow Name are required".to_string()); } - + save_settings(settings).map_err(|e| format!("Failed to save settings: {}", e)) } diff --git a/src/gui/settings/ui.rs b/src/gui/settings/ui.rs index 58c897d..618208e 100644 --- a/src/gui/settings/ui.rs +++ b/src/gui/settings/ui.rs @@ -1,19 +1,19 @@ +use super::connection::{save_settings_validated, ConnectionStatus, ConnectionTab, SyncStatus}; +use crate::gui::common::{get_config_path, KhmSettings}; use eframe::egui; -use crate::gui::common::{KhmSettings, get_config_path}; -use super::connection::{ConnectionTab, ConnectionStatus, SyncStatus, save_settings_validated}; /// Render connection settings tab with modern horizontal UI design pub fn render_connection_tab( - ui: &mut egui::Ui, + ui: &mut egui::Ui, ctx: &egui::Context, settings: &mut KhmSettings, auto_sync_interval_str: &mut String, connection_tab: &mut ConnectionTab, - operation_log: &mut Vec + operation_log: &mut Vec, ) { // Check for connection test and sync results connection_tab.check_results(ctx, settings, operation_log); - + // Use scrollable area for the entire content egui::ScrollArea::vertical() .auto_shrink([false; 2]) @@ -21,16 +21,16 @@ pub fn render_connection_tab( ui.spacing_mut().item_spacing = egui::vec2(6.0, 8.0); ui.spacing_mut().button_padding = egui::vec2(12.0, 6.0); ui.spacing_mut().indent = 16.0; - + // Connection Status Card at top (full width) render_connection_status_card(ui, connection_tab); - + // Main configuration area - horizontal layout ui.horizontal_top(|ui| { let available_width = ui.available_width(); let left_panel_width = available_width * 0.6; let right_panel_width = available_width * 0.38; - + // Left panel - Connection and Local config ui.allocate_ui_with_layout( [left_panel_width, ui.available_height()].into(), @@ -38,14 +38,14 @@ pub fn render_connection_tab( |ui| { // Connection Configuration Card render_connection_config_card(ui, settings); - + // Local Configuration Card render_local_config_card(ui, settings); - } + }, ); - + ui.add_space(8.0); - + // Right panel - Auto-sync and System info ui.allocate_ui_with_layout( [right_panel_width, ui.available_height()].into(), @@ -53,15 +53,15 @@ pub fn render_connection_tab( |ui| { // Auto-sync Configuration Card render_auto_sync_card(ui, settings, auto_sync_interval_str); - + // System Information Card render_system_info_card(ui); - } + }, ); }); - + ui.add_space(12.0); - + // Action buttons at bottom render_action_section(ui, ctx, settings, connection_tab, operation_log); }); @@ -71,10 +71,13 @@ pub fn render_connection_tab( fn render_connection_status_card(ui: &mut egui::Ui, connection_tab: &ConnectionTab) { let frame = egui::Frame::group(ui.style()) .fill(ui.visuals().faint_bg_color) - .stroke(egui::Stroke::new(1.0, ui.visuals().widgets.noninteractive.bg_stroke.color)) + .stroke(egui::Stroke::new( + 1.0, + ui.visuals().widgets.noninteractive.bg_stroke.color, + )) .rounding(6.0) .inner_margin(egui::Margin::same(12.0)); - + frame.show(ui, |ui| { // Header with status indicator ui.horizontal(|ui| { @@ -85,56 +88,75 @@ fn render_connection_status_card(ui: &mut egui::Ui, connection_tab: &ConnectionT } else { format!("Connected to '{}' • {} keys", flow, keys_count) }; - ("🟢", text, egui::Color32::GREEN) - } - ConnectionStatus::Error(error_msg) => { - ("šŸ”“", format!("Connection Error: {}", error_msg), egui::Color32::RED) + ("āœ…", text, egui::Color32::GREEN) } + ConnectionStatus::Error(error_msg) => ( + "āŒ", + format!("Connection Error: {}", error_msg), + egui::Color32::RED, + ), ConnectionStatus::Unknown => { ("⚫", "Not Connected".to_string(), ui.visuals().text_color()) } }; - + ui.label(egui::RichText::new(status_icon).size(14.0)); ui.label(egui::RichText::new("Connection Status").size(14.0).strong()); - + ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| { if connection_tab.is_testing_connection { ui.spinner(); - ui.label(egui::RichText::new("Testing...").italics().color(ui.visuals().weak_text_color())); + ui.label( + egui::RichText::new("Testing...") + .italics() + .color(ui.visuals().weak_text_color()), + ); } else { - ui.label(egui::RichText::new(&status_text).size(13.0).color(status_color)); + ui.label( + egui::RichText::new(&status_text) + .size(13.0) + .color(status_color), + ); } }); }); - + // Sync status - always visible ui.add_space(6.0); ui.separator(); ui.add_space(6.0); - + ui.horizontal(|ui| { ui.label("šŸ”„"); ui.label("Last Sync:"); - + match &connection_tab.sync_status { SyncStatus::Success { keys_count } => { - ui.label(egui::RichText::new(format!("āœ… {} keys synced", keys_count)) - .size(13.0).color(egui::Color32::GREEN)); + ui.label( + egui::RichText::new(format!("āœ… {} keys synced", keys_count)) + .size(13.0) + .color(egui::Color32::GREEN), + ); } SyncStatus::Error(error_msg) => { - ui.label(egui::RichText::new("āŒ Failed") - .size(13.0).color(egui::Color32::RED)) - .on_hover_text(error_msg); + ui.label( + egui::RichText::new("āŒ Failed") + .size(13.0) + .color(egui::Color32::RED), + ) + .on_hover_text(error_msg); } SyncStatus::Unknown => { - ui.label(egui::RichText::new("No sync performed yet") - .size(13.0).color(ui.visuals().weak_text_color())); + ui.label( + egui::RichText::new("No sync performed yet") + .size(13.0) + .color(ui.visuals().weak_text_color()), + ); } } }); }); - + ui.add_space(8.0); } @@ -142,23 +164,30 @@ fn render_connection_status_card(ui: &mut egui::Ui, connection_tab: &ConnectionT fn render_connection_config_card(ui: &mut egui::Ui, settings: &mut KhmSettings) { let frame = egui::Frame::group(ui.style()) .fill(ui.visuals().faint_bg_color) - .stroke(egui::Stroke::new(1.0, ui.visuals().widgets.noninteractive.bg_stroke.color)) + .stroke(egui::Stroke::new( + 1.0, + ui.visuals().widgets.noninteractive.bg_stroke.color, + )) .rounding(6.0) .inner_margin(egui::Margin::same(12.0)); - + frame.show(ui, |ui| { // Header ui.horizontal(|ui| { ui.label("🌐"); - ui.label(egui::RichText::new("Server Configuration").size(14.0).strong()); + ui.label( + egui::RichText::new("Server Configuration") + .size(14.0) + .strong(), + ); }); - + ui.add_space(8.0); - + // Input fields with better spacing ui.vertical(|ui| { ui.spacing_mut().item_spacing.y = 8.0; - + // Host URL ui.vertical(|ui| { ui.label(egui::RichText::new("Host URL").size(13.0).strong()); @@ -168,10 +197,10 @@ fn render_connection_config_card(ui: &mut egui::Ui, settings: &mut KhmSettings) egui::TextEdit::singleline(&mut settings.host) .hint_text("https://your-khm-server.com") .font(egui::FontId::new(14.0, egui::FontFamily::Monospace)) - .margin(egui::Margin::symmetric(8.0, 6.0)) // Better vertical centering + .margin(egui::Margin::symmetric(8.0, 6.0)), // Better vertical centering ); }); - + // Flow Name ui.vertical(|ui| { ui.label(egui::RichText::new("Flow Name").size(13.0).strong()); @@ -181,15 +210,24 @@ fn render_connection_config_card(ui: &mut egui::Ui, settings: &mut KhmSettings) egui::TextEdit::singleline(&mut settings.flow) .hint_text("production, staging, development") .font(egui::FontId::new(14.0, egui::FontFamily::Proportional)) - .margin(egui::Margin::symmetric(8.0, 6.0)) + .margin(egui::Margin::symmetric(8.0, 6.0)), ); }); - + // Basic Auth (optional) ui.vertical(|ui| { ui.horizontal(|ui| { - ui.label(egui::RichText::new("Basic Authentication").size(13.0).strong()); - ui.label(egui::RichText::new("(optional)").size(12.0).weak().italics()); + ui.label( + egui::RichText::new("Basic Authentication") + .size(13.0) + .strong(), + ); + ui.label( + egui::RichText::new("(optional)") + .size(12.0) + .weak() + .italics(), + ); }); ui.add_space(3.0); ui.add_sized( @@ -198,12 +236,12 @@ fn render_connection_config_card(ui: &mut egui::Ui, settings: &mut KhmSettings) .hint_text("username:password") .password(true) .font(egui::FontId::new(14.0, egui::FontFamily::Monospace)) - .margin(egui::Margin::symmetric(8.0, 6.0)) + .margin(egui::Margin::symmetric(8.0, 6.0)), ); }); }); }); - + ui.add_space(8.0); } @@ -211,90 +249,122 @@ fn render_connection_config_card(ui: &mut egui::Ui, settings: &mut KhmSettings) fn render_local_config_card(ui: &mut egui::Ui, settings: &mut KhmSettings) { let frame = egui::Frame::group(ui.style()) .fill(ui.visuals().faint_bg_color) - .stroke(egui::Stroke::new(1.0, ui.visuals().widgets.noninteractive.bg_stroke.color)) + .stroke(egui::Stroke::new( + 1.0, + ui.visuals().widgets.noninteractive.bg_stroke.color, + )) .rounding(6.0) .inner_margin(egui::Margin::same(12.0)); - + frame.show(ui, |ui| { // Header ui.horizontal(|ui| { ui.label("šŸ“"); - ui.label(egui::RichText::new("Local Configuration").size(14.0).strong()); + ui.label( + egui::RichText::new("Local Configuration") + .size(14.0) + .strong(), + ); }); - + ui.add_space(8.0); - + // Known hosts file ui.vertical(|ui| { - ui.label(egui::RichText::new("Known Hosts File Path").size(13.0).strong()); + ui.label( + egui::RichText::new("Known Hosts File Path") + .size(13.0) + .strong(), + ); ui.add_space(3.0); ui.add_sized( [ui.available_width(), 28.0], egui::TextEdit::singleline(&mut settings.known_hosts) .hint_text("~/.ssh/known_hosts") .font(egui::FontId::new(14.0, egui::FontFamily::Monospace)) - .margin(egui::Margin::symmetric(8.0, 6.0)) + .margin(egui::Margin::symmetric(8.0, 6.0)), ); - + ui.add_space(8.0); - + // In-place update option with better styling ui.horizontal(|ui| { ui.checkbox(&mut settings.in_place, ""); ui.vertical(|ui| { - ui.label(egui::RichText::new("Update file in-place after sync").size(13.0).strong()); - ui.label(egui::RichText::new("Automatically modify the known_hosts file when synchronizing").size(12.0).weak().italics()); + ui.label( + egui::RichText::new("Update file in-place after sync") + .size(13.0) + .strong(), + ); + ui.label( + egui::RichText::new( + "Automatically modify the known_hosts file when synchronizing", + ) + .size(12.0) + .weak() + .italics(), + ); }); }); }); }); - + ui.add_space(8.0); } /// Auto-sync configuration card -fn render_auto_sync_card(ui: &mut egui::Ui, settings: &mut KhmSettings, auto_sync_interval_str: &mut String) { +fn render_auto_sync_card( + ui: &mut egui::Ui, + settings: &mut KhmSettings, + auto_sync_interval_str: &mut String, +) { let frame = egui::Frame::group(ui.style()) .fill(ui.visuals().faint_bg_color) - .stroke(egui::Stroke::new(1.0, ui.visuals().widgets.noninteractive.bg_stroke.color)) + .stroke(egui::Stroke::new( + 1.0, + ui.visuals().widgets.noninteractive.bg_stroke.color, + )) .rounding(6.0) .inner_margin(egui::Margin::same(12.0)); - + frame.show(ui, |ui| { - let is_auto_sync_enabled = !settings.host.is_empty() - && !settings.flow.is_empty() - && settings.in_place; - + let is_auto_sync_enabled = + !settings.host.is_empty() && !settings.flow.is_empty() && settings.in_place; + // Header with status ui.horizontal(|ui| { ui.label("šŸ”„"); ui.label(egui::RichText::new("Auto Sync").size(14.0).strong()); - + ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| { let (status_text, status_color) = if is_auto_sync_enabled { - ("ā— Active", egui::Color32::GREEN) + ("āœ… Active", egui::Color32::GREEN) } else { - ("ā—‹ Inactive", egui::Color32::from_gray(128)) + ("āŒ Inactive", egui::Color32::from_gray(128)) }; - - ui.label(egui::RichText::new(status_text).size(12.0).color(status_color)); + + ui.label( + egui::RichText::new(status_text) + .size(12.0) + .color(status_color), + ); }); }); - + ui.add_space(8.0); - + // Interval setting ui.horizontal(|ui| { ui.label(egui::RichText::new("Interval").size(13.0).strong()); ui.add_space(6.0); ui.add_sized( - [80.0, 26.0], // Smaller height - egui::TextEdit::singleline(auto_sync_interval_str) - .font(egui::FontId::new(14.0, egui::FontFamily::Monospace)) - .margin(egui::Margin::symmetric(6.0, 5.0)) - ); + [80.0, 26.0], // Smaller height + egui::TextEdit::singleline(auto_sync_interval_str) + .font(egui::FontId::new(14.0, egui::FontFamily::Monospace)) + .margin(egui::Margin::symmetric(6.0, 5.0)), + ); ui.label("min"); - + // Update the actual setting if let Ok(value) = auto_sync_interval_str.parse::() { if value > 0 { @@ -302,40 +372,52 @@ fn render_auto_sync_card(ui: &mut egui::Ui, settings: &mut KhmSettings, auto_syn } } }); - + // Requirements - always visible ui.add_space(8.0); ui.separator(); ui.add_space(8.0); - + ui.vertical(|ui| { ui.label(egui::RichText::new("Requirements:").size(12.0).strong()); ui.add_space(3.0); - + let host_ok = !settings.host.is_empty(); let flow_ok = !settings.flow.is_empty(); let in_place_ok = settings.in_place; - + ui.horizontal(|ui| { - let (icon, color) = if host_ok { ("āœ…", egui::Color32::GREEN) } else { ("āŒ", egui::Color32::RED) }; + let (icon, color) = if host_ok { + ("āœ…", egui::Color32::GREEN) + } else { + ("āŒ", egui::Color32::RED) + }; ui.label(egui::RichText::new(icon).color(color)); ui.label(egui::RichText::new("Host URL").size(11.0)); }); - + ui.horizontal(|ui| { - let (icon, color) = if flow_ok { ("āœ…", egui::Color32::GREEN) } else { ("āŒ", egui::Color32::RED) }; + let (icon, color) = if flow_ok { + ("āœ…", egui::Color32::GREEN) + } else { + ("āŒ", egui::Color32::RED) + }; ui.label(egui::RichText::new(icon).color(color)); ui.label(egui::RichText::new("Flow name").size(11.0)); }); - + ui.horizontal(|ui| { - let (icon, color) = if in_place_ok { ("āœ…", egui::Color32::GREEN) } else { ("āŒ", egui::Color32::RED) }; + let (icon, color) = if in_place_ok { + ("āœ…", egui::Color32::GREEN) + } else { + ("āŒ", egui::Color32::RED) + }; ui.label(egui::RichText::new(icon).color(color)); ui.label(egui::RichText::new("In-place update").size(11.0)); }); }); }); - + ui.add_space(8.0); } @@ -343,103 +425,110 @@ fn render_auto_sync_card(ui: &mut egui::Ui, settings: &mut KhmSettings, auto_syn fn render_system_info_card(ui: &mut egui::Ui) { let frame = egui::Frame::group(ui.style()) .fill(ui.visuals().extreme_bg_color) - .stroke(egui::Stroke::new(1.0, ui.visuals().widgets.noninteractive.bg_stroke.color)) + .stroke(egui::Stroke::new( + 1.0, + ui.visuals().widgets.noninteractive.bg_stroke.color, + )) .rounding(6.0) .inner_margin(egui::Margin::same(12.0)); - + frame.show(ui, |ui| { // Header ui.horizontal(|ui| { - ui.label("āš™ļø"); + ui.label("šŸ”§"); ui.label(egui::RichText::new("System Info").size(14.0).strong()); }); - + ui.add_space(8.0); - + // Config file location ui.vertical(|ui| { ui.label(egui::RichText::new("Config File").size(13.0).strong()); ui.add_space(3.0); - + let config_path = get_config_path(); let path_str = config_path.display().to_string(); - + ui.vertical(|ui| { ui.add_sized( - [ui.available_width(), 26.0], // Smaller height - egui::TextEdit::singleline(&mut path_str.clone()) - .interactive(false) - .font(egui::FontId::new(12.0, egui::FontFamily::Monospace)) - .margin(egui::Margin::symmetric(8.0, 5.0)) - ); - + [ui.available_width(), 26.0], // Smaller height + egui::TextEdit::singleline(&mut path_str.clone()) + .interactive(false) + .font(egui::FontId::new(12.0, egui::FontFamily::Monospace)) + .margin(egui::Margin::symmetric(8.0, 5.0)), + ); + ui.add_space(4.0); - + if ui.small_button("šŸ“‹ Copy Path").clicked() { ui.output_mut(|o| o.copied_text = path_str); } }); }); }); - + ui.add_space(8.0); } /// Action section with buttons only (Activity Log moved to bottom panel) fn render_action_section( - ui: &mut egui::Ui, + ui: &mut egui::Ui, ctx: &egui::Context, settings: &KhmSettings, connection_tab: &mut ConnectionTab, - operation_log: &mut Vec + operation_log: &mut Vec, ) { - ui.add_space(8.0); - - // Validation message + ui.add_space(2.0); + + // Validation for save button let save_enabled = !settings.host.is_empty() && !settings.flow.is_empty(); - if !save_enabled { - ui.horizontal(|ui| { - ui.label("āš ļø"); - ui.label(egui::RichText::new("Complete server configuration to enable saving") - .size(12.0) - .color(egui::Color32::LIGHT_YELLOW) - .italics()); - }); - ui.add_space(8.0); - } - + // Action buttons with modern styling - render_modern_action_buttons(ui, ctx, settings, connection_tab, save_enabled, operation_log); + render_modern_action_buttons( + ui, + ctx, + settings, + connection_tab, + save_enabled, + operation_log, + ); } /// Modern action buttons with improved styling and layout fn render_modern_action_buttons( - ui: &mut egui::Ui, + ui: &mut egui::Ui, ctx: &egui::Context, settings: &KhmSettings, connection_tab: &mut ConnectionTab, save_enabled: bool, - operation_log: &mut Vec + operation_log: &mut Vec, ) { ui.horizontal(|ui| { ui.spacing_mut().item_spacing.x = 8.0; - + // Primary actions (left side) - if ui.add_enabled( + let mut save_button = ui.add_enabled( save_enabled, egui::Button::new( egui::RichText::new("šŸ’¾ Save & Close") .size(13.0) .color(egui::Color32::WHITE) ) - .fill(if save_enabled { - egui::Color32::from_rgb(0, 120, 212) - } else { - ui.visuals().widgets.inactive.bg_fill + .fill(if save_enabled { + egui::Color32::from_rgb(0, 120, 212) + } else { + ui.visuals().widgets.inactive.bg_fill }) .min_size(egui::vec2(120.0, 32.0)) .rounding(6.0) - ).clicked() { + ); + + // Add tooltip when button is disabled + if !save_enabled { + save_button = save_button.on_hover_text("Complete server configuration to enable saving:\n• Host URL is required\n• Flow name is required"); + } + + if save_button.clicked() { match save_settings_validated(settings) { Ok(()) => { add_log_entry(operation_log, "āœ… Settings saved successfully".to_string()); @@ -450,7 +539,7 @@ fn render_modern_action_buttons( } } } - + if ui.add( egui::Button::new( egui::RichText::new("āœ– Cancel") @@ -464,14 +553,14 @@ fn render_modern_action_buttons( ).clicked() { ctx.send_viewport_cmd(egui::ViewportCommand::Close); } - + // Spacer ui.add_space(ui.available_width() - 220.0); - + // Secondary actions (right side) let can_test = !settings.host.is_empty() && !settings.flow.is_empty() && !connection_tab.is_testing_connection; let can_sync = !settings.host.is_empty() && !settings.flow.is_empty() && !connection_tab.is_syncing; - + if ui.add_enabled( can_test, egui::Button::new( @@ -485,10 +574,10 @@ fn render_modern_action_buttons( .size(13.0) .color(egui::Color32::WHITE) ) - .fill(if can_test { - egui::Color32::from_rgb(16, 124, 16) - } else { - ui.visuals().widgets.inactive.bg_fill + .fill(if can_test { + egui::Color32::from_rgb(16, 124, 16) + } else { + ui.visuals().widgets.inactive.bg_fill }) .min_size(egui::vec2(80.0, 32.0)) .rounding(6.0) @@ -496,7 +585,7 @@ fn render_modern_action_buttons( add_log_entry(operation_log, "šŸ” Testing connection...".to_string()); connection_tab.start_test(settings, ctx); } - + if ui.add_enabled( can_sync, egui::Button::new( @@ -510,10 +599,10 @@ fn render_modern_action_buttons( .size(13.0) .color(egui::Color32::WHITE) ) - .fill(if can_sync { - egui::Color32::from_rgb(255, 140, 0) - } else { - ui.visuals().widgets.inactive.bg_fill + .fill(if can_sync { + egui::Color32::from_rgb(255, 140, 0) + } else { + ui.visuals().widgets.inactive.bg_fill }) .min_size(egui::vec2(80.0, 32.0)) .rounding(6.0) @@ -531,17 +620,17 @@ pub fn add_log_entry(operation_log: &mut Vec, message: String) { .unwrap(); let secs = now.as_secs(); let millis = now.subsec_millis(); - + // Format as HH:MM:SS.mmm let hours = (secs / 3600) % 24; let minutes = (secs / 60) % 60; let seconds = secs % 60; let timestamp = format!("{:02}:{:02}:{:02}.{:03}", hours, minutes, seconds, millis); - + let log_entry = format!("{} {}", timestamp, message); - + operation_log.push(log_entry); - + // Keep only last 20 entries to prevent memory growth if operation_log.len() > 20 { operation_log.remove(0); diff --git a/src/gui/settings/window.rs b/src/gui/settings/window.rs index c69eafc..fa83cf4 100644 --- a/src/gui/settings/window.rs +++ b/src/gui/settings/window.rs @@ -1,14 +1,17 @@ +use crate::gui::admin::{ + render_bulk_actions, render_keys_table, render_search_controls, render_statistics, + AdminOperation, AdminState, BulkAction, KeyAction, +}; +use crate::gui::api::{ + bulk_deprecate_servers, bulk_restore_servers, delete_key, deprecate_key, restore_key, SshKey, +}; +use crate::gui::common::{load_settings, KhmSettings}; use eframe::egui; use log::info; use std::sync::mpsc; -use crate::gui::common::{load_settings, KhmSettings}; -use crate::gui::admin::{AdminState, AdminOperation, render_statistics, render_search_controls, - render_bulk_actions, render_keys_table, KeyAction, BulkAction}; -use crate::gui::api::{SshKey, bulk_deprecate_servers, bulk_restore_servers, - deprecate_key, restore_key, delete_key}; use super::connection::{ConnectionTab, SettingsTab}; -use super::ui::{render_connection_tab, add_log_entry}; +use super::ui::{add_log_entry, render_connection_tab}; pub struct SettingsWindow { settings: KhmSettings, @@ -25,8 +28,8 @@ impl SettingsWindow { pub fn new() -> Self { let settings = load_settings(); let auto_sync_interval_str = settings.auto_sync_interval_minutes.to_string(); - - Self { + + let mut instance = Self { settings, auto_sync_interval_str, current_tab: SettingsTab::Connection, @@ -35,7 +38,20 @@ impl SettingsWindow { admin_receiver: None, operation_receiver: None, operation_log: Vec::new(), + }; + + // Auto-test connection if configuration is found and valid + if !instance.settings.host.is_empty() && !instance.settings.flow.is_empty() { + add_log_entry( + &mut instance.operation_log, + "šŸ” Auto-testing connection with saved configuration...".to_string(), + ); + // We can't call start_test here because we don't have egui::Context yet + // So we set a flag to trigger test on first frame + instance.connection_tab.should_auto_test = true; } + + instance } } @@ -43,27 +59,29 @@ impl eframe::App for SettingsWindow { fn update(&mut self, ctx: &egui::Context, _frame: &mut eframe::Frame) { // Check for admin operation results self.check_admin_results(ctx); - + // Apply enhanced modern dark theme apply_modern_theme(ctx); - + // Bottom panel for Activity Log (fixed at bottom) egui::TopBottomPanel::bottom("activity_log_panel") .resizable(false) .min_height(140.0) .max_height(140.0) - .frame(egui::Frame::none() - .fill(egui::Color32::from_gray(12)) - .stroke(egui::Stroke::new(1.0, egui::Color32::from_gray(60))) + .frame( + egui::Frame::none() + .fill(egui::Color32::from_gray(12)) + .stroke(egui::Stroke::new(1.0, egui::Color32::from_gray(60))), ) .show(ctx, |ui| { render_bottom_activity_log(ui, &mut self.operation_log); }); - + egui::CentralPanel::default() - .frame(egui::Frame::none() - .fill(egui::Color32::from_gray(18)) - .inner_margin(egui::Margin::same(20.0)) + .frame( + egui::Frame::none() + .fill(egui::Color32::from_gray(18)) + .inner_margin(egui::Margin::same(20.0)), ) .show(ctx, |ui| { // Modern header with gradient-like styling @@ -71,34 +89,43 @@ impl eframe::App for SettingsWindow { .fill(ui.visuals().panel_fill) .rounding(egui::Rounding::same(8.0)) .inner_margin(egui::Margin::same(12.0)) - .stroke(egui::Stroke::new(1.0, ui.visuals().widgets.noninteractive.bg_stroke.color)); - + .stroke(egui::Stroke::new( + 1.0, + ui.visuals().widgets.noninteractive.bg_stroke.color, + )); + header_frame.show(ui, |ui| { ui.horizontal(|ui| { ui.add_space(4.0); ui.label("šŸ”‘"); ui.heading(egui::RichText::new("KHM Settings").size(20.0).strong()); - ui.label(egui::RichText::new( - "(Known Hosts Manager for SSH key management and synchronization)" - ).size(11.0).weak().italics()); - + ui.label( + egui::RichText::new( + "(Known Hosts Manager for SSH key management and synchronization)", + ) + .size(11.0) + .weak() + .italics(), + ); + ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| { // Version from Cargo.toml let version = env!("CARGO_PKG_VERSION"); - if ui.small_button(format!("v{}", version)) + if ui + .small_button(format!("v{}", version)) .on_hover_text(format!( - "{}\n{}\nRepository: {}\nLicense: {}", + "{}\n{}\nRepository: {}\nLicense: {}", env!("CARGO_PKG_DESCRIPTION"), env!("CARGO_PKG_AUTHORS"), env!("CARGO_PKG_REPOSITORY"), "WTFPL" )) - .clicked() + .clicked() { // Open repository URL if let Err(_) = std::process::Command::new("open") .arg(env!("CARGO_PKG_REPOSITORY")) - .spawn() + .spawn() { // Fallback for non-macOS systems let _ = std::process::Command::new("xdg-open") @@ -109,70 +136,74 @@ impl eframe::App for SettingsWindow { }); }); }); - + ui.add_space(12.0); - + // Modern tab selector with card styling ui.horizontal(|ui| { ui.spacing_mut().item_spacing.x = 6.0; - + // Connection/Settings Tab let connection_selected = matches!(self.current_tab, SettingsTab::Connection); - let connection_button = egui::Button::new( - egui::RichText::new("🌐 Connection").size(13.0) - ) - .fill(if connection_selected { - egui::Color32::from_rgb(0, 120, 212) - } else { - ui.visuals().widgets.inactive.bg_fill - }) - .stroke(if connection_selected { - egui::Stroke::new(1.0, egui::Color32::from_rgb(0, 120, 212)) - } else { - egui::Stroke::new(1.0, ui.visuals().widgets.noninteractive.bg_stroke.color) - }) - .rounding(6.0) - .min_size(egui::vec2(110.0, 32.0)); - + let connection_button = + egui::Button::new(egui::RichText::new("🌐 Connection").size(13.0)) + .fill(if connection_selected { + egui::Color32::from_rgb(0, 120, 212) + } else { + ui.visuals().widgets.inactive.bg_fill + }) + .stroke(if connection_selected { + egui::Stroke::new(1.0, egui::Color32::from_rgb(0, 120, 212)) + } else { + egui::Stroke::new( + 1.0, + ui.visuals().widgets.noninteractive.bg_stroke.color, + ) + }) + .rounding(6.0) + .min_size(egui::vec2(110.0, 32.0)); + if ui.add(connection_button).clicked() { self.current_tab = SettingsTab::Connection; } - + // Admin Tab let admin_selected = matches!(self.current_tab, SettingsTab::Admin); - let admin_button = egui::Button::new( - egui::RichText::new("šŸ”§ Admin Panel").size(13.0) - ) - .fill(if admin_selected { - egui::Color32::from_rgb(120, 80, 0) - } else { - ui.visuals().widgets.inactive.bg_fill - }) - .stroke(if admin_selected { - egui::Stroke::new(1.0, egui::Color32::from_rgb(120, 80, 0)) - } else { - egui::Stroke::new(1.0, ui.visuals().widgets.noninteractive.bg_stroke.color) - }) - .rounding(6.0) - .min_size(egui::vec2(110.0, 32.0)); - + let admin_button = + egui::Button::new(egui::RichText::new("šŸ”§ Admin Panel").size(13.0)) + .fill(if admin_selected { + egui::Color32::from_rgb(120, 80, 0) + } else { + ui.visuals().widgets.inactive.bg_fill + }) + .stroke(if admin_selected { + egui::Stroke::new(1.0, egui::Color32::from_rgb(120, 80, 0)) + } else { + egui::Stroke::new( + 1.0, + ui.visuals().widgets.noninteractive.bg_stroke.color, + ) + }) + .rounding(6.0) + .min_size(egui::vec2(110.0, 32.0)); + if ui.add(admin_button).clicked() { self.current_tab = SettingsTab::Admin; } }); - + ui.add_space(16.0); - + // Content area with proper spacing match self.current_tab { SettingsTab::Connection => { render_connection_tab( - ui, - ctx, - &mut self.settings, + ui, + ctx, + &mut self.settings, &mut self.auto_sync_interval_str, &mut self.connection_tab, - &mut self.operation_log + &mut self.operation_log, ); } SettingsTab::Admin => { @@ -193,7 +224,7 @@ impl SettingsWindow { ctx.request_repaint(); } } - + // Check for operation results if let Some(receiver) = &self.operation_receiver { if let Ok(result) = receiver.try_recv() { @@ -205,7 +236,10 @@ impl SettingsWindow { self.load_admin_keys(ctx); } Err(error) => { - add_log_entry(&mut self.operation_log, format!("āŒ Operation failed: {}", error)); + add_log_entry( + &mut self.operation_log, + format!("āŒ Operation failed: {}", error), + ); } } self.admin_state.current_operation = AdminOperation::None; @@ -214,33 +248,35 @@ impl SettingsWindow { } } } - + fn render_admin_tab(&mut self, ui: &mut egui::Ui, ctx: &egui::Context) { // Admin tab header ui.horizontal(|ui| { ui.label(egui::RichText::new("šŸ”§ Admin Panel").size(18.0).strong()); - + ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| { if ui.button("šŸ” Refresh").clicked() { self.load_admin_keys(ctx); } - + if let Some(last_load) = self.admin_state.last_load_time { let elapsed = last_load.elapsed().as_secs(); ui.label(format!("Updated {}s ago", elapsed)); } }); }); - + ui.separator(); ui.add_space(10.0); - + // Check if connection is configured if self.settings.host.is_empty() || self.settings.flow.is_empty() { ui.vertical_centered(|ui| { - ui.label(egui::RichText::new("ā— Please configure connection settings first") - .size(16.0) - .color(egui::Color32::YELLOW)); + ui.label( + egui::RichText::new("ā— Please configure connection settings first") + .size(16.0) + .color(egui::Color32::YELLOW), + ); ui.add_space(10.0); if ui.button("Go to Connection Settings").clicked() { self.current_tab = SettingsTab::Connection; @@ -248,37 +284,45 @@ impl SettingsWindow { }); return; } - + // Load keys automatically on first view - if self.admin_state.keys.is_empty() && !matches!(self.admin_state.current_operation, AdminOperation::LoadingKeys) { + if self.admin_state.keys.is_empty() + && !matches!( + self.admin_state.current_operation, + AdminOperation::LoadingKeys + ) + { self.load_admin_keys(ctx); } - + // Show loading state - if matches!(self.admin_state.current_operation, AdminOperation::LoadingKeys) { + if matches!( + self.admin_state.current_operation, + AdminOperation::LoadingKeys + ) { ui.vertical_centered(|ui| { ui.spinner(); ui.label("Loading keys..."); }); return; } - + // Statistics section render_statistics(ui, &self.admin_state); ui.add_space(10.0); - + // Search and filters render_search_controls(ui, &mut self.admin_state); ui.add_space(10.0); - + // Bulk actions let bulk_action = render_bulk_actions(ui, &mut self.admin_state); self.handle_bulk_action(bulk_action, ctx); - + if self.admin_state.selected_servers.values().any(|&v| v) { ui.add_space(8.0); } - + // Keys table egui::ScrollArea::vertical() .max_height(450.0) @@ -288,13 +332,13 @@ impl SettingsWindow { self.handle_key_action(key_action, ctx); }); } - + fn load_admin_keys(&mut self, ctx: &egui::Context) { if let Some(receiver) = self.admin_state.load_keys(&self.settings, ctx) { self.admin_receiver = Some(receiver); } } - + fn handle_bulk_action(&mut self, action: BulkAction, ctx: &egui::Context) { match action { BulkAction::DeprecateSelected => { @@ -315,7 +359,7 @@ impl SettingsWindow { BulkAction::None => {} } } - + fn handle_key_action(&mut self, action: KeyAction, ctx: &egui::Context) { match action { KeyAction::DeprecateKey(server) | KeyAction::DeprecateServer(server) => { @@ -330,120 +374,130 @@ impl SettingsWindow { KeyAction::None => {} } } - + fn start_bulk_deprecate(&mut self, servers: Vec, ctx: &egui::Context) { self.admin_state.current_operation = AdminOperation::BulkDeprecating; - add_log_entry(&mut self.operation_log, format!("Deprecating {} servers...", servers.len())); - + add_log_entry( + &mut self.operation_log, + format!("Deprecating {} servers...", servers.len()), + ); + let (tx, rx) = mpsc::channel(); self.operation_receiver = Some(rx); - + let host = self.settings.host.clone(); let flow = self.settings.flow.clone(); let basic_auth = self.settings.basic_auth.clone(); let ctx_clone = ctx.clone(); - + std::thread::spawn(move || { let rt = tokio::runtime::Runtime::new().unwrap(); - let result = rt.block_on(async { - bulk_deprecate_servers(host, flow, basic_auth, servers).await - }); - + let result = rt + .block_on(async { bulk_deprecate_servers(host, flow, basic_auth, servers).await }); + let _ = tx.send(result); ctx_clone.request_repaint(); }); } - + fn start_bulk_restore(&mut self, servers: Vec, ctx: &egui::Context) { self.admin_state.current_operation = AdminOperation::BulkRestoring; - add_log_entry(&mut self.operation_log, format!("Restoring {} servers...", servers.len())); - + add_log_entry( + &mut self.operation_log, + format!("Restoring {} servers...", servers.len()), + ); + let (tx, rx) = mpsc::channel(); self.operation_receiver = Some(rx); - + let host = self.settings.host.clone(); let flow = self.settings.flow.clone(); let basic_auth = self.settings.basic_auth.clone(); let ctx_clone = ctx.clone(); - + std::thread::spawn(move || { let rt = tokio::runtime::Runtime::new().unwrap(); - let result = rt.block_on(async { - bulk_restore_servers(host, flow, basic_auth, servers).await - }); - + let result = + rt.block_on(async { bulk_restore_servers(host, flow, basic_auth, servers).await }); + let _ = tx.send(result); ctx_clone.request_repaint(); }); } - + fn start_deprecate_key(&mut self, server: &str, ctx: &egui::Context) { self.admin_state.current_operation = AdminOperation::DeprecatingKey; - add_log_entry(&mut self.operation_log, format!("Deprecating key for server: {}", server)); - + add_log_entry( + &mut self.operation_log, + format!("Deprecating key for server: {}", server), + ); + let (tx, rx) = mpsc::channel(); self.operation_receiver = Some(rx); - + let host = self.settings.host.clone(); let flow = self.settings.flow.clone(); let basic_auth = self.settings.basic_auth.clone(); let server_name = server.to_string(); let ctx_clone = ctx.clone(); - + std::thread::spawn(move || { let rt = tokio::runtime::Runtime::new().unwrap(); - let result = rt.block_on(async { - deprecate_key(host, flow, basic_auth, server_name).await - }); - + let result = + rt.block_on(async { deprecate_key(host, flow, basic_auth, server_name).await }); + let _ = tx.send(result); ctx_clone.request_repaint(); }); } - + fn start_restore_key(&mut self, server: &str, ctx: &egui::Context) { self.admin_state.current_operation = AdminOperation::RestoringKey; - add_log_entry(&mut self.operation_log, format!("Restoring key for server: {}", server)); - + add_log_entry( + &mut self.operation_log, + format!("Restoring key for server: {}", server), + ); + let (tx, rx) = mpsc::channel(); self.operation_receiver = Some(rx); - + let host = self.settings.host.clone(); let flow = self.settings.flow.clone(); let basic_auth = self.settings.basic_auth.clone(); let server_name = server.to_string(); let ctx_clone = ctx.clone(); - + std::thread::spawn(move || { let rt = tokio::runtime::Runtime::new().unwrap(); - let result = rt.block_on(async { - restore_key(host, flow, basic_auth, server_name).await - }); - + let result = + rt.block_on(async { restore_key(host, flow, basic_auth, server_name).await }); + let _ = tx.send(result); ctx_clone.request_repaint(); }); } - + fn start_delete_key(&mut self, server: &str, ctx: &egui::Context) { self.admin_state.current_operation = AdminOperation::DeletingKey; - add_log_entry(&mut self.operation_log, format!("Deleting key for server: {}", server)); - + add_log_entry( + &mut self.operation_log, + format!("Deleting key for server: {}", server), + ); + let (tx, rx) = mpsc::channel(); self.operation_receiver = Some(rx); - + let host = self.settings.host.clone(); let flow = self.settings.flow.clone(); let basic_auth = self.settings.basic_auth.clone(); let server_name = server.to_string(); let ctx_clone = ctx.clone(); - + std::thread::spawn(move || { let rt = tokio::runtime::Runtime::new().unwrap(); - let result = rt.block_on(async { - delete_key(host, flow, basic_auth, server_name).await - }); - + let result = + rt.block_on(async { delete_key(host, flow, basic_auth, server_name).await }); + let _ = tx.send(result); ctx_clone.request_repaint(); }); @@ -453,19 +507,19 @@ impl SettingsWindow { /// Apply modern dark theme for the settings window with enhanced styling fn apply_modern_theme(ctx: &egui::Context) { let mut visuals = egui::Visuals::dark(); - + // Modern color palette - visuals.window_fill = egui::Color32::from_gray(18); // Darker background - visuals.panel_fill = egui::Color32::from_gray(24); // Panel background + visuals.window_fill = egui::Color32::from_gray(18); // Darker background + visuals.panel_fill = egui::Color32::from_gray(24); // Panel background visuals.faint_bg_color = egui::Color32::from_gray(32); // Card background visuals.extreme_bg_color = egui::Color32::from_gray(12); // Darkest areas - + // Enhanced widget styling visuals.button_frame = true; visuals.collapsing_header_frame = true; visuals.indent_has_left_vline = true; visuals.striped = true; - + // Modern rounded corners let rounding = egui::Rounding::same(8.0); visuals.menu_rounding = rounding; @@ -474,32 +528,32 @@ fn apply_modern_theme(ctx: &egui::Context) { visuals.widgets.inactive.rounding = rounding; visuals.widgets.hovered.rounding = rounding; visuals.widgets.active.rounding = rounding; - + // Better widget colors visuals.widgets.noninteractive.bg_fill = egui::Color32::from_gray(40); visuals.widgets.inactive.bg_fill = egui::Color32::from_gray(45); visuals.widgets.hovered.bg_fill = egui::Color32::from_gray(55); visuals.widgets.active.bg_fill = egui::Color32::from_gray(60); - + // Subtle borders let border_color = egui::Color32::from_gray(60); visuals.widgets.noninteractive.bg_stroke = egui::Stroke::new(1.0, border_color); visuals.widgets.inactive.bg_stroke = egui::Stroke::new(1.0, border_color); visuals.widgets.hovered.bg_stroke = egui::Stroke::new(1.5, egui::Color32::from_gray(80)); visuals.widgets.active.bg_stroke = egui::Stroke::new(1.5, egui::Color32::from_gray(100)); - + ctx.set_visuals(visuals); } /// Render bottom activity log panel fn render_bottom_activity_log(ui: &mut egui::Ui, operation_log: &mut Vec) { ui.add_space(18.0); // Larger top padding - + ui.horizontal(|ui| { ui.add_space(8.0); ui.label("šŸ“‹"); ui.label(egui::RichText::new("Activity Log").size(13.0).strong()); - + ui.with_layout(egui::Layout::right_to_left(egui::Align::Min), |ui| { ui.add_space(8.0); if ui.small_button("šŸ—‘ Clear").clicked() { @@ -507,13 +561,13 @@ fn render_bottom_activity_log(ui: &mut egui::Ui, operation_log: &mut Vec } }); }); - + ui.add_space(8.0); - + // Add horizontal margin for the text area ui.horizontal(|ui| { ui.add_space(8.0); // Left margin - + // Show last 5 log entries in multiline text let log_text = if operation_log.is_empty() { "No recent activity".to_string() @@ -525,14 +579,14 @@ fn render_bottom_activity_log(ui: &mut egui::Ui, operation_log: &mut Vec }; operation_log[start_idx..].join("\n") }; - + ui.add_sized( [ui.available_width() - 8.0, 80.0], // Account for right margin egui::TextEdit::multiline(&mut log_text.clone()) .font(egui::FontId::new(11.0, egui::FontFamily::Monospace)) - .interactive(false) + .interactive(false), ); - + ui.add_space(8.0); // Right margin }); } @@ -552,7 +606,7 @@ pub fn create_window_icon() -> egui::IconData { } }) .collect(); - + egui::IconData { rgba: icon_data, width: icon_size as u32, @@ -565,8 +619,8 @@ pub fn run_settings_window() { let options = eframe::NativeOptions { viewport: egui::ViewportBuilder::default() .with_title("KHM Settings") - .with_inner_size([900.0, 905.0]) // Decreased height by another 15px - .with_min_inner_size([900.0, 905.0]) // Fixed size + .with_inner_size([900.0, 905.0]) // Decreased height by another 15px + .with_min_inner_size([900.0, 905.0]) // Fixed size .with_max_inner_size([900.0, 905.0]) // Same as min - fixed size .with_resizable(false) // Disable resizing since window is fixed size .with_icon(create_window_icon()) @@ -575,7 +629,7 @@ pub fn run_settings_window() { centered: true, ..Default::default() }; - + let _ = eframe::run_native( "KHM Settings", options, diff --git a/src/gui/tray/app.rs b/src/gui/tray/app.rs index b02d738..bb65961 100644 --- a/src/gui/tray/app.rs +++ b/src/gui/tray/app.rs @@ -6,10 +6,7 @@ use notify::RecursiveMode; use notify_debouncer_mini::{new_debouncer, DebounceEventResult}; use std::sync::{Arc, Mutex}; use std::time::Duration; -use tray_icon::{ - menu::MenuEvent, - TrayIcon, -}; +use tray_icon::{menu::MenuEvent, TrayIcon}; use winit::{ application::ApplicationHandler, event_loop::{EventLoop, EventLoopProxy}, @@ -18,9 +15,11 @@ use winit::{ #[cfg(target_os = "macos")] use winit::platform::macos::EventLoopBuilderExtMacOS; -use super::{SyncStatus, TrayMenuIds, create_tray_icon, update_tray_menu, - create_tooltip, start_auto_sync_task, update_sync_status}; -use crate::gui::common::{load_settings, get_config_path, perform_sync, KhmSettings}; +use super::{ + create_tooltip, create_tray_icon, start_auto_sync_task, update_sync_status, update_tray_menu, + SyncStatus, TrayMenuIds, +}; +use crate::gui::common::{get_config_path, load_settings, perform_sync, KhmSettings}; pub struct TrayApplication { tray_icon: Option, @@ -46,26 +45,33 @@ impl TrayApplication { auto_sync_handle: None, } } - + #[cfg(feature = "gui")] fn setup_file_watcher(&mut self) { let config_path = get_config_path(); let (tx, rx) = std::sync::mpsc::channel::(); let proxy = self.proxy.clone(); - + std::thread::spawn(move || { while let Ok(result) = rx.recv() { if let Ok(events) = result { - if events.iter().any(|e| e.path.to_string_lossy().contains("khm_config.json")) { + if events + .iter() + .any(|e| e.path.to_string_lossy().contains("khm_config.json")) + { let _ = proxy.send_event(crate::gui::UserEvent::ConfigFileChanged); } } } }); - + if let Ok(mut debouncer) = new_debouncer(Duration::from_millis(500), tx) { if let Some(config_dir) = config_path.parent() { - if debouncer.watcher().watch(config_dir, RecursiveMode::NonRecursive).is_ok() { + if debouncer + .watcher() + .watch(config_dir, RecursiveMode::NonRecursive) + .is_ok() + { info!("File watcher started"); self._debouncer = Some(debouncer); } else { @@ -74,45 +80,48 @@ impl TrayApplication { } } } - + fn handle_config_change(&mut self) { info!("Config file changed"); let new_settings = load_settings(); let old_interval = self.settings.lock().unwrap().auto_sync_interval_minutes; let new_interval = new_settings.auto_sync_interval_minutes; - + *self.settings.lock().unwrap() = new_settings; - + // Update menu if let Some(tray_icon) = &self.tray_icon { let settings = self.settings.lock().unwrap(); let new_menu_ids = update_tray_menu(tray_icon, &settings); self.menu_ids = Some(new_menu_ids); } - + // Update tooltip self.update_tooltip(); - + // Restart auto sync if interval changed if old_interval != new_interval { - info!("Auto sync interval changed from {} to {} minutes, restarting auto sync", old_interval, new_interval); + info!( + "Auto sync interval changed from {} to {} minutes, restarting auto sync", + old_interval, new_interval + ); self.start_auto_sync(); } } - + fn start_auto_sync(&mut self) { if let Some(handle) = self.auto_sync_handle.take() { // Note: In a real implementation, you'd want to properly signal the thread to stop drop(handle); } - + self.auto_sync_handle = start_auto_sync_task( Arc::clone(&self.settings), Arc::clone(&self.sync_status), - self.proxy.clone() + self.proxy.clone(), ); } - + fn update_tooltip(&self) { if let Some(tray_icon) = &self.tray_icon { let settings = self.settings.lock().unwrap(); @@ -121,8 +130,12 @@ impl TrayApplication { let _ = tray_icon.set_tooltip(Some(&tooltip)); } } - - fn handle_menu_event(&mut self, event: MenuEvent, event_loop: &winit::event_loop::ActiveEventLoop) { + + fn handle_menu_event( + &mut self, + event: MenuEvent, + event_loop: &winit::event_loop::ActiveEventLoop, + ) { if let Some(menu_ids) = &self.menu_ids { if event.id == menu_ids.settings_id { info!("Settings menu clicked"); @@ -136,7 +149,7 @@ impl TrayApplication { } } } - + fn launch_settings_window(&self) { if let Ok(exe_path) = std::env::current_exe() { std::thread::spawn(move || { @@ -150,20 +163,23 @@ impl TrayApplication { }); } } - + fn start_manual_sync(&self) { let settings = self.settings.lock().unwrap().clone(); let sync_status_clone: Arc> = Arc::clone(&self.sync_status); let proxy_clone = self.proxy.clone(); - + // Check if settings are valid if settings.host.is_empty() || settings.flow.is_empty() { error!("Cannot sync: host or flow not configured"); return; } - - info!("Syncing with host: {}, flow: {}", settings.host, settings.flow); - + + info!( + "Syncing with host: {}, flow: {}", + settings.host, settings.flow + ); + // Run sync in separate thread with its own tokio runtime std::thread::spawn(move || { let rt = tokio::runtime::Runtime::new().unwrap(); @@ -183,7 +199,7 @@ impl TrayApplication { }); }); } - + fn handle_update_menu(&mut self) { let settings = self.settings.lock().unwrap(); if !settings.host.is_empty() && !settings.flow.is_empty() && settings.in_place { @@ -191,7 +207,7 @@ impl TrayApplication { update_sync_status(&settings, &mut sync_status); } drop(settings); - + self.update_tooltip(); } } @@ -202,8 +218,9 @@ impl ApplicationHandler for TrayApplication { _event_loop: &winit::event_loop::ActiveEventLoop, _window_id: winit::window::WindowId, _event: winit::event::WindowEvent, - ) {} - + ) { + } + fn resumed(&mut self, _event_loop: &winit::event_loop::ActiveEventLoop) { if self.tray_icon.is_none() { info!("Creating tray icon"); @@ -212,17 +229,21 @@ impl ApplicationHandler for TrayApplication { let (tray_icon, menu_ids) = create_tray_icon(&settings, &sync_status); drop(settings); drop(sync_status); - + self.tray_icon = Some(tray_icon); self.menu_ids = Some(menu_ids); - + self.setup_file_watcher(); self.start_auto_sync(); info!("KHM tray application ready"); } } - - fn user_event(&mut self, event_loop: &winit::event_loop::ActiveEventLoop, event: crate::gui::UserEvent) { + + fn user_event( + &mut self, + event_loop: &winit::event_loop::ActiveEventLoop, + event: crate::gui::UserEvent, + ) { match event { crate::gui::UserEvent::TrayIconEvent => {} crate::gui::UserEvent::UpdateMenu => { @@ -246,30 +267,45 @@ pub async fn run_tray_app() -> std::io::Result<()> { EventLoop::::with_user_event() .with_activation_policy(ActivationPolicy::Accessory) .build() - .map_err(|e| std::io::Error::new(std::io::ErrorKind::Other, format!("Failed to create event loop: {}", e)))? + .map_err(|e| { + std::io::Error::new( + std::io::ErrorKind::Other, + format!("Failed to create event loop: {}", e), + ) + })? }; - + #[cfg(not(target_os = "macos"))] - let event_loop = EventLoop::::with_user_event().build() - .map_err(|e| std::io::Error::new(std::io::ErrorKind::Other, format!("Failed to create event loop: {}", e)))?; - + let event_loop = EventLoop::::with_user_event() + .build() + .map_err(|e| { + std::io::Error::new( + std::io::ErrorKind::Other, + format!("Failed to create event loop: {}", e), + ) + })?; + let proxy = event_loop.create_proxy(); - + // Setup event handlers let proxy_clone = proxy.clone(); tray_icon::TrayIconEvent::set_event_handler(Some(move |_event| { let _ = proxy_clone.send_event(crate::gui::UserEvent::TrayIconEvent); })); - + let proxy_clone = proxy.clone(); MenuEvent::set_event_handler(Some(move |event: MenuEvent| { let _ = proxy_clone.send_event(crate::gui::UserEvent::MenuEvent(event)); })); - + let mut app = TrayApplication::new(proxy); - - event_loop.run_app(&mut app) - .map_err(|e| std::io::Error::new(std::io::ErrorKind::Other, format!("Event loop error: {:?}", e)))?; - + + event_loop.run_app(&mut app).map_err(|e| { + std::io::Error::new( + std::io::ErrorKind::Other, + format!("Event loop error: {:?}", e), + ) + })?; + Ok(()) } diff --git a/src/gui/tray/icon.rs b/src/gui/tray/icon.rs index 3d1c8de..1e59273 100644 --- a/src/gui/tray/icon.rs +++ b/src/gui/tray/icon.rs @@ -1,10 +1,10 @@ +use crate::gui::common::{perform_sync, KhmSettings}; use log::{error, info}; use std::sync::{Arc, Mutex}; use tray_icon::{ - menu::{Menu, MenuItem, MenuId}, + menu::{Menu, MenuId, MenuItem}, TrayIcon, TrayIconBuilder, }; -use crate::gui::common::{KhmSettings, perform_sync}; #[derive(Debug, Clone)] pub struct SyncStatus { @@ -30,21 +30,26 @@ pub struct TrayMenuIds { } /// Create tray icon with menu -pub fn create_tray_icon(settings: &KhmSettings, sync_status: &SyncStatus) -> (TrayIcon, TrayMenuIds) { +pub fn create_tray_icon( + settings: &KhmSettings, + sync_status: &SyncStatus, +) -> (TrayIcon, TrayMenuIds) { // Create simple blue icon - let icon_data: Vec = (0..32*32).flat_map(|i| { - let y = i / 32; - let x = i % 32; - if x < 2 || x >= 30 || y < 2 || y >= 30 { - [255, 255, 255, 255] // White border - } else { - [64, 128, 255, 255] // Blue center - } - }).collect(); - + let icon_data: Vec = (0..32 * 32) + .flat_map(|i| { + let y = i / 32; + let x = i % 32; + if x < 2 || x >= 30 || y < 2 || y >= 30 { + [255, 255, 255, 255] // White border + } else { + [64, 128, 255, 255] // Blue center + } + }) + .collect(); + let icon = tray_icon::Icon::from_rgba(icon_data, 32, 32).unwrap(); let menu = Menu::new(); - + // Show current configuration status (static) let host_text = if settings.host.is_empty() { "Host: Not configured" @@ -52,64 +57,75 @@ pub fn create_tray_icon(settings: &KhmSettings, sync_status: &SyncStatus) -> (Tr &format!("Host: {}", settings.host) }; menu.append(&MenuItem::new(host_text, false, None)).unwrap(); - + let flow_text = if settings.flow.is_empty() { "Flow: Not configured" } else { &format!("Flow: {}", settings.flow) }; menu.append(&MenuItem::new(flow_text, false, None)).unwrap(); - - let is_auto_sync_enabled = !settings.host.is_empty() && !settings.flow.is_empty() && settings.in_place; - let sync_text = format!("Auto sync: {} ({}min)", - if is_auto_sync_enabled { "On" } else { "Off" }, - settings.auto_sync_interval_minutes); - menu.append(&MenuItem::new(&sync_text, false, None)).unwrap(); - - menu.append(&tray_icon::menu::PredefinedMenuItem::separator()).unwrap(); - + + let is_auto_sync_enabled = + !settings.host.is_empty() && !settings.flow.is_empty() && settings.in_place; + let sync_text = format!( + "Auto sync: {} ({}min)", + if is_auto_sync_enabled { "On" } else { "Off" }, + settings.auto_sync_interval_minutes + ); + menu.append(&MenuItem::new(&sync_text, false, None)) + .unwrap(); + + menu.append(&tray_icon::menu::PredefinedMenuItem::separator()) + .unwrap(); + // Sync Now menu item - let sync_item = MenuItem::new("Sync Now", !settings.host.is_empty() && !settings.flow.is_empty(), None); + let sync_item = MenuItem::new( + "Sync Now", + !settings.host.is_empty() && !settings.flow.is_empty(), + None, + ); let sync_id = sync_item.id().clone(); menu.append(&sync_item).unwrap(); - - menu.append(&tray_icon::menu::PredefinedMenuItem::separator()).unwrap(); - + + menu.append(&tray_icon::menu::PredefinedMenuItem::separator()) + .unwrap(); + // Settings menu item let settings_item = MenuItem::new("Settings", true, None); let settings_id = settings_item.id().clone(); menu.append(&settings_item).unwrap(); - - menu.append(&tray_icon::menu::PredefinedMenuItem::separator()).unwrap(); - + + menu.append(&tray_icon::menu::PredefinedMenuItem::separator()) + .unwrap(); + // Quit menu item let quit_item = MenuItem::new("Quit", true, None); let quit_id = quit_item.id().clone(); menu.append(&quit_item).unwrap(); - + // Create initial tooltip let tooltip = create_tooltip(settings, sync_status); - + let tray_icon = TrayIconBuilder::new() .with_tooltip(&tooltip) .with_icon(icon) .with_menu(Box::new(menu)) .build() .unwrap(); - + let menu_ids = TrayMenuIds { settings_id, quit_id, sync_id, }; - + (tray_icon, menu_ids) } /// Update tray menu with new settings pub fn update_tray_menu(tray_icon: &TrayIcon, settings: &KhmSettings) -> TrayMenuIds { let menu = Menu::new(); - + // Show current configuration status (static) let host_text = if settings.host.is_empty() { "Host: Not configured" @@ -117,43 +133,54 @@ pub fn update_tray_menu(tray_icon: &TrayIcon, settings: &KhmSettings) -> TrayMen &format!("Host: {}", settings.host) }; menu.append(&MenuItem::new(host_text, false, None)).unwrap(); - + let flow_text = if settings.flow.is_empty() { "Flow: Not configured" } else { &format!("Flow: {}", settings.flow) }; menu.append(&MenuItem::new(flow_text, false, None)).unwrap(); - - let is_auto_sync_enabled = !settings.host.is_empty() && !settings.flow.is_empty() && settings.in_place; - let sync_text = format!("Auto sync: {} ({}min)", - if is_auto_sync_enabled { "On" } else { "Off" }, - settings.auto_sync_interval_minutes); - menu.append(&MenuItem::new(&sync_text, false, None)).unwrap(); - - menu.append(&tray_icon::menu::PredefinedMenuItem::separator()).unwrap(); - + + let is_auto_sync_enabled = + !settings.host.is_empty() && !settings.flow.is_empty() && settings.in_place; + let sync_text = format!( + "Auto sync: {} ({}min)", + if is_auto_sync_enabled { "On" } else { "Off" }, + settings.auto_sync_interval_minutes + ); + menu.append(&MenuItem::new(&sync_text, false, None)) + .unwrap(); + + menu.append(&tray_icon::menu::PredefinedMenuItem::separator()) + .unwrap(); + // Sync Now menu item - let sync_item = MenuItem::new("Sync Now", !settings.host.is_empty() && !settings.flow.is_empty(), None); + let sync_item = MenuItem::new( + "Sync Now", + !settings.host.is_empty() && !settings.flow.is_empty(), + None, + ); let sync_id = sync_item.id().clone(); menu.append(&sync_item).unwrap(); - - menu.append(&tray_icon::menu::PredefinedMenuItem::separator()).unwrap(); - + + menu.append(&tray_icon::menu::PredefinedMenuItem::separator()) + .unwrap(); + // Settings menu item let settings_item = MenuItem::new("Settings", true, None); let settings_id = settings_item.id().clone(); menu.append(&settings_item).unwrap(); - - menu.append(&tray_icon::menu::PredefinedMenuItem::separator()).unwrap(); - + + menu.append(&tray_icon::menu::PredefinedMenuItem::separator()) + .unwrap(); + // Quit menu item let quit_item = MenuItem::new("Quit", true, None); let quit_id = quit_item.id().clone(); menu.append(&quit_item).unwrap(); - + tray_icon.set_menu(Some(Box::new(menu))); - + TrayMenuIds { settings_id, quit_id, @@ -163,14 +190,17 @@ pub fn update_tray_menu(tray_icon: &TrayIcon, settings: &KhmSettings) -> TrayMen /// Create tooltip text for tray icon pub fn create_tooltip(settings: &KhmSettings, sync_status: &SyncStatus) -> String { - let mut tooltip = format!("KHM - SSH Key Manager\nHost: {}\nFlow: {}", settings.host, settings.flow); - + let mut tooltip = format!( + "KHM - SSH Key Manager\nHost: {}\nFlow: {}", + settings.host, settings.flow + ); + if let Some(keys_count) = sync_status.last_sync_keys { tooltip.push_str(&format!("\nLast sync: {} keys", keys_count)); } else { tooltip.push_str("\nLast sync: Never"); } - + if let Some(seconds) = sync_status.next_sync_in_seconds { if seconds > 60 { tooltip.push_str(&format!("\nNext sync: {}m {}s", seconds / 60, seconds % 60)); @@ -178,7 +208,7 @@ pub fn create_tooltip(settings: &KhmSettings, sync_status: &SyncStatus) -> Strin tooltip.push_str(&format!("\nNext sync: {}s", seconds)); } } - + tooltip } @@ -186,18 +216,24 @@ pub fn create_tooltip(settings: &KhmSettings, sync_status: &SyncStatus) -> Strin pub fn start_auto_sync_task( settings: Arc>, sync_status: Arc>, - event_sender: winit::event_loop::EventLoopProxy + event_sender: winit::event_loop::EventLoopProxy, ) -> Option> { let initial_settings = settings.lock().unwrap().clone(); - + // Only start auto sync if settings are valid and in_place is enabled - if initial_settings.host.is_empty() || initial_settings.flow.is_empty() || !initial_settings.in_place { + if initial_settings.host.is_empty() + || initial_settings.flow.is_empty() + || !initial_settings.in_place + { info!("Auto sync disabled or settings invalid"); return None; } - - info!("Starting auto sync with interval {} minutes", initial_settings.auto_sync_interval_minutes); - + + info!( + "Starting auto sync with interval {} minutes", + initial_settings.auto_sync_interval_minutes + ); + let handle = std::thread::spawn(move || { // Initial sync on startup info!("Performing initial sync on startup"); @@ -207,7 +243,10 @@ pub fn start_auto_sync_task( rt.block_on(async { match perform_sync(¤t_settings).await { Ok(keys_count) => { - info!("Initial sync completed successfully with {} keys", keys_count); + info!( + "Initial sync completed successfully with {} keys", + keys_count + ); let mut status = sync_status.lock().unwrap(); status.last_sync_time = Some(std::time::Instant::now()); status.last_sync_keys = Some(keys_count); @@ -219,27 +258,28 @@ pub fn start_auto_sync_task( } }); } - + // Start menu update timer let timer_sender = event_sender.clone(); - std::thread::spawn(move || { - loop { - std::thread::sleep(std::time::Duration::from_secs(1)); - let _ = timer_sender.send_event(crate::gui::UserEvent::UpdateMenu); - } + std::thread::spawn(move || loop { + std::thread::sleep(std::time::Duration::from_secs(1)); + let _ = timer_sender.send_event(crate::gui::UserEvent::UpdateMenu); }); - + // Periodic sync loop { let interval_minutes = current_settings.auto_sync_interval_minutes; std::thread::sleep(std::time::Duration::from_secs(interval_minutes as u64 * 60)); - + let current_settings = settings.lock().unwrap().clone(); - if current_settings.host.is_empty() || current_settings.flow.is_empty() || !current_settings.in_place { + if current_settings.host.is_empty() + || current_settings.flow.is_empty() + || !current_settings.in_place + { info!("Auto sync stopped due to invalid settings or disabled in_place"); break; } - + info!("Performing scheduled auto sync"); let rt = tokio::runtime::Runtime::new().unwrap(); rt.block_on(async { @@ -258,7 +298,7 @@ pub fn start_auto_sync_task( }); } }); - + Some(handle) } @@ -268,7 +308,7 @@ pub fn update_sync_status(settings: &KhmSettings, sync_status: &mut SyncStatus) if let Some(last_sync) = sync_status.last_sync_time { let elapsed = last_sync.elapsed().as_secs(); let interval_seconds = settings.auto_sync_interval_minutes as u64 * 60; - + if elapsed < interval_seconds { sync_status.next_sync_in_seconds = Some(interval_seconds - elapsed); } else { diff --git a/src/gui/tray/mod.rs b/src/gui/tray/mod.rs index 661c6bf..20dd36d 100644 --- a/src/gui/tray/mod.rs +++ b/src/gui/tray/mod.rs @@ -2,5 +2,7 @@ mod app; mod icon; pub use app::*; -pub use icon::{SyncStatus, TrayMenuIds, create_tray_icon, update_tray_menu, - create_tooltip, start_auto_sync_task, update_sync_status}; +pub use icon::{ + create_tooltip, create_tray_icon, start_auto_sync_task, update_sync_status, update_tray_menu, + SyncStatus, TrayMenuIds, +}; diff --git a/src/main.rs b/src/main.rs index 7488926..0eb12b4 100644 --- a/src/main.rs +++ b/src/main.rs @@ -1,8 +1,8 @@ mod client; mod db; +mod gui; mod server; mod web; -mod gui; use clap::Parser; use env_logger; @@ -147,7 +147,7 @@ async fn main() -> std::io::Result<()> { .filter_module("glow", log::LevelFilter::Error) // Graphics .filter_module("tracing", log::LevelFilter::Error) // Tracing spans .init(); - + info!("Starting SSH Key Manager"); let args = Args::parse(); @@ -165,7 +165,7 @@ async fn main() -> std::io::Result<()> { error!("GUI features not compiled. Install system dependencies and rebuild with --features gui"); return Err(std::io::Error::new( std::io::ErrorKind::Unsupported, - "GUI features not compiled" + "GUI features not compiled", )); } } @@ -193,10 +193,7 @@ async fn main() -> std::io::Result<()> { " Client mode: {} --host https://khm.example.com --flow work", env!("CARGO_PKG_NAME") ); - eprintln!( - " GUI mode: {} --gui", - env!("CARGO_PKG_NAME") - ); + eprintln!(" GUI mode: {} --gui", env!("CARGO_PKG_NAME")); eprintln!( " Settings window: {} --gui --settings-ui", env!("CARGO_PKG_NAME") diff --git a/src/web.rs b/src/web.rs index 7c9c043..a08f530 100644 --- a/src/web.rs +++ b/src/web.rs @@ -1,14 +1,14 @@ use actix_web::{web, HttpResponse, Result}; +use futures::future; use log::info; use rust_embed::RustEmbed; +use serde::{Deserialize, Serialize}; use serde_json::json; use std::sync::Arc; -use trust_dns_resolver::TokioAsyncResolver; -use trust_dns_resolver::config::*; -use serde::{Deserialize, Serialize}; -use futures::future; use tokio::sync::Semaphore; use tokio::time::{timeout, Duration}; +use trust_dns_resolver::config::*; +use trust_dns_resolver::TokioAsyncResolver; use crate::db::ReconnectingDbClient; use crate::server::Flows; @@ -41,10 +41,7 @@ async fn check_dns_resolution(hostname: String, semaphore: Arc) -> Dn } }; - let resolver = TokioAsyncResolver::tokio( - ResolverConfig::default(), - ResolverOpts::default(), - ); + let resolver = TokioAsyncResolver::tokio(ResolverConfig::default(), ResolverOpts::default()); let lookup_result = timeout(Duration::from_secs(5), resolver.lookup_ip(&hostname)).await; @@ -88,7 +85,10 @@ pub async fn scan_dns_resolution( ) -> Result { let flow_id_str = path.into_inner(); - info!("API request to scan DNS resolution for flow '{}'" , flow_id_str); + info!( + "API request to scan DNS resolution for flow '{}'", + flow_id_str + ); if !allowed_flows.contains(&flow_id_str) { return Ok(HttpResponse::Forbidden().json(json!({ @@ -114,7 +114,10 @@ pub async fn scan_dns_resolution( drop(flows_guard); - info!("Scanning DNS resolution for {} unique hosts", hostnames.len()); + info!( + "Scanning DNS resolution for {} unique hosts", + hostnames.len() + ); // Limit concurrent DNS requests to prevent "too many open files" error let semaphore = Arc::new(Semaphore::new(20)); @@ -128,7 +131,11 @@ pub async fn scan_dns_resolution( let results = future::join_all(scan_futures).await; let unresolved_count = results.iter().filter(|r| !r.resolved).count(); - info!("DNS scan complete: {} unresolved out of {} hosts", unresolved_count, results.len()); + info!( + "DNS scan complete: {} unresolved out of {} hosts", + unresolved_count, + results.len() + ); Ok(HttpResponse::Ok().json(json!({ "results": results, @@ -147,7 +154,11 @@ pub async fn bulk_deprecate_servers( ) -> Result { let flow_id_str = path.into_inner(); - info!("API request to bulk deprecate {} servers in flow '{}'", request.servers.len(), flow_id_str); + info!( + "API request to bulk deprecate {} servers in flow '{}'", + request.servers.len(), + flow_id_str + ); if !allowed_flows.contains(&flow_id_str) { return Ok(HttpResponse::Forbidden().json(json!({ @@ -161,7 +172,11 @@ pub async fn bulk_deprecate_servers( .await { Ok(count) => { - info!("Bulk deprecated {} key(s) for {} servers", count, request.servers.len()); + info!( + "Bulk deprecated {} key(s) for {} servers", + count, + request.servers.len() + ); count } Err(e) => { @@ -203,7 +218,11 @@ pub async fn bulk_restore_servers( ) -> Result { let flow_id_str = path.into_inner(); - info!("API request to bulk restore {} servers in flow '{}'", request.servers.len(), flow_id_str); + info!( + "API request to bulk restore {} servers in flow '{}'", + request.servers.len(), + flow_id_str + ); if !allowed_flows.contains(&flow_id_str) { return Ok(HttpResponse::Forbidden().json(json!({ @@ -217,7 +236,11 @@ pub async fn bulk_restore_servers( .await { Ok(count) => { - info!("Bulk restored {} key(s) for {} servers", count, request.servers.len()); + info!( + "Bulk restored {} key(s) for {} servers", + count, + request.servers.len() + ); count } Err(e) => {