public inbox for pdm-devel@lists.proxmox.com
 help / color / mirror / Atom feed
From: Hannes Laimer <h.laimer@proxmox.com>
To: pdm-devel@lists.proxmox.com
Subject: [pdm-devel] [PATCH proxmox-yew-comp 2/2] firewall: rules: make security group entries expandable
Date: Fri,  5 Dec 2025 16:25:43 +0100	[thread overview]
Message-ID: <20251205152543.91431-9-h.laimer@proxmox.com> (raw)
In-Reply-To: <20251205152543.91431-1-h.laimer@proxmox.com>

With this is is possible to see what rules a security group contains
within the list of normal firewall rules.

Signed-off-by: Hannes Laimer <h.laimer@proxmox.com>
---
 src/firewall/context.rs |  12 +++
 src/firewall/rules.rs   | 202 ++++++++++++++++++++++++++++++++++++----
 2 files changed, 194 insertions(+), 20 deletions(-)

diff --git a/src/firewall/context.rs b/src/firewall/context.rs
index 6495fa3..79aa69b 100644
--- a/src/firewall/context.rs
+++ b/src/firewall/context.rs
@@ -82,6 +82,18 @@ impl FirewallContext {
         }
     }
 
+    pub fn group_rules_url(&self, group: &str) -> String {
+        match self {
+            Self::Cluster { remote } | Self::Node { remote, .. } | Self::Guest { remote, .. } => {
+                format!(
+                    "/pve/remotes/{}/firewall/groups/{}",
+                    percent_encode_component(remote),
+                    percent_encode_component(group)
+                )
+            }
+        }
+    }
+
     pub fn options_url(&self) -> String {
         match self {
             Self::Cluster { remote } => {
diff --git a/src/firewall/rules.rs b/src/firewall/rules.rs
index c40fab7..234fd3c 100644
--- a/src/firewall/rules.rs
+++ b/src/firewall/rules.rs
@@ -1,12 +1,17 @@
+use std::collections::HashSet;
+use std::ops::Deref;
 use std::rc::Rc;
 
 use yew::html::{IntoEventCallback, IntoPropValue};
 use yew::virtual_dom::{Key, VComp, VNode};
 
 use pwt::prelude::*;
-use pwt::state::{Loader, LoaderState, SharedStateObserver, Store};
-use pwt::widget::data_table::{DataTable, DataTableColumn, DataTableHeader};
-use pwt::widget::{Container, Fa};
+use pwt::props::ExtractPrimaryKey;
+use pwt::state::{Loader, LoaderState, SharedStateObserver, SlabTree, TreeStore};
+use pwt::widget::data_table::{
+    DataTable, DataTableCellRenderArgs, DataTableCellRenderer, DataTableColumn, DataTableHeader,
+};
+use pwt::widget::{Container, Fa, Row};
 use pwt_macros::builder;
 
 use super::context::FirewallContext;
@@ -76,16 +81,45 @@ impl FirewallRules {
     }
 }
 
+#[derive(Clone, Debug, PartialEq)]
+pub struct FirewallRuleEntry {
+    pub rule: pve_api_types::FirewallRule,
+    pub parent_group: Option<String>,
+}
+
+impl Deref for FirewallRuleEntry {
+    type Target = pve_api_types::FirewallRule;
+
+    fn deref(&self) -> &Self::Target {
+        &self.rule
+    }
+}
+
+impl ExtractPrimaryKey for FirewallRuleEntry {
+    fn extract_key(&self) -> Key {
+        match &self.parent_group {
+            Some(group) => Key::from(format!("group-{group}-{}", self.pos)),
+            None => Key::from(format!("top-{}", self.pos)),
+        }
+    }
+}
+
 pub enum FirewallMsg {
     DataChange,
+    ToggleGroup(Key, String),
+    GroupRulesLoaded(
+        String,
+        Result<Vec<pve_api_types::FirewallRule>, anyhow::Error>,
+    ),
 }
 
 #[doc(hidden)]
 pub struct ProxmoxFirewallRules {
-    store: Store<pve_api_types::FirewallRule>,
+    store: TreeStore<FirewallRuleEntry>,
     loader: Loader<Vec<pve_api_types::FirewallRule>>,
     _listener: SharedStateObserver<LoaderState<Vec<pve_api_types::FirewallRule>>>,
-    columns: Rc<Vec<DataTableHeader<pve_api_types::FirewallRule>>>,
+    columns: Rc<Vec<DataTableHeader<FirewallRuleEntry>>>,
+    loaded_groups: HashSet<String>,
 }
 
 fn pill(text: impl Into<AttrValue>) -> Container {
@@ -148,28 +182,108 @@ fn format_firewall_rule(rule: &pve_api_types::FirewallRule) -> Html {
         .collect::<Html>()
 }
 
+// Helper to create a dummy root entry since TreeStore needs a root
+impl Default for FirewallRuleEntry {
+    fn default() -> Self {
+        Self {
+            rule: pve_api_types::FirewallRule {
+                action: "".to_string(),
+                comment: None,
+                dest: None,
+                dport: None,
+                enable: None,
+                icmp_type: None,
+                iface: None,
+                ipversion: None,
+                log: None,
+                r#macro: None,
+                pos: 0,
+                proto: None,
+                source: None,
+                sport: None,
+                ty: "".to_string(),
+            },
+            parent_group: None,
+        }
+    }
+}
+
 impl ProxmoxFirewallRules {
     fn update_data(&mut self) {
         if let Some(Ok(data)) = &self.loader.read().data {
-            self.store.set_data((**data).clone());
+            let mut tree = SlabTree::new();
+            let mut root = tree.set_root(FirewallRuleEntry::default());
+
+            for rule in data.iter() {
+                let entry = FirewallRuleEntry {
+                    rule: rule.clone(),
+                    parent_group: None,
+                };
+                root.append(entry);
+            }
+
+            self.store.set_data(tree);
+            self.loaded_groups.clear();
         }
     }
 
-    fn build_columns() -> Rc<Vec<DataTableHeader<pve_api_types::FirewallRule>>> {
+    fn build_columns(
+        on_toggle: Callback<(Key, String)>,
+    ) -> Rc<Vec<DataTableHeader<FirewallRuleEntry>>> {
         Rc::new(vec![
             DataTableColumn::new("")
-                .width("30px")
+                .width("40px")
                 .justify("right")
                 .show_menu(false)
                 .resizable(false)
-                .render(|rule: &pve_api_types::FirewallRule| html! {&rule.pos})
+                .render_cell(DataTableCellRenderer::new(move |args: &mut DataTableCellRenderArgs<FirewallRuleEntry>| {
+                    let on_toggle = on_toggle.clone();
+                    let rule = &args.record().rule;
+                    let is_group = rule.ty == "group";
+                    let group_name = rule.action.clone();
+                    let key = args.key().clone();
+
+                    let expander = if is_group {
+                        let caret = if args.is_expanded() {
+                            "pwt-tree-expander fa fa-fw fa-caret-down"
+                        } else {
+                            "pwt-tree-expander fa fa-fw fa-caret-right"
+                        };
+
+                        let onclick = move |event: MouseEvent| {
+                            event.stop_propagation();
+                            on_toggle.emit((key.clone(), group_name.clone()));
+                        };
+
+                        html! { <i role="none" style="cursor: pointer;" class={caret} {onclick} /> }
+                    } else {
+                        html! {}
+                    };
+
+                    let content = if !is_group {
+                        html! { &rule.pos }
+                    } else {
+                        html! {}
+                    };
+
+                    let indent = Container::from_tag("span")
+                        .style("flex", "0 0 auto")
+                        .padding_start(4 * args.level());
+
+                    Row::new()
+                        .class(pwt::css::AlignItems::Baseline)
+                        .with_child(indent)
+                        .with_child(expander)
+                        .with_child(content)
+                        .into()
+                }))
                 .into(),
             DataTableColumn::new(tr!("On"))
                 .width("40px")
                 .justify("center")
                 .resizable(false)
                 .render(
-                    |rule: &pve_api_types::FirewallRule| match rule.enable {
+                    |rule: &FirewallRuleEntry| match rule.enable {
                         Some(1) => Fa::new("check").into(),
                         Some(0) | None => Fa::new("minus").into(),
                         _ => "-".into(),
@@ -178,19 +292,19 @@ impl ProxmoxFirewallRules {
                 .into(),
             DataTableColumn::new(tr!("Type"))
                 .width("80px")
-                .render(|rule: &pve_api_types::FirewallRule| html! {&rule.ty})
+                .render(|rule: &FirewallRuleEntry| html! {&rule.ty})
                 .into(),
             DataTableColumn::new(tr!("Action"))
                 .width("100px")
-                .render(|rule: &pve_api_types::FirewallRule| html! {&rule.action})
+                .render(|rule: &FirewallRuleEntry| html! {&rule.action})
                 .into(),
             DataTableColumn::new(tr!("Rule"))
                 .flex(1)
-                .render(|rule: &pve_api_types::FirewallRule| format_firewall_rule(rule))
+                .render(|rule: &FirewallRuleEntry| format_firewall_rule(&rule.rule))
                 .into(),
             DataTableColumn::new(tr!("Comment"))
                 .width("150px")
-                .render(|rule: &pve_api_types::FirewallRule| {
+                .render(|rule: &FirewallRuleEntry| {
                     rule.comment.as_deref().unwrap_or("-").into()
                 })
                 .into(),
@@ -207,9 +321,7 @@ impl Component for ProxmoxFirewallRules {
 
         let url: AttrValue = props.context.rules_url().into();
 
-        let store = Store::with_extract_key(|item: &pve_api_types::FirewallRule| {
-            Key::from(item.pos.to_string())
-        });
+        let store = TreeStore::new().view_root(false);
 
         let loader = Loader::new().loader({
             let url = url.clone();
@@ -224,12 +336,18 @@ impl Component for ProxmoxFirewallRules {
         loader.load();
 
         let mut me = Self {
-            store,
+            store: store.clone(),
             loader,
             _listener,
-            columns: Self::build_columns(),
+            columns: Rc::new(Vec::new()), // Initial empty columns, will be set below
+            loaded_groups: HashSet::new(),
         };
 
+        me.columns = Self::build_columns(
+            ctx.link()
+                .callback(|(key, group)| FirewallMsg::ToggleGroup(key, group)),
+        );
+
         me.update_data();
         me
     }
@@ -241,12 +359,56 @@ impl Component for ProxmoxFirewallRules {
         true
     }
 
-    fn update(&mut self, _ctx: &Context<Self>, msg: Self::Message) -> bool {
+    fn update(&mut self, ctx: &Context<Self>, msg: Self::Message) -> bool {
         match msg {
             FirewallMsg::DataChange => {
                 self.update_data();
                 true
             }
+            FirewallMsg::ToggleGroup(key, group_name) => {
+                if let Some(mut node) = self.store.write().lookup_node_mut(&key) {
+                    let expanded = node.expanded();
+                    node.set_expanded(!expanded);
+
+                    if !expanded && !self.loaded_groups.contains(&group_name) {
+                        let url = ctx.props().context.group_rules_url(&group_name);
+                        let group_name_clone = group_name.clone();
+                        ctx.link().send_future(async move {
+                            let result = crate::http_get(url, None).await;
+                            FirewallMsg::GroupRulesLoaded(group_name_clone, result)
+                        });
+                    }
+                }
+                false
+            }
+            FirewallMsg::GroupRulesLoaded(group_name, result) => {
+                if let Ok(rules) = result {
+                    self.loaded_groups.insert(group_name.clone());
+
+                    let mut parent_key = None;
+                    {
+                        for (_, item) in self.store.filtered_data() {
+                            if item.record().ty == "group" && item.record().action == group_name {
+                                parent_key = Some(self.store.extract_key(&item.record()));
+                                break;
+                            }
+                        }
+                    }
+
+                    if let Some(key) = parent_key {
+                        if let Some(mut parent_node) = self.store.write().lookup_node_mut(&key) {
+                            for rule in rules {
+                                let entry = FirewallRuleEntry {
+                                    rule,
+                                    parent_group: Some(group_name.clone()),
+                                };
+                                parent_node.append(entry);
+                            }
+                        }
+                    }
+                }
+                true
+            }
         }
     }
 
-- 
2.47.3



_______________________________________________
pdm-devel mailing list
pdm-devel@lists.proxmox.com
https://lists.proxmox.com/cgi-bin/mailman/listinfo/pdm-devel


      parent reply	other threads:[~2025-12-05 15:26 UTC|newest]

Thread overview: 9+ messages / expand[flat|nested]  mbox.gz  Atom feed  top
2025-12-05 15:25 [pdm-devel] [RFC proxmox{, -datacenter-manager, -yew-comp} 0/8] make security groups expandable in firewall rules list Hannes Laimer
2025-12-05 15:25 ` [pdm-devel] [PATCH proxmox 1/4] pve-api-types: rename ListFirewallRules to FirewallRule Hannes Laimer
2025-12-05 15:25 ` [pdm-devel] [PATCH proxmox 2/4] pve-api-types: update pve-api.json Hannes Laimer
2025-12-05 15:25 ` [pdm-devel] [PATCH proxmox 3/4] pve-api-types: add security group GET endpoints Hannes Laimer
2025-12-05 15:25 ` [pdm-devel] [PATCH proxmox 4/4] pve-api-types: regenerate Hannes Laimer
2025-12-05 15:25 ` [pdm-devel] [PATCH proxmox-datacenter-manager 1/2] pdm: rename ListFirewallRules to FirewallRule Hannes Laimer
2025-12-05 15:25 ` [pdm-devel] [PATCH proxmox-datacenter-manager 2/2] api: firewall: add pve firewall security group GET endpoints Hannes Laimer
2025-12-05 15:25 ` [pdm-devel] [PATCH proxmox-yew-comp 1/2] firewall: rules: rename ListFirewallRules to FirewallRule Hannes Laimer
2025-12-05 15:25 ` 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=20251205152543.91431-9-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 a public inbox, see mirroring instructions
for how to clone and mirror all data and code used for this inbox
Service provided by Proxmox Server Solutions GmbH | Privacy | Legal