From mboxrd@z Thu Jan 1 00:00:00 1970 Return-Path: Received: from firstgate.proxmox.com (firstgate.proxmox.com [IPv6:2a01:7e0:0:424::9]) by lore.proxmox.com (Postfix) with ESMTPS id 280DE1FF17E for ; Thu, 30 Oct 2025 15:33:54 +0100 (CET) Received: from firstgate.proxmox.com (localhost [127.0.0.1]) by firstgate.proxmox.com (Proxmox) with ESMTP id 3D61624415; Thu, 30 Oct 2025 15:34:24 +0100 (CET) From: Hannes Laimer To: pdm-devel@lists.proxmox.com Date: Thu, 30 Oct 2025 15:34:06 +0100 Message-ID: <20251030143406.193744-14-h.laimer@proxmox.com> X-Mailer: git-send-email 2.47.3 In-Reply-To: <20251030143406.193744-1-h.laimer@proxmox.com> References: <20251030143406.193744-1-h.laimer@proxmox.com> MIME-Version: 1.0 X-Bm-Milter-Handled: 55990f41-d878-4baa-be0a-ee34c49e34d2 X-Bm-Transport-Timestamp: 1761834836446 X-SPAM-LEVEL: Spam detection results: 0 AWL 0.042 Adjusted score from AWL reputation of From: address BAYES_00 -1.9 Bayes spam probability is 0 to 1% DMARC_MISSING 0.1 Missing DMARC policy KAM_DMARC_STATUS 0.01 Test Rule for DKIM or SPF Failure with Strict Alignment RCVD_IN_MSPIKE_H2 0.001 Average reputation (+2) SPF_HELO_NONE 0.001 SPF: HELO does not publish an SPF Record SPF_PASS -0.001 SPF: sender matches SPF record Subject: [pdm-devel] [PATCH proxmox-datacenter-manager 4/4] ui: add firewall status tree X-BeenThere: pdm-devel@lists.proxmox.com X-Mailman-Version: 2.1.29 Precedence: list List-Id: Proxmox Datacenter Manager development discussion List-Unsubscribe: , List-Archive: List-Post: List-Help: List-Subscribe: , Reply-To: Proxmox Datacenter Manager development discussion Content-Type: text/plain; charset="us-ascii" Content-Transfer-Encoding: 7bit Errors-To: pdm-devel-bounces@lists.proxmox.com Sender: "pdm-devel" Adds tree displaying the firewall status of remotes, nodes and guests. Upon selecting an entity in the tree the right panle shows its configured firewall rules, this is read-only, so rules can't be added, deleted or modified currently. The tree contains a button that allows to edit the firewall options of remotes, nodes or guests. Given the rather large amount of requests it takes PDM to accumulate all the data this contains comboboxes to select specific remotes or nodes on a specific remote. This doesn't just filter the results but changes how data is requested. So is is possible to limit the amount of work the PDM has to do for each request and improve responsiveness. The text filter is only for local, already loaded, data, so it won't trigger a new load. Signed-off-by: Hannes Laimer --- ui/src/remotes/firewall/columns.rs | 150 ++++++ ui/src/remotes/firewall/mod.rs | 30 ++ ui/src/remotes/firewall/tree.rs | 634 ++++++++++++++++++++++++++ ui/src/remotes/firewall/types.rs | 284 ++++++++++++ ui/src/remotes/firewall/ui_helpers.rs | 156 +++++++ ui/src/remotes/mod.rs | 10 + 6 files changed, 1264 insertions(+) create mode 100644 ui/src/remotes/firewall/columns.rs create mode 100644 ui/src/remotes/firewall/mod.rs create mode 100644 ui/src/remotes/firewall/tree.rs create mode 100644 ui/src/remotes/firewall/types.rs create mode 100644 ui/src/remotes/firewall/ui_helpers.rs diff --git a/ui/src/remotes/firewall/columns.rs b/ui/src/remotes/firewall/columns.rs new file mode 100644 index 0000000..d05095b --- /dev/null +++ b/ui/src/remotes/firewall/columns.rs @@ -0,0 +1,150 @@ +use proxmox_yew_comp::LoadableComponentContext; +use pwt::css::AlignItems; +use pwt::prelude::*; +use pwt::state::TreeStore; +use pwt::tr; +use pwt::widget::data_table::{DataTableColumn, DataTableHeader}; +use pwt::widget::{Container, Fa, Row}; +use std::rc::Rc; +use yew::Html; + +use super::types::{Scope, TreeEntry, ViewState}; +use super::ui_helpers::{ + render_firewall_status, render_load_error_message, render_rule_stats, render_warning_icon, +}; + +use crate::remotes::firewall::tree::FirewallTreeComponent; + +pub fn create_columns( + ctx: &LoadableComponentContext, + store: TreeStore, + loading: bool, + scope: &Scope, +) -> Rc>> { + let scope = Rc::new(scope.clone()); + + Rc::new(vec![ + create_name_column(store, loading, scope.clone()), + create_enabled_column(scope.clone()), + create_rules_column(scope), + create_actions_column(ctx), + ]) +} + +fn create_name_column( + store: TreeStore, + loading: bool, + scope: Rc, +) -> DataTableHeader { + DataTableColumn::new(tr!("Name")) + .tree_column(store) + .render(move |entry: &TreeEntry| { + let (icon, text) = match entry { + TreeEntry::Root if loading => { + let loading_text = tr!("Loading {}...", scope.loading_description()); + ( + Container::from_tag("i").class("pwt-loading-icon"), + loading_text, + ) + } + _ => { + let icon = entry.icon_name(); + let text = entry.name(); + ( + if let Some(icon) = icon { + Container::new().with_child(Fa::new(icon)) + } else { + Container::new() + }, + text, + ) + } + }; + Row::new() + .class(AlignItems::Baseline) + .gap(2) + .with_child(icon) + .with_child(text) + .into() + }) + .into() +} + +fn create_enabled_column(scope: Rc) -> DataTableHeader { + DataTableColumn::new(tr!("Enabled")) + .width("40px") + .render(move |entry: &TreeEntry| match entry { + TreeEntry::Root => Html::default(), + TreeEntry::Remote(_) => { + if let Some((status, masked)) = entry.firewall_status() { + render_firewall_status(status, masked) + } else if matches!(scope.as_ref(), Scope::Node { .. }) { + Html::default() + } else { + render_warning_icon() + } + } + _ => { + if let Some((status, masked)) = entry.firewall_status() { + render_firewall_status(status, masked) + } else { + render_warning_icon() + } + } + }) + .into() +} + +fn create_rules_column(scope: Rc) -> DataTableHeader { + DataTableColumn::new(tr!("Rules")) + .render(move |entry: &TreeEntry| match entry { + TreeEntry::Root => Html::default(), + TreeEntry::Remote(_) => { + if let Some(rules) = entry.rule_stats() { + render_rule_stats(rules) + } else if matches!(scope.as_ref(), Scope::Node { .. }) { + Html::default() + } else { + render_load_error_message() + } + } + _ => { + if let Some(rules) = entry.rule_stats() { + render_rule_stats(rules) + } else { + render_load_error_message() + } + } + }) + .into() +} + +fn create_actions_column( + ctx: &LoadableComponentContext, +) -> DataTableHeader { + let link = ctx.link().clone(); + + DataTableColumn::new(tr!("Actions")) + .width("50px") + .justify("right") + .render(move |entry: &TreeEntry| { + if !entry.is_editable() { + return Html::default(); + } + + let view_state = match ViewState::from_entry(entry) { + Some(state) => state, + None => return Html::default(), + }; + + let link_clone = link.clone(); + pwt::widget::Tooltip::new(pwt::widget::ActionIcon::new("fa fa-fw fa-cog").on_activate( + move |_| { + link_clone.change_view(Some(view_state.clone())); + }, + )) + .tip(tr!("Edit Options")) + .into() + }) + .into() +} diff --git a/ui/src/remotes/firewall/mod.rs b/ui/src/remotes/firewall/mod.rs new file mode 100644 index 0000000..c500e1e --- /dev/null +++ b/ui/src/remotes/firewall/mod.rs @@ -0,0 +1,30 @@ +mod columns; +mod tree; +mod types; +mod ui_helpers; + +// Re-export public types +pub use tree::FirewallTreeComponent; + +use std::rc::Rc; +use yew::virtual_dom::{VComp, VNode}; +use yew::Properties; + +use proxmox_yew_comp::LoadableComponentMaster; + +#[derive(PartialEq, Properties)] +pub struct FirewallTree {} + +impl FirewallTree { + pub fn new() -> Self { + yew::props!(Self {}) + } +} + +impl From for VNode { + fn from(value: FirewallTree) -> Self { + let comp = + VComp::new::>(Rc::new(value), None); + VNode::from(comp) + } +} diff --git a/ui/src/remotes/firewall/tree.rs b/ui/src/remotes/firewall/tree.rs new file mode 100644 index 0000000..1dbddee --- /dev/null +++ b/ui/src/remotes/firewall/tree.rs @@ -0,0 +1,634 @@ +use futures::Future; +use std::pin::Pin; +use std::rc::Rc; +use yew::{ContextHandle, Html}; + +use proxmox_yew_comp::{EditFirewallOptions, LoadableComponent, LoadableComponentContext}; +use pwt::css; +use pwt::prelude::*; +use pwt::props::{FieldBuilder, WidgetBuilder}; +use pwt::state::{Selection, TreeStore}; +use pwt::tr; +use pwt::widget::data_table::DataTable; +use pwt::widget::form::{Combobox, Field}; +use pwt::widget::{Button, Container, Panel, Toolbar, Trigger}; + +use crate::RemoteList; + +use super::columns::create_columns; +use super::types::{ + FirewallError, GuestEntry, LoadState, NodeEntry, RemoteEntry, Scope, TreeEntry, ViewState, +}; +use super::ui_helpers::{create_panel_title, PanelConfig}; + +use pdm_api_types::firewall::{GuestKind, RemoteFirewallStatus}; +use std::cmp::Ordering; + +fn create_loading_tree() -> pwt::state::SlabTree { + let mut tree = pwt::state::SlabTree::new(); + tree.set_root(TreeEntry::Root); + tree +} + +fn build_tree_from_remotes( + remote_statuses: Vec, +) -> pwt::state::SlabTree { + let mut tree = pwt::state::SlabTree::new(); + tree.set_root(TreeEntry::Root); + + if let Some(mut root) = tree.root_mut() { + root.set_expanded(true); + + for remote_status in remote_statuses { + add_remote_to_tree(&mut root, remote_status); + } + } + + tree +} + +fn add_remote_to_tree( + root: &mut pwt::state::SlabTreeNodeMut, + remote_status: RemoteFirewallStatus, +) { + let remote_name = remote_status.remote.clone(); + let cluster_fw_status = remote_status.status; + + let cluster_is_enabled = cluster_fw_status + .as_ref() + .map(|s| s.enabled) + .unwrap_or(true); + + let remote_entry = TreeEntry::Remote(RemoteEntry { + name: remote_name.clone(), + status: cluster_fw_status, + }); + + let mut remote_handle = root.append(remote_entry); + remote_handle.set_expanded(cluster_is_enabled); + + for node_status in remote_status.nodes { + let node_name = node_status.node.clone(); + let node_firewall_status = node_status.status; + + let node_entry = TreeEntry::Node(NodeEntry { + remote: remote_name.clone(), + name: node_name.clone(), + status: node_firewall_status, + masked: !cluster_is_enabled, + }); + + let mut node_handle = remote_handle.append(node_entry); + node_handle.set_expanded(!node_status.guests.is_empty()); + + for guest in node_status.guests { + let guest_entry = GuestEntry::new( + guest.clone(), + node_name.clone(), + remote_name.clone(), + !cluster_is_enabled, + ); + + let tree_entry = match guest.kind { + GuestKind::Lxc => TreeEntry::Guest(guest_entry, GuestKind::Lxc), + GuestKind::Qemu => TreeEntry::Guest(guest_entry, GuestKind::Qemu), + }; + + node_handle.append(tree_entry); + } + } +} + +fn sort_entries(a: &TreeEntry, b: &TreeEntry) -> Ordering { + let rank_a = a.sort_rank(); + let rank_b = b.sort_rank(); + match rank_a.cmp(&rank_b) { + Ordering::Equal => a.name().cmp(&b.name()), + other => other, + } +} + +pub enum Msg { + DataLoaded { + generation: usize, + data: Vec, + }, + RemoteListChanged, + Reload, + FilterChanged(String), + ScopeChanged(Scope), + RemotesLoaded(Vec), + NodesLoaded { + generation: usize, + nodes: Vec, + }, + SelectionChanged(Option), + Error(FirewallError), + NoOp, +} + +pub struct FirewallTreeComponent { + store: TreeStore, + selection: Selection, + _context_listener: ContextHandle, + filter_text: String, + scope: Scope, + available_remotes: Vec, + available_nodes: Vec, + options_loading: bool, + load_state: LoadState, + selected_entry: Option, +} + +impl FirewallTreeComponent { + fn reset_tree_for_loading(&mut self) { + let tree = create_loading_tree(); + self.store.write().update_root_tree(tree); + self.store.write().set_view_root(true); + self.clear_selection(); + } + + fn clear_selection(&mut self) { + self.selected_entry = None; + } + + fn handle_scope_change(&mut self, ctx: &LoadableComponentContext, new_scope: Scope) { + let remote_changed = self.scope.remote_name() != new_scope.remote_name(); + + if remote_changed && new_scope.remote_name().is_some() { + self.scope = match &new_scope { + Scope::Node { remote, .. } | Scope::Remote { name: remote } => Scope::Remote { + name: remote.clone(), + }, + Scope::All => Scope::All, + }; + self.available_nodes.clear(); + self.start_node_load(ctx); + } else { + self.scope = new_scope; + } + + self.reset_tree_for_loading(); + let _generation = self.load_state.start_data_load(); + ctx.link().send_reload(); + } + + fn start_node_load(&mut self, ctx: &LoadableComponentContext) { + if let Some(remote) = self.scope.remote_name() { + let generation = self.load_state.start_nodes_load(); + let link = ctx.link().clone(); + let remote = remote.to_string(); + + ctx.link().spawn(async move { + match load_nodes_for_remote(remote).await { + Ok((_remote, nodes)) => { + link.send_message(Msg::NodesLoaded { generation, nodes }); + } + Err(err) => { + link.send_message(Msg::Error(err)); + } + } + }); + } + } + + fn render_tree_panel(&self, ctx: &LoadableComponentContext) -> Panel { + let columns = create_columns(ctx, self.store.clone(), ctx.loading(), &self.scope); + + let table = DataTable::new(columns, self.store.clone()) + .selection(self.selection.clone()) + .striped(false) + .borderless(true) + .show_header(false) + .class(css::FlexFit); + + let title = create_panel_title("shield", tr!("Firewall Status")); + + Panel::new() + .class(css::FlexFit) + .title(title) + .border(true) + .min_width(500) + .with_child(table) + .style("flex", "1 1 0") + } + + fn render_rules_panel(&self, _ctx: &LoadableComponentContext) -> Panel { + let config = match &self.selected_entry { + Some(entry) => PanelConfig::from_entry(entry), + None => PanelConfig::for_no_selection(), + }; + + config.build() + } +} + +impl LoadableComponent for FirewallTreeComponent { + type Properties = super::FirewallTree; + type Message = Msg; + type ViewState = ViewState; + + fn create(ctx: &LoadableComponentContext) -> Self { + let tree = create_loading_tree(); + let store = TreeStore::new(); + store.write().update_root_tree(tree); + store.write().set_view_root(true); + + let link = ctx.link(); + let selection = Selection::new().on_select( + link.callback(|selection: Selection| Msg::SelectionChanged(selection.selected_key())), + ); + + let (_, context_listener) = ctx + .link() + .yew_link() + .context(ctx.link().callback(|_: RemoteList| Msg::RemoteListChanged)) + .expect("No Remote list context provided"); + + Self { + store, + selection, + _context_listener: context_listener, + filter_text: String::new(), + scope: Scope::default(), + available_remotes: Vec::new(), + available_nodes: Vec::new(), + options_loading: true, + load_state: LoadState::default(), + selected_entry: None, + } + } + + fn load( + &self, + ctx: &LoadableComponentContext, + ) -> Pin>>> { + let link = ctx.link().clone(); + let scope = self.scope.clone(); + let need_remotes = self.available_remotes.is_empty(); + let generation = self.load_state.data_generation; + + Box::pin(async move { + // Load remotes list if needed + if need_remotes { + match load_remotes().await { + Ok(remotes) => { + link.send_message(Msg::RemotesLoaded(remotes)); + } + Err(err) => { + link.send_message(Msg::Error(err)); + } + } + } + + // Load firewall status + match load_firewall_status(&scope).await { + Ok(data) => { + link.send_message(Msg::DataLoaded { generation, data }); + } + Err(err) => { + link.send_message(Msg::Error(err)); + } + } + + Ok(()) + }) + } + + fn update(&mut self, ctx: &LoadableComponentContext, msg: Self::Message) -> bool { + match msg { + Msg::DataLoaded { generation, data } => { + if !self.load_state.is_data_current(generation) { + log::debug!( + "Ignoring stale data (generation {} vs current {})", + generation, + self.load_state.data_generation + ); + return false; + } + + let tree = build_tree_from_remotes(data); + self.store.write().set_view_root(false); + self.store.write().update_root_tree(tree); + self.store.set_sorter(sort_entries); + self.load_state.finish_load(); + self.clear_selection(); + true + } + Msg::RemoteListChanged => true, + Msg::Reload => { + let _generation = self.load_state.start_data_load(); + ctx.link().send_reload(); + false + } + Msg::FilterChanged(filter) => { + self.filter_text = filter; + if self.filter_text.is_empty() { + self.store.set_filter(None); + } else { + let filter_text = Rc::new(self.filter_text.clone()); + self.store + .set_filter(move |entry: &TreeEntry| entry.matches_filter(&filter_text)); + } + self.clear_selection(); + true + } + Msg::ScopeChanged(new_scope) => { + if self.scope != new_scope { + self.handle_scope_change(ctx, new_scope); + true + } else { + false + } + } + Msg::RemotesLoaded(remotes) => { + self.available_remotes = remotes; + self.options_loading = false; + true + } + Msg::NodesLoaded { generation, nodes } => { + if !self.load_state.is_nodes_current(generation) { + log::debug!( + "Ignoring stale nodes (generation {} vs current {})", + generation, + self.load_state.nodes_generation + ); + return false; + } + self.available_nodes = nodes; + true + } + Msg::SelectionChanged(key) => { + if let Some(key) = key { + let read_guard = self.store.read(); + if let Some(node_ref) = read_guard.lookup_node(&key) { + self.selected_entry = Some(node_ref.record().clone()); + } else { + self.selected_entry = None; + } + } else { + self.selected_entry = None; + } + true + } + Msg::Error(err) => { + log::error!("{}", err); + self.load_state.finish_load(); + true + } + Msg::NoOp => false, + } + } + + fn main_view(&self, ctx: &LoadableComponentContext) -> Html { + Container::new() + .class("pwt-content-spacer") + .class(css::FlexFit) + .class("pwt-flex-direction-row") + .with_child(self.render_tree_panel(ctx)) + .with_child(self.render_rules_panel(ctx)) + .into() + } + + fn dialog_view( + &self, + ctx: &LoadableComponentContext, + view_state: &Self::ViewState, + ) -> Option { + let dialog = match view_state { + ViewState::EditRemote { remote } => EditFirewallOptions::cluster(remote.to_string()) + .on_close(ctx.link().change_view_callback(|_| None)) + .into(), + ViewState::EditNode { remote, node } => { + EditFirewallOptions::node(remote.to_string(), node.to_string()) + .on_close(ctx.link().change_view_callback(|_| None)) + .into() + } + ViewState::EditGuest { + remote, + node, + vmid, + ty, + } => { + let vmtype: &str = ty.into(); + EditFirewallOptions::guest( + remote.to_string(), + node.to_string(), + *vmid as u64, + vmtype, + ) + .on_close(ctx.link().change_view_callback(|_| None)) + .into() + } + }; + + Some(dialog) + } + + fn toolbar(&self, ctx: &LoadableComponentContext) -> Option { + let link = ctx.link(); + + Some( + Toolbar::new() + .border_bottom(true) + .with_child( + Field::new() + .value(self.filter_text.clone()) + .with_trigger( + Trigger::new(if !self.filter_text.is_empty() { + "fa fa-times" + } else { + "" + }) + .on_activate(link.callback(|_| Msg::FilterChanged(String::new()))), + true, + ) + .placeholder(tr!("Filter")) + .on_input(link.callback(Msg::FilterChanged)), + ) + .with_child(create_remote_combobox( + ctx, + &self.available_remotes, + self.options_loading, + &self.scope, + )) + .with_child(create_node_combobox( + ctx, + &self.available_nodes, + self.options_loading, + &self.scope, + )) + .with_flex_spacer() + .with_child(Button::refresh(ctx.loading()).onclick(link.callback(|_| Msg::Reload))) + .into(), + ) + } +} + +fn create_remote_combobox( + ctx: &LoadableComponentContext, + available_remotes: &[String], + options_loading: bool, + current_scope: &Scope, +) -> Html { + if options_loading { + return Combobox::new() + .items(Rc::new(vec![])) + .placeholder(tr!("Loading...")) + .disabled(true) + .key("remote-combobox-loading") + .on_change(ctx.link().callback(|_: String| Msg::NoOp)) + .into(); + } + + let items: Vec = available_remotes + .iter() + .map(|remote| yew::AttrValue::from(remote.clone())) + .collect(); + + let current_value = current_scope + .remote_name() + .map(|s| yew::AttrValue::from(s.to_string())); + + Combobox::new() + .items(Rc::new(items)) + .default(current_value) + .placeholder(tr!("All remotes")) + .disabled(false) + .key("remote-combobox") + .on_change(ctx.link().callback(move |value: String| { + if value.is_empty() { + Msg::ScopeChanged(Scope::All) + } else { + Msg::ScopeChanged(Scope::Remote { name: value }) + } + })) + .into() +} + +fn create_node_combobox( + ctx: &LoadableComponentContext, + available_nodes: &[String], + options_loading: bool, + current_scope: &Scope, +) -> Html { + let selected_remote = current_scope.remote_name(); + + let items: Vec = if selected_remote.is_some() { + available_nodes + .iter() + .map(|node| yew::AttrValue::from(node.clone())) + .collect() + } else { + Vec::new() + }; + + let current_value = current_scope + .node_name() + .map(|s| yew::AttrValue::from(s.to_string())); + + let has_nodes = !available_nodes.is_empty(); + let is_enabled = selected_remote.is_some() && !options_loading && has_nodes; + let key = format!("node-combobox-{:?}", selected_remote); + + let selected_remote_owned = selected_remote.map(String::from); + + Combobox::new() + .items(Rc::new(items)) + .default(current_value) + .placeholder(tr!("All nodes")) + .disabled(!is_enabled) + .key(key) + .on_change(ctx.link().callback(move |value: String| { + if value.is_empty() { + if let Some(ref remote) = selected_remote_owned { + Msg::ScopeChanged(Scope::Remote { + name: remote.clone(), + }) + } else { + Msg::ScopeChanged(Scope::All) + } + } else if let Some(ref remote) = selected_remote_owned { + Msg::ScopeChanged(Scope::Node { + remote: remote.clone(), + name: value, + }) + } else { + Msg::ScopeChanged(Scope::All) + } + })) + .into() +} + +async fn load_firewall_status( + scope: &Scope, +) -> Result, FirewallError> { + match scope { + Scope::All => crate::pdm_client() + .pve_get_firewall_status() + .await + .map_err(|e| FirewallError::StatusLoadFailed { + scope: scope.clone(), + message: e.to_string(), + }), + Scope::Remote { name } => { + let remote_status = crate::pdm_client() + .pve_cluster_firewall_status(name) + .await + .map_err(|e| FirewallError::StatusLoadFailed { + scope: scope.clone(), + message: e.to_string(), + })?; + Ok(vec![remote_status]) + } + Scope::Node { remote, name } => { + let node_status = crate::pdm_client() + .pve_node_firewall_status(remote, name) + .await + .map_err(|e| FirewallError::StatusLoadFailed { + scope: scope.clone(), + message: e.to_string(), + })?; + + // Wrap node status in a remote status structure + let remote_status = pdm_api_types::firewall::RemoteFirewallStatus { + remote: remote.clone(), + status: None, + nodes: vec![node_status], + }; + Ok(vec![remote_status]) + } + } +} + +async fn load_remotes() -> Result, FirewallError> { + let remotes = crate::pdm_client() + .list_remotes() + .await + .map_err(|e| FirewallError::RemoteListLoadFailed(e.to_string()))?; + + Ok(remotes.into_iter().map(|r| r.id).collect()) +} + +async fn load_nodes_for_remote(remote: String) -> Result<(String, Vec), FirewallError> { + let resources = crate::pdm_client() + .pve_cluster_resources(&remote, Some(pdm_client::types::ClusterResourceKind::Node)) + .await + .map_err(|e| FirewallError::NodesLoadFailed { + remote: remote.clone(), + message: e.to_string(), + })?; + + let nodes = resources + .into_iter() + .filter_map(|resource| { + if let pdm_api_types::resource::PveResource::Node(node) = resource { + Some(node.node) + } else { + None + } + }) + .collect(); + + Ok((remote, nodes)) +} diff --git a/ui/src/remotes/firewall/types.rs b/ui/src/remotes/firewall/types.rs new file mode 100644 index 0000000..84aa657 --- /dev/null +++ b/ui/src/remotes/firewall/types.rs @@ -0,0 +1,284 @@ +use pdm_api_types::firewall::{FirewallStatus, GuestFirewallStatus, GuestKind, RuleStat}; +use pwt::props::ExtractPrimaryKey; +use std::fmt; + +#[derive(Clone, PartialEq, Eq, Debug)] +pub enum Scope { + /// Show all remotes, nodes, and guests + All, + /// Show specific remote with all its nodes and guests + Remote { name: String }, + /// Show specific node with all its guests + Node { remote: String, name: String }, +} + +impl Default for Scope { + fn default() -> Self { + Self::All + } +} + +impl Scope { + pub fn remote_name(&self) -> Option<&str> { + match self { + Self::All => None, + Self::Remote { name } | Self::Node { remote: name, .. } => Some(name), + } + } + + pub fn node_name(&self) -> Option<&str> { + match self { + Self::Node { name, .. } => Some(name), + _ => None, + } + } + + pub fn loading_description(&self) -> String { + match self { + Self::All => "all remotes".to_string(), + Self::Remote { name } => format!("remote {}", name), + Self::Node { remote, name } => format!("node {}/{}", remote, name), + } + } +} + +impl fmt::Display for Scope { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + match self { + Self::All => write!(f, "All"), + Self::Remote { name } => write!(f, "{}", name), + Self::Node { remote, name } => write!(f, "{}/{}", remote, name), + } + } +} + +#[derive(Clone, Debug, PartialEq)] +pub struct LoadState { + pub data_generation: usize, + pub nodes_generation: usize, + pub is_loading: bool, +} + +impl Default for LoadState { + fn default() -> Self { + Self { + data_generation: 0, + nodes_generation: 0, + is_loading: false, + } + } +} + +impl LoadState { + pub fn start_data_load(&mut self) -> usize { + self.data_generation = self.data_generation.wrapping_add(1); + self.is_loading = true; + self.data_generation + } + + pub fn start_nodes_load(&mut self) -> usize { + self.nodes_generation = self.nodes_generation.wrapping_add(1); + self.nodes_generation + } + + pub fn finish_load(&mut self) { + self.is_loading = false; + } + + pub fn is_data_current(&self, generation: usize) -> bool { + generation == self.data_generation + } + + pub fn is_nodes_current(&self, generation: usize) -> bool { + generation == self.nodes_generation + } +} + +#[derive(Clone, PartialEq, Debug)] +pub struct GuestEntry { + pub guest: GuestFirewallStatus, + pub node: String, + pub remote: String, + pub masked: bool, +} + +impl GuestEntry { + pub fn new(guest: GuestFirewallStatus, node: String, remote: String, masked: bool) -> Self { + Self { + guest, + node, + remote, + masked, + } + } +} + +#[derive(Clone, PartialEq, Debug)] +pub enum TreeEntry { + Root, + Remote(RemoteEntry), + Node(NodeEntry), + Guest(GuestEntry, GuestKind), +} + +#[derive(Clone, PartialEq, Debug)] +pub struct RemoteEntry { + pub name: String, + pub status: Option, +} + +#[derive(Clone, PartialEq, Debug)] +pub struct NodeEntry { + pub remote: String, + pub name: String, + pub status: Option, + pub masked: bool, +} + +impl TreeEntry { + pub fn name(&self) -> String { + match self { + Self::Root => String::new(), + Self::Remote(entry) => entry.name.clone(), + Self::Node(entry) => entry.name.clone(), + Self::Guest(guest, _) => { + format!("{} ({})", guest.guest.vmid, guest.guest.name) + } + } + } + + pub fn matches_filter(&self, filter_text: &str) -> bool { + let text = filter_text.to_lowercase(); + + match self { + Self::Root | Self::Remote(..) | Self::Node(..) => true, + Self::Guest(guest, kind) => { + let type_name = kind.as_str(); + guest.guest.name.to_lowercase().contains(&text) + || guest.guest.vmid.to_string().contains(&text) + || type_name.contains(&text) + || guest.node.to_lowercase().contains(&text) + || guest.remote.to_lowercase().contains(&text) + } + } + } + + pub fn icon_name(&self) -> Option<&'static str> { + match self { + Self::Remote(..) => Some("server"), + Self::Node(..) => Some("building"), + Self::Guest(_, GuestKind::Lxc) => Some("cube"), + Self::Guest(_, GuestKind::Qemu) => Some("desktop"), + Self::Root => None, + } + } + + pub fn is_editable(&self) -> bool { + !matches!(self, Self::Root) + } + + pub fn firewall_status(&self) -> Option<(&FirewallStatus, bool)> { + match self { + Self::Remote(entry) => entry.status.as_ref().map(|s| (s, false)), + Self::Node(entry) => entry.status.as_ref().map(|s| (s, entry.masked)), + Self::Guest(guest, _) => guest.guest.status.as_ref().map(|s| (s, guest.masked)), + Self::Root => None, + } + } + + pub fn rule_stats(&self) -> Option<&RuleStat> { + self.firewall_status().map(|(status, _)| &status.rules) + } + + pub fn sort_rank(&self) -> u8 { + match self { + Self::Root => 0, + Self::Remote(..) => 1, + Self::Node(..) => 2, + Self::Guest(_, GuestKind::Lxc) => 3, + Self::Guest(_, GuestKind::Qemu) => 4, + } + } +} + +impl ExtractPrimaryKey for TreeEntry { + fn extract_key(&self) -> yew::virtual_dom::Key { + use yew::virtual_dom::Key; + match self { + Self::Root => Key::from("root"), + Self::Remote(entry) => Key::from(format!("remote-{}", entry.name)), + Self::Node(entry) => Key::from(format!("{}/{}", entry.remote, entry.name)), + Self::Guest(guest, _) => Key::from(format!( + "{}/{}/{}", + guest.remote, guest.node, guest.guest.vmid + )), + } + } +} + +#[derive(PartialEq, Debug, Clone)] +pub enum ViewState { + EditRemote { + remote: String, + }, + EditNode { + remote: String, + node: String, + }, + EditGuest { + remote: String, + node: String, + vmid: u32, + ty: GuestKind, + }, +} + +impl ViewState { + pub fn from_entry(entry: &TreeEntry) -> Option { + match entry { + TreeEntry::Remote(e) => Some(Self::EditRemote { + remote: e.name.clone(), + }), + TreeEntry::Node(e) => Some(Self::EditNode { + remote: e.remote.clone(), + node: e.name.clone(), + }), + TreeEntry::Guest(guest, _) => Some(Self::EditGuest { + remote: guest.remote.clone(), + node: guest.node.clone(), + vmid: guest.guest.vmid, + ty: guest.guest.kind, + }), + TreeEntry::Root => None, + } + } +} + +#[derive(Debug, Clone)] +pub enum FirewallError { + RemoteListLoadFailed(String), + StatusLoadFailed { scope: Scope, message: String }, + NodesLoadFailed { remote: String, message: String }, +} + +impl fmt::Display for FirewallError { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + match self { + Self::RemoteListLoadFailed(msg) => { + write!(f, "Failed to load remote list: {}", msg) + } + Self::StatusLoadFailed { scope, message } => { + write!( + f, + "Failed to load firewall status for {}: {}", + scope, message + ) + } + Self::NodesLoadFailed { remote, message } => { + write!(f, "Failed to load nodes for remote {}: {}", remote, message) + } + } + } +} + +impl std::error::Error for FirewallError {} diff --git a/ui/src/remotes/firewall/ui_helpers.rs b/ui/src/remotes/firewall/ui_helpers.rs new file mode 100644 index 0000000..f9be466 --- /dev/null +++ b/ui/src/remotes/firewall/ui_helpers.rs @@ -0,0 +1,156 @@ +use pdm_api_types::firewall::{FirewallStatus, GuestKind, RuleStat}; +use pwt::css::{AlignItems, FontColor}; +use pwt::prelude::*; +use pwt::tr; +use pwt::widget::{Container, Fa, Panel, Row}; +use yew::{html, Html}; + +use super::types::TreeEntry; + +pub fn render_firewall_status(status: &FirewallStatus, masked: bool) -> Html { + if status.enabled { + let check = if !masked { + Fa::new("check").class(FontColor::Success) + } else { + Fa::new("check") + }; + Row::new() + .class(AlignItems::Baseline) + .gap(2) + .with_child(check) + .into() + } else { + Row::new() + .class(AlignItems::Baseline) + .gap(2) + .with_child(Fa::new("minus")) + .into() + } +} + +pub fn render_rule_stats(rules: &RuleStat) -> Html { + if rules.all == 0 { + return Html::default(); + } + Row::new() + .with_child(format!( + "{} out of {} rules enabled", + rules.active, rules.all + )) + .into() +} + +pub fn render_warning_icon() -> Html { + Row::new() + .with_child(Fa::new("exclamation-triangle").class(FontColor::Warning)) + .into() +} + +pub fn render_load_error_message() -> Html { + Row::new() + .with_child(tr!("Could not load firewall status")) + .into() +} + +pub fn create_panel_title(icon_name: &str, title_text: String) -> Html { + Row::new() + .gap(2) + .class(AlignItems::Baseline) + .with_child(Fa::new(icon_name)) + .with_child(title_text) + .into() +} + +pub fn create_rules_panel(title: Html, key: String, content: Html) -> Panel { + Panel::new() + .class(pwt::css::FlexFit) + .title(title) + .border(true) + .min_width(500) + .with_child(Container::new().key(key).with_child(content)) + .style("flex", "1 1 0") +} + +pub struct PanelConfig { + pub title: Html, + pub key: String, + pub content: Html, +} + +impl PanelConfig { + pub fn for_remote(remote: &str) -> Self { + Self { + title: create_panel_title("list", tr!("Cluster Firewall Rules - {}", remote)), + key: format!("cluster-{}", remote), + content: proxmox_yew_comp::FirewallRules::cluster(remote.to_string()).into(), + } + } + + pub fn for_node(remote: &str, node: &str) -> Self { + Self { + title: create_panel_title("list", tr!("Node Firewall Rules - {}/{}", remote, node)), + key: format!("node-{}-{}", remote, node), + content: proxmox_yew_comp::FirewallRules::node(remote.to_string(), node.to_string()) + .into(), + } + } + + pub fn for_guest(remote: &str, node: &str, vmid: u32, kind: GuestKind) -> Self { + let vmtype = kind.as_str(); + Self { + title: create_panel_title( + "list", + tr!( + "Guest Firewall Rules - {}/{}/{} {}", + remote, + node, + vmtype.to_uppercase(), + vmid + ), + ), + key: format!("guest-{}-{}-{}-{}", remote, node, vmid, vmtype), + content: proxmox_yew_comp::FirewallRules::guest( + remote.to_string(), + node.to_string(), + vmid as u64, + vmtype, + ) + .into(), + } + } + + pub fn for_no_selection() -> Self { + let header = tr!("No entry selected"); + let msg = tr!("Select a firewall entry to show its rules."); + + let content = pwt::widget::Column::new() + .class(pwt::css::FlexFit) + .padding(2) + .class(AlignItems::Center) + .class(pwt::css::TextAlign::Center) + .with_child(html! {

{header}

}) + .with_child(Container::new().with_child(msg)) + .into(); + + Self { + title: create_panel_title("list", tr!("Firewall Rules")), + key: String::new(), + content, + } + } + + pub fn from_entry(entry: &TreeEntry) -> Self { + match entry { + TreeEntry::Remote(remote_entry) => Self::for_remote(&remote_entry.name), + TreeEntry::Node(node_entry) => Self::for_node(&node_entry.remote, &node_entry.name), + TreeEntry::Guest(guest, kind) => { + Self::for_guest(&guest.remote, &guest.node, guest.guest.vmid, *kind) + } + TreeEntry::Root => Self::for_no_selection(), + } + } + + pub fn build(self) -> Panel { + create_rules_panel(self.title, self.key, self.content) + } +} diff --git a/ui/src/remotes/mod.rs b/ui/src/remotes/mod.rs index 5e06b2c..603077c 100644 --- a/ui/src/remotes/mod.rs +++ b/ui/src/remotes/mod.rs @@ -27,6 +27,9 @@ pub use tasks::RemoteTaskList; mod updates; pub use updates::UpdateTree; +mod firewall; +pub use firewall::FirewallTree; + use yew::{function_component, Html}; use pwt::prelude::*; @@ -63,6 +66,13 @@ pub fn system_configuration() -> Html { .label(tr!("Updates")) .icon_class("fa fa-refresh"), |_| UpdateTree::new().into(), + ) + .with_item_builder( + TabBarItem::new() + .key("firewall") + .label(tr!("Firewall")) + .icon_class("fa fa-shield"), + |_| FirewallTree::new().into(), ); NavigationContainer::new().with_child(panel).into() -- 2.47.3 _______________________________________________ pdm-devel mailing list pdm-devel@lists.proxmox.com https://lists.proxmox.com/cgi-bin/mailman/listinfo/pdm-devel