From: Stefan Hanreich <s.hanreich@proxmox.com>
To: Proxmox Datacenter Manager development discussion
<pdm-devel@lists.proxmox.com>,
Hannes Laimer <h.laimer@proxmox.com>
Subject: Re: [pdm-devel] [PATCH proxmox-datacenter-manager v3 4/4] ui: add firewall status tree
Date: Wed, 12 Nov 2025 12:21:00 +0100 [thread overview]
Message-ID: <82ed4e5a-1fef-42cb-930c-6464f9e10aad@proxmox.com> (raw)
In-Reply-To: <20251110172517.335741-13-h.laimer@proxmox.com>
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 <h.laimer@proxmox.com>
> ---
> 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<FirewallTreeComponent>,
> + store: TreeStore<TreeEntry>,
> + loading: bool,
> + scope: &Scope,
> +) -> Rc<Vec<DataTableHeader<TreeEntry>>> {
> + 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<TreeEntry>,
> + loading: bool,
> + scope: Rc<Scope>,
> +) -> DataTableHeader<TreeEntry> {
> + 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<Scope>) -> DataTableHeader<TreeEntry> {
> + 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<Scope>) -> DataTableHeader<TreeEntry> {
> + 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<FirewallTreeComponent>,
> +) -> DataTableHeader<TreeEntry> {
> + 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<FirewallTree> for VNode {
> + fn from(value: FirewallTree) -> Self {
> + let comp =
> + VComp::new::<LoadableComponentMaster<FirewallTreeComponent>>(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<TreeEntry> {
> + let mut tree = pwt::state::SlabTree::new();
> + tree.set_root(TreeEntry::Root);
> + tree
> +}
> +
> +fn build_tree_from_remotes(
> + remote_statuses: Vec<RemoteFirewallStatus>,
> +) -> pwt::state::SlabTree<TreeEntry> {
> + 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<TreeEntry>,
> + 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<pdm_api_types::firewall::RemoteFirewallStatus>,
> + },
> + RemoteListChanged,
> + Reload,
> + FilterChanged(String),
> + ScopeChanged(Scope),
> + RemotesLoaded(Vec<String>),
> + NodesLoaded {
> + generation: usize,
> + nodes: Vec<String>,
> + },
> + SelectionChanged(Option<yew::virtual_dom::Key>),
> + ToggleTreePanel,
> + Error(FirewallError),
> + NoOp,
> +}
> +
> +pub struct FirewallTreeComponent {
> + store: TreeStore<TreeEntry>,
> + selection: Selection,
> + _context_listener: ContextHandle<RemoteList>,
> + filter_text: String,
> + scope: Scope,
> + available_remotes: Vec<String>,
> + available_nodes: Vec<String>,
> + options_loading: bool,
> + load_state: LoadState,
> + selected_entry: Option<TreeEntry>,
> + 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<Self>, 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<Self>) {
> + 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<Self>) -> 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<Self>) -> 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>) -> 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<Self>,
> + ) -> Pin<Box<dyn Future<Output = Result<(), anyhow::Error>>>> {
> + 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<Self>, 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<Self>) -> 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<Self>,
> + view_state: &Self::ViewState,
> + ) -> Option<Html> {
> + 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<FirewallTreeComponent>,
> + 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<yew::AttrValue> = 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<FirewallTreeComponent>,
> + available_nodes: &[String],
> + options_loading: bool,
> + current_scope: &Scope,
> +) -> Html {
> + let selected_remote = current_scope.remote_name();
> +
> + let items: Vec<yew::AttrValue> = 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<Vec<pdm_api_types::firewall::RemoteFirewallStatus>, 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<Vec<String>, 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<String>), 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<FirewallStatus>,
> +}
> +
> +#[derive(Clone, PartialEq, Debug)]
> +pub struct NodeEntry {
> + pub remote: String,
> + pub name: String,
> + pub status: Option<FirewallStatus>,
> + 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<Self> {
> + 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<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(),
> + 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! {<h1 class="pwt-font-headline-medium">{header}</h1>})
> + .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
next prev parent reply other threads:[~2025-11-12 11:20 UTC|newest]
Thread overview: 21+ messages / expand[flat|nested] mbox.gz Atom feed top
2025-11-10 17:25 [pdm-devel] [PATCH proxmox{, -yew-comp, -datacenter-manager} v3 00/12] add basic integration of PVE firewall Hannes Laimer
2025-11-10 17:25 ` [pdm-devel] [PATCH proxmox v3 1/4] pve-api-types: update pve-api.json Hannes Laimer
2025-11-10 17:25 ` [pdm-devel] [PATCH proxmox v3 2/4] pve-api-types: add get/update firewall options endpoints Hannes Laimer
2025-11-10 17:25 ` [pdm-devel] [PATCH proxmox v3 3/4] pve-api-types: add list firewall rules endpoints Hannes Laimer
2025-11-10 17:25 ` [pdm-devel] [PATCH proxmox v3 4/4] pve-api-types: regenerate Hannes Laimer
2025-11-10 17:25 ` [pdm-devel] [PATCH proxmox-yew-comp v3 1/4] form: add helpers for extractig data out of schemas Hannes Laimer
2025-11-10 17:25 ` [pdm-devel] [PATCH proxmox-yew-comp v3 2/4] firewall: add FirewallContext Hannes Laimer
2025-11-10 17:25 ` [pdm-devel] [PATCH proxmox-yew-comp v3 3/4] firewall: add options edit form Hannes Laimer
2025-11-10 17:25 ` [pdm-devel] [PATCH proxmox-yew-comp v3 4/4] firewall: add rules table Hannes Laimer
2025-11-12 13:06 ` Stefan Hanreich
2025-11-10 17:25 ` [pdm-devel] [PATCH proxmox-datacenter-manager v3 1/4] pdm-api-types: add firewall status types Hannes Laimer
2025-11-10 17:25 ` [pdm-devel] [PATCH proxmox-datacenter-manager v3 2/4] api: firewall: add option, rules and status endpoints Hannes Laimer
2025-11-12 10:52 ` Stefan Hanreich
2025-11-12 11:09 ` Hannes Laimer
2025-11-12 11:22 ` Thomas Lamprecht
2025-11-12 11:27 ` Stefan Hanreich
2025-11-10 17:25 ` [pdm-devel] [PATCH proxmox-datacenter-manager v3 3/4] pdm-client: add api methods for firewall options, " Hannes Laimer
2025-11-10 17:25 ` [pdm-devel] [PATCH proxmox-datacenter-manager v3 4/4] ui: add firewall status tree Hannes Laimer
2025-11-12 11:21 ` Stefan Hanreich [this message]
2025-11-12 14:41 ` Lukas Wagner
2025-11-12 13:07 ` [pdm-devel] [PATCH proxmox{, -yew-comp, -datacenter-manager} v3 00/12] add basic integration of PVE firewall Stefan Hanreich
Reply instructions:
You may reply publicly to this message via plain-text email
using any one of the following methods:
* Save the following mbox file, import it into your mail client,
and reply-to-all from there: mbox
Avoid top-posting and favor interleaved quoting:
https://en.wikipedia.org/wiki/Posting_style#Interleaved_style
* Reply using the --to, --cc, and --in-reply-to
switches of git-send-email(1):
git send-email \
--in-reply-to=82ed4e5a-1fef-42cb-930c-6464f9e10aad@proxmox.com \
--to=s.hanreich@proxmox.com \
--cc=h.laimer@proxmox.com \
--cc=pdm-devel@lists.proxmox.com \
/path/to/YOUR_REPLY
https://kernel.org/pub/software/scm/git/docs/git-send-email.html
* If your mail client supports setting the In-Reply-To header
via mailto: links, try the mailto: link
Be sure your reply has a Subject: header at the top and a blank line
before the message body.
This is a public inbox, see mirroring instructions
for how to clone and mirror all data and code used for this inbox