public inbox for pdm-devel@lists.proxmox.com
 help / color / mirror / Atom feed
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


  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
Service provided by Proxmox Server Solutions GmbH | Privacy | Legal