From: Hannes Laimer <h.laimer@proxmox.com>
To: pdm-devel@lists.proxmox.com
Subject: [pdm-devel] [PATCH proxmox-datacenter-manager 4/4] ui: add firewall status tree
Date: Thu, 30 Oct 2025 15:34:06 +0100	[thread overview]
Message-ID: <20251030143406.193744-14-h.laimer@proxmox.com> (raw)
In-Reply-To: <20251030143406.193744-1-h.laimer@proxmox.com>
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    | 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<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"))
+        .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")
+        .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"))
+        .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..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<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 = 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<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>),
+    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>,
+}
+
+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 = 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<Self>) -> 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>) -> 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<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) => {
+                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<Self>) -> 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<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 toolbar(&self, ctx: &LoadableComponentContext<Self>) -> Option<Html> {
+        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<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().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..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! {<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,
+        }
+    }
+
+    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
     prev parent reply	other threads:[~2025-10-30 14:33 UTC|newest]
Thread overview: 14+ messages / expand[flat|nested]  mbox.gz  Atom feed  top
2025-10-30 14:33 [pdm-devel] [PATCH proxmox{, -yew-comp, -datacenter-manager} 00/13] add basic integration of PVE firewall Hannes Laimer
2025-10-30 14:33 ` [pdm-devel] [PATCH proxmox 1/5] pve-api-types: update pve-api.json Hannes Laimer
2025-10-30 14:33 ` [pdm-devel] [PATCH proxmox 2/5] pve-api-types: add get/update firewall options endpoints Hannes Laimer
2025-10-30 14:33 ` [pdm-devel] [PATCH proxmox 3/5] pve-api-types: schema2rust: handle `macro` keyword like we do `type` Hannes Laimer
2025-10-30 14:33 ` [pdm-devel] [PATCH proxmox 4/5] pve-api-types: add list firewall rules endpoints Hannes Laimer
2025-10-30 14:33 ` [pdm-devel] [PATCH proxmox 5/5] pve-api-types: regenerate Hannes Laimer
2025-10-30 14:33 ` [pdm-devel] [PATCH proxmox-yew-comp 1/4] form: add helpers for extractig data out of schemas Hannes Laimer
2025-10-30 14:34 ` [pdm-devel] [PATCH proxmox-yew-comp 2/4] firewall: add FirewallContext Hannes Laimer
2025-10-30 14:34 ` [pdm-devel] [PATCH proxmox-yew-comp 3/4] firewall: add options edit form Hannes Laimer
2025-10-30 14:34 ` [pdm-devel] [PATCH proxmox-yew-comp 4/4] firewall: add rules table Hannes Laimer
2025-10-30 14:34 ` [pdm-devel] [PATCH proxmox-datacenter-manager 1/4] pdm-api-types: add firewall status types Hannes Laimer
2025-10-30 14:34 ` [pdm-devel] [PATCH proxmox-datacenter-manager 2/4] api: firewall: add option, rules and status endpoints Hannes Laimer
2025-10-30 14:34 ` [pdm-devel] [PATCH proxmox-datacenter-manager 3/4] pdm-client: add api methods for firewall options, " Hannes Laimer
2025-10-30 14:34 ` Hannes Laimer [this message]
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=20251030143406.193744-14-h.laimer@proxmox.com \
    --to=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 an external index of several public inboxes,
see mirroring instructions on how to clone and mirror
all data and code used by this external index.