From mboxrd@z Thu Jan 1 00:00:00 1970 Return-Path: Received: from firstgate.proxmox.com (firstgate.proxmox.com [IPv6:2a01:7e0:0:424::9]) by lore.proxmox.com (Postfix) with ESMTPS id D548A1FF1A6 for ; Fri, 5 Dec 2025 16:26:07 +0100 (CET) Received: from firstgate.proxmox.com (localhost [127.0.0.1]) by firstgate.proxmox.com (Proxmox) with ESMTP id 0E98E53BD; Fri, 5 Dec 2025 16:26:36 +0100 (CET) From: Hannes Laimer To: pdm-devel@lists.proxmox.com Date: Fri, 5 Dec 2025 16:25:43 +0100 Message-ID: <20251205152543.91431-9-h.laimer@proxmox.com> X-Mailer: git-send-email 2.47.3 In-Reply-To: <20251205152543.91431-1-h.laimer@proxmox.com> References: <20251205152543.91431-1-h.laimer@proxmox.com> MIME-Version: 1.0 X-Bm-Milter-Handled: 55990f41-d878-4baa-be0a-ee34c49e34d2 X-Bm-Transport-Timestamp: 1764948314751 X-SPAM-LEVEL: Spam detection results: 0 AWL 0.054 Adjusted score from AWL reputation of From: address BAYES_00 -1.9 Bayes spam probability is 0 to 1% DMARC_MISSING 0.1 Missing DMARC policy KAM_DMARC_STATUS 0.01 Test Rule for DKIM or SPF Failure with Strict Alignment SPF_HELO_NONE 0.001 SPF: HELO does not publish an SPF Record SPF_PASS -0.001 SPF: sender matches SPF record Subject: [pdm-devel] [PATCH proxmox-yew-comp 2/2] firewall: rules: make security group entries expandable X-BeenThere: pdm-devel@lists.proxmox.com X-Mailman-Version: 2.1.29 Precedence: list List-Id: Proxmox Datacenter Manager development discussion List-Unsubscribe: , List-Archive: List-Post: List-Help: List-Subscribe: , Reply-To: Proxmox Datacenter Manager development discussion Content-Type: text/plain; charset="us-ascii" Content-Transfer-Encoding: 7bit Errors-To: pdm-devel-bounces@lists.proxmox.com Sender: "pdm-devel" 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 --- 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, +} + +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, anyhow::Error>, + ), } #[doc(hidden)] pub struct ProxmoxFirewallRules { - store: Store, + store: TreeStore, loader: Loader>, _listener: SharedStateObserver>>, - columns: Rc>>, + columns: Rc>>, + loaded_groups: HashSet, } fn pill(text: impl Into) -> Container { @@ -148,28 +182,108 @@ fn format_firewall_rule(rule: &pve_api_types::FirewallRule) -> Html { .collect::() } +// 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>> { + fn build_columns( + on_toggle: Callback<(Key, String)>, + ) -> Rc>> { 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| { + 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! { } + } 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, msg: Self::Message) -> bool { + fn update(&mut self, ctx: &Context, 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