From mboxrd@z Thu Jan 1 00:00:00 1970 Return-Path: Received: from firstgate.proxmox.com (firstgate.proxmox.com [212.224.123.68]) by lore.proxmox.com (Postfix) with ESMTPS id B9B1A1FF179 for ; Wed, 12 Nov 2025 12:20:52 +0100 (CET) Received: from firstgate.proxmox.com (localhost [127.0.0.1]) by firstgate.proxmox.com (Proxmox) with ESMTP id A468C1FBFA; Wed, 12 Nov 2025 12:21:39 +0100 (CET) Message-ID: <82ed4e5a-1fef-42cb-930c-6464f9e10aad@proxmox.com> Date: Wed, 12 Nov 2025 12:21:00 +0100 MIME-Version: 1.0 User-Agent: Mozilla Thunderbird To: Proxmox Datacenter Manager development discussion , Hannes Laimer References: <20251110172517.335741-1-h.laimer@proxmox.com> <20251110172517.335741-13-h.laimer@proxmox.com> Content-Language: en-US From: Stefan Hanreich In-Reply-To: <20251110172517.335741-13-h.laimer@proxmox.com> X-SPAM-LEVEL: Spam detection results: 0 AWL 0.725 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 SPF_HELO_NONE 0.001 SPF: HELO does not publish an SPF Record SPF_PASS -0.001 SPF: sender matches SPF record Subject: Re: [pdm-devel] [PATCH proxmox-datacenter-manager v3 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" some comments inline On 11/10/25 6:25 PM, Hannes Laimer wrote: > 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 | 154 ++++++ > ui/src/remotes/firewall/mod.rs | 30 ++ > ui/src/remotes/firewall/tree.rs | 662 ++++++++++++++++++++++++++ > ui/src/remotes/firewall/types.rs | 284 +++++++++++ > ui/src/remotes/firewall/ui_helpers.rs | 166 +++++++ > ui/src/remotes/mod.rs | 10 + > 6 files changed, 1306 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..d1becb6 > --- /dev/null > +++ b/ui/src/remotes/firewall/columns.rs > @@ -0,0 +1,154 @@ > +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")) > + .width("250px") > + .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") > + .justify("center") > + .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")) > + .flex(1) > + .width("160px") > + .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..b1d3555 > --- /dev/null > +++ b/ui/src/remotes/firewall/tree.rs > @@ -0,0 +1,662 @@ > +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::PanelConfig; > + > +use pdm_api_types::firewall::RemoteFirewallStatus; > +use pdm_api_types::remotes::RemoteType; > +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 = TreeEntry::Guest(guest_entry, guest.kind); > + > + 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, > + } > +} maybe (a.sort_rank(), a.name()).cmp(&(b.sort_rank(), b.name())), makes the intention clearer imo - or is a.name() expensive? > + > +pub enum Msg { > + DataLoaded { > + generation: usize, > + data: Vec, > + }, > + RemoteListChanged, > + Reload, > + FilterChanged(String), > + ScopeChanged(Scope), > + RemotesLoaded(Vec), > + NodesLoaded { > + generation: usize, > + nodes: Vec, > + }, > + SelectionChanged(Option), > + ToggleTreePanel, > + 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, > + tree_collapsed: bool, > +} > + > +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_toolbar = Toolbar::new() > + .border_bottom(true) > + .with_child( > + pwt::widget::Row::new() > + .class(pwt::css::AlignItems::Baseline) > + .class(pwt::css::FontStyle::TitleMedium) > + .gap(2) > + .with_child(pwt::widget::Fa::new("shield")) > + .with_child(tr!("Status")), > + ) > + .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(ctx.link().callback(|_| Msg::FilterChanged(String::new()))), > + true, > + ) > + .placeholder(tr!("Filter")) > + .on_input(ctx.link().callback(Msg::FilterChanged)), > + ) > + .with_child( > + Button::new_icon("fa fa-angle-double-left") > + .onclick(ctx.link().callback(|_| Msg::ToggleTreePanel)) > + .aria_label(tr!("Hide tree panel")), > + ); > + > + let scope_toolbar = Toolbar::new() > + .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(ctx.link().callback(|_| Msg::Reload)), > + ); > + > + Panel::new().border(true).with_child( > + pwt::widget::Column::new() > + .class(css::FlexFit) > + .with_child(title_toolbar) > + .with_child(scope_toolbar) > + .with_child(table), > + ) > + } > + > + fn render_rules_panel(&self, ctx: &LoadableComponentContext) -> Panel { > + let mut config = match &self.selected_entry { > + Some(entry) => PanelConfig::from_entry(entry), > + None => PanelConfig::for_no_selection(), > + }; > + > + if self.tree_collapsed { > + let expand_button: Html = Button::new_icon("fa fa-angle-double-right") > + .onclick(ctx.link().callback(|_| Msg::ToggleTreePanel)) > + .aria_label(tr!("Show tree panel")) > + .into(); > + > + config.title_prefix = Some(expand_button); > + } > + > + 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, > + tree_collapsed: false, > + } > + } > + > + 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) => { nit: if you're storing the selection in the component, you could just use it directly here instead of passing the key around > + 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::ToggleTreePanel => { > + self.tree_collapsed = !self.tree_collapsed; > + true > + } > + Msg::Error(err) => { > + log::error!("{}", err); We have pwt::widget::error_message which we could conditionally render in this case? > + self.load_state.finish_load(); > + true > + } > + Msg::NoOp => false, > + } > + } > + > + fn main_view(&self, ctx: &LoadableComponentContext) -> Html { > + let mut container = Container::new() > + .class("pwt-content-spacer") > + .class(css::FlexFit) > + .class("pwt-flex-direction-row"); > + > + if !self.tree_collapsed { > + container = container.with_child(self.render_tree_panel(ctx)); > + } > + > + container.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 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() > + .filter(|r| r.ty == RemoteType::Pve) > + .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..49048f2 > --- /dev/null > +++ b/ui/src/remotes/firewall/ui_helpers.rs > @@ -0,0 +1,166 @@ > +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!("{} 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 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, > + pub title_prefix: Option, > +} > + > +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(), > + title_prefix: None, > + } > + } > + > + 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(), > + title_prefix: None, > + } > + } > + > + 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(), > + title_prefix: None, > + } > + } > + > + 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, > + title_prefix: None, > + } > + } > + > + 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 { > + let title = if let Some(prefix) = self.title_prefix { > + Row::new() > + .gap(2) > + .class(AlignItems::Baseline) > + .with_child(prefix) > + .with_child(self.title) > + .into() > + } else { > + self.title > + }; > + create_rules_panel(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() _______________________________________________ pdm-devel mailing list pdm-devel@lists.proxmox.com https://lists.proxmox.com/cgi-bin/mailman/listinfo/pdm-devel