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 77D7B1FF165 for ; Thu, 28 Aug 2025 09:15:35 +0200 (CEST) Received: from firstgate.proxmox.com (localhost [127.0.0.1]) by firstgate.proxmox.com (Proxmox) with ESMTP id 4E4978FE2; Thu, 28 Aug 2025 09:15:44 +0200 (CEST) Message-ID: <4e0eadeb-f027-4d7b-8065-06e48751a9bb@proxmox.com> Date: Thu, 28 Aug 2025 09:15:27 +0200 MIME-Version: 1.0 User-Agent: Mozilla Thunderbird Beta To: Proxmox Datacenter Manager development discussion , Stefan Hanreich References: <20250827113427.199253-1-s.hanreich@proxmox.com> <20250827113427.199253-28-s.hanreich@proxmox.com> Content-Language: en-US From: Dominik Csapak In-Reply-To: <20250827113427.199253-28-s.hanreich@proxmox.com> X-Bm-Milter-Handled: 55990f41-d878-4baa-be0a-ee34c49e34d2 X-Bm-Transport-Timestamp: 1756365331942 X-SPAM-LEVEL: Spam detection results: 0 AWL 0.021 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 RCVD_IN_VALIDITY_CERTIFIED_BLOCKED 0.001 ADMINISTRATOR NOTICE: The query to Validity was blocked. See https://knowledge.validity.com/hc/en-us/articles/20961730681243 for more information. RCVD_IN_VALIDITY_RPBL_BLOCKED 0.001 ADMINISTRATOR NOTICE: The query to Validity was blocked. See https://knowledge.validity.com/hc/en-us/articles/20961730681243 for more information. RCVD_IN_VALIDITY_SAFE_BLOCKED 0.001 ADMINISTRATOR NOTICE: The query to Validity was blocked. See https://knowledge.validity.com/hc/en-us/articles/20961730681243 for more information. SPF_HELO_NONE 0.001 SPF: HELO does not publish an SPF Record SPF_PASS -0.001 SPF: sender matches SPF record Subject: Re: [pdm-devel] [PATCH proxmox-datacenter-manager 11/16] ui: add view for showing ip vrfs 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-Transfer-Encoding: 7bit Content-Type: text/plain; charset="us-ascii"; Format="flowed" Errors-To: pdm-devel-bounces@lists.proxmox.com Sender: "pdm-devel" here i have the same comments regarding columns/selection change message and tree update as in the last patch On 8/27/25 1:40 PM, Stefan Hanreich wrote: > This component shows the content of all IP-VRFs from all remotes > configured in the PDM. It merges the contents of IP-VRFs that have the > same ASN:VNI (= Route Target) combination. Practically speaking, it > shows a list of VNets who would have the Route Target of the IP-VRF in > their EVPN route target. This means that when importing the IP-VRF in > a different zone, routes from guests in those VNets would be imported > into the routing table. > > The component operates under the assumption that zones that are in the > same ASN, are also interconnected - since it merges the VNets of all > zones with the same route target (= ASN + VRF VNI). This means ASNs > cannot be reused across remotes, if they are not connected, in order > for this view to correctly show the contents of the IP-VRFs. In the > future this could be improved by storing IDs or tags on the PVE side, > reading them from PDM and then only merging the zones of remotes that > have the same ID / tag. > > In addition to the terms zones / vnets, the terms IP-VRF and MAC-VRF > are introduced. For EVPN a zone maps to a routing table and a vnet > maps to a bridging table. FRR [1] uses the terms in their > documentation and they are also referred to as such in the EVPN RFC > [2]. In order to make this relationship more clear, particularly to > users that are familiar with EVPN but not necessarily Proxmox VE SDN, > those terms are now used in addition to the existing terms zone / > vnet. > > [1] https://docs.frrouting.org/en/latest/evpn.html > [2] https://datatracker.ietf.org/doc/html/rfc8365 > > Signed-off-by: Stefan Hanreich > --- > ui/src/lib.rs | 2 + > ui/src/sdn/evpn/mod.rs | 3 + > ui/src/sdn/evpn/vrf_tree.rs | 415 ++++++++++++++++++++++++++++++++++++ > ui/src/sdn/mod.rs | 1 + > 4 files changed, 421 insertions(+) > create mode 100644 ui/src/sdn/evpn/vrf_tree.rs > create mode 100644 ui/src/sdn/mod.rs > > diff --git a/ui/src/lib.rs b/ui/src/lib.rs > index e3755ec..bde8917 100644 > --- a/ui/src/lib.rs > +++ b/ui/src/lib.rs > @@ -30,6 +30,8 @@ mod widget; > pub mod pbs; > pub mod pve; > > +pub mod sdn; > + > pub mod renderer; > > mod tasks; > diff --git a/ui/src/sdn/evpn/mod.rs b/ui/src/sdn/evpn/mod.rs > index c2958f0..da020a9 100644 > --- a/ui/src/sdn/evpn/mod.rs > +++ b/ui/src/sdn/evpn/mod.rs > @@ -1,6 +1,9 @@ > mod remote_tree; > pub use remote_tree::RemoteTree; > > +mod vrf_tree; > +pub use vrf_tree::VrfTree; > + > #[derive(Clone, Copy, Debug, PartialEq, Eq, Ord, PartialOrd, Hash)] > pub struct EvpnRouteTarget { > asn: u32, > diff --git a/ui/src/sdn/evpn/vrf_tree.rs b/ui/src/sdn/evpn/vrf_tree.rs > new file mode 100644 > index 0000000..cc9528a > --- /dev/null > +++ b/ui/src/sdn/evpn/vrf_tree.rs > @@ -0,0 +1,415 @@ > +use std::cmp::Ordering; > +use std::collections::HashSet; > +use std::rc::Rc; > + > +use anyhow::{format_err, Error}; > +use yew::virtual_dom::{Key, VNode}; > +use yew::{Component, Context, Html, Properties}; > + > +use pdm_client::types::{ListController, ListVnet, ListZone}; > +use pwt::css; > +use pwt::props::{ContainerBuilder, ExtractPrimaryKey, WidgetBuilder}; > +use pwt::state::{Selection, SlabTree, TreeStore}; > +use pwt::tr; > +use pwt::widget::data_table::{ > + DataTable, DataTableColumn, DataTableHeader, DataTableRowRenderArgs, > +}; > +use pwt::widget::{error_message, Column, Fa, Row}; > +use pwt_macros::widget; > + > +use crate::sdn::evpn::EvpnRouteTarget; > + > +#[widget(comp=VrfTreeComponent)] > +#[derive(Clone, PartialEq, Properties, Default)] > +pub struct VrfTree { > + zones: Rc>, > + vnets: Rc>, > + controllers: Rc>, > +} > + > +impl VrfTree { > + pub fn new( > + zones: Rc>, > + vnets: Rc>, > + controllers: Rc>, > + ) -> Self { > + yew::props!(Self { > + zones, > + vnets, > + controllers, > + }) > + } > +} > + > +pub enum VrfTreeMsg { > + SelectionChange, > +} > + > +#[derive(Clone, PartialEq, Debug)] > +struct VrfData { > + route_target: EvpnRouteTarget, > +} > + > +#[derive(Clone, PartialEq, Debug)] > +struct FdbData { > + vrf_route_target: EvpnRouteTarget, > + route_target: EvpnRouteTarget, > +} > + > +#[derive(Clone, PartialEq, Debug)] > +struct RemoteData { > + remote: String, > + zone: String, > + vnet: String, > +} > + > +#[derive(Clone, PartialEq, Debug)] > +enum VrfTreeEntry { > + Root, > + Vrf(VrfData), > + Fdb(FdbData), > + Remote(RemoteData), > +} > + > +impl VrfTreeEntry { > + fn vni(&self) -> Option { > + match self { > + VrfTreeEntry::Vrf(vrf) => Some(vrf.route_target.vni), > + VrfTreeEntry::Fdb(fdb) => Some(fdb.route_target.vni), > + _ => None, > + } > + } > + > + fn asn(&self) -> Option { > + match self { > + VrfTreeEntry::Vrf(vrf) => Some(vrf.route_target.asn), > + _ => None, > + } > + } > + > + fn heading(&self) -> Option { > + Some(match self { > + VrfTreeEntry::Root => return None, > + VrfTreeEntry::Vrf(_) => "IP-VRF".to_string(), > + VrfTreeEntry::Fdb(_) => "VNet".to_string(), > + VrfTreeEntry::Remote(remote) => remote.vnet.clone(), > + }) > + } > +} > + > +impl ExtractPrimaryKey for VrfTreeEntry { > + fn extract_key(&self) -> Key { > + match self { > + Self::Root => Key::from("root"), > + Self::Vrf(vrf) => Key::from(vrf.route_target.to_string()), > + Self::Fdb(fdb) => Key::from(format!("{}/{}", fdb.vrf_route_target, fdb.route_target)), > + Self::Remote(remote) => { > + Key::from(format!("{}/{}/{}", remote.remote, remote.zone, remote.vnet,)) > + } > + } > + } > +} > + > +fn zones_to_vrf_view( > + controllers: &[ListController], > + zones: &[ListZone], > + vnets: &[ListVnet], > +) -> Result, Error> { > + let mut tree = SlabTree::new(); > + > + let mut root = tree.set_root(VrfTreeEntry::Root); > + root.set_expanded(true); > + > + let mut existing_vrfs: HashSet = HashSet::new(); > + > + for zone in zones { > + let zone_data = &zone.zone; > + > + let zone_controller_id = zone_data.controller.as_ref().ok_or_else(|| { > + format_err!("EVPN zone {} has no controller defined!", &zone_data.zone) > + })?; > + > + let controller = controllers > + .iter() > + .find(|controller| { > + controller.remote == zone.remote > + && zone_controller_id == &controller.controller.controller > + }) > + .ok_or_else(|| { > + format_err!("Controller of EVPN zone {} does not exist", zone_data.zone) > + })?; > + > + let controller_asn = controller.controller.asn.ok_or_else(|| { > + format_err!( > + "EVPN controller {} has no ASN defined!", > + controller.controller.controller > + ) > + })?; > + > + let route_target = EvpnRouteTarget { > + asn: controller_asn, > + vni: zone > + .zone > + .vrf_vxlan > + .ok_or_else(|| format_err!("EVPN Zone {} has no VRF VNI", zone_data.zone))?, > + }; > + > + if !existing_vrfs.insert(route_target) { > + continue; > + } > + > + let mut vrf_entry = root.append(VrfTreeEntry::Vrf(VrfData { route_target })); > + vrf_entry.set_expanded(true); > + } > + > + for vnet in vnets { > + let vnet_data = &vnet.vnet; > + > + let vnet_zone_id = vnet_data > + .zone > + .as_ref() > + .ok_or_else(|| format_err!("VNet {} has no zone defined!", vnet_data.vnet))?; > + > + let Some(zone) = zones > + .iter() > + .find(|zone| { > + zone.remote == vnet.remote > + && vnet_zone_id == &zone.zone.zone > + }) else { > + // this VNet is not part of an EVPN zone, skip it > + continue; > + }; > + > + let zone_controller_id = zone.zone.controller.as_ref().ok_or_else(|| { > + format_err!("EVPN zone {} has no controller defined!", &zone.zone.zone) > + })?; > + > + let controller = controllers > + .iter() > + .find(|controller| { > + controller.remote == zone.remote > + && zone_controller_id == &controller.controller.controller > + }) > + .ok_or_else(|| { > + format_err!("Controller of EVPN zone {} does not exist", zone.zone.zone) > + })?; > + > + let controller_asn = controller.controller.asn.ok_or_else(|| { > + format_err!( > + "EVPN controller {} has no ASN defined!", > + controller.controller.controller > + ) > + })?; > + > + let zone_target = EvpnRouteTarget { > + asn: controller_asn, > + vni: zone > + .zone > + .vrf_vxlan > + .ok_or_else(|| format_err!("EVPN Zone {} has no VRF VNI", zone.zone.zone))?, > + }; > + > + let vnet_target = EvpnRouteTarget { > + asn: controller_asn, > + vni: vnet_data > + .tag > + .ok_or_else(|| format_err!("VNet {} has no VNI", vnet_data.vnet))?, > + }; > + > + for mut vrf_entry in root.children_mut() { > + if let VrfTreeEntry::Vrf(vrf_data) = vrf_entry.record() { > + if vrf_data.route_target != zone_target { > + continue; > + } > + > + let searched_entry = vrf_entry.children_mut().find(|entry| { > + if let VrfTreeEntry::Fdb(fdb_data) = entry.record() { > + return fdb_data.route_target == vnet_target; > + } > + > + false > + }); > + > + let mut fdb_entry = if let Some(fdb_entry) = searched_entry { > + fdb_entry > + } else { > + let fdb_entry = vrf_entry.append(VrfTreeEntry::Fdb(FdbData { > + vrf_route_target: zone_target, > + route_target: vnet_target, > + })); > + > + fdb_entry > + }; > + > + let vnet_zone = > + vnet.vnet.zone.as_ref().ok_or_else(|| { > + format_err!("VNet {} has no zone defined!", vnet.vnet.vnet) > + })?; > + > + fdb_entry.append(VrfTreeEntry::Remote(RemoteData { > + remote: vnet.remote.clone(), > + zone: vnet_zone.clone(), > + vnet: vnet.vnet.vnet.clone(), > + })); > + } > + } > + } > + > + Ok(tree) > +} > + > +pub struct VrfTreeComponent { > + store: TreeStore, > + selection: Selection, > + error_msg: Option, > +} > + > +fn default_sorter(a: &VrfTreeEntry, b: &VrfTreeEntry) -> Ordering { > + (a.asn(), a.vni()).cmp(&(b.asn(), b.vni())) > +} > + > +impl VrfTreeComponent { > + fn columns(store: TreeStore) -> Rc>> { > + Rc::new(vec![ > + DataTableColumn::new(tr!("Type / Name")) > + .tree_column(store) > + .render(|item: &VrfTreeEntry| { > + let heading = item.heading(); > + > + heading > + .map(|heading| { > + let mut row = Row::new().class(css::AlignItems::Baseline).gap(2); > + > + row = match item { > + VrfTreeEntry::Vrf(_) => row.with_child(Fa::new("th")), > + VrfTreeEntry::Fdb(_) => row.with_child(Fa::new("sdn-vnet")), > + _ => row, > + }; > + > + row = row.with_child(heading); > + > + Html::from(row) > + }) > + .unwrap_or_default() > + }) > + .sorter(default_sorter) > + .into(), > + DataTableColumn::new(tr!("ASN")) > + .render(|item: &VrfTreeEntry| item.asn().map(VNode::from).unwrap_or_default()) > + .sorter(|a: &VrfTreeEntry, b: &VrfTreeEntry| a.asn().cmp(&b.asn())) > + .into(), > + DataTableColumn::new(tr!("VNI")) > + .render(|item: &VrfTreeEntry| item.vni().map(VNode::from).unwrap_or_default()) > + .sorter(|a: &VrfTreeEntry, b: &VrfTreeEntry| a.vni().cmp(&b.vni())) > + .into(), > + DataTableColumn::new(tr!("Zone")) > + .get_property(|item: &VrfTreeEntry| match item { > + VrfTreeEntry::Remote(remote) => remote.zone.as_str(), > + _ => "", > + }) > + .into(), > + DataTableColumn::new(tr!("Remote")) > + .get_property(|item: &VrfTreeEntry| match item { > + VrfTreeEntry::Remote(remote) => remote.remote.as_str(), > + _ => "", > + }) > + .into(), > + ]) > + } > +} > + > +impl Component for VrfTreeComponent { > + type Properties = VrfTree; > + type Message = VrfTreeMsg; > + > + fn create(ctx: &Context) -> Self { > + let store = TreeStore::new().view_root(false); > + > + let selection = > + Selection::new().on_select(ctx.link().callback(|_| Self::Message::SelectionChange)); seems unused > + > + let mut error_msg = None; > + > + match zones_to_vrf_view( > + &ctx.props().controllers, > + &ctx.props().zones, > + &ctx.props().vnets, > + ) { > + Ok(data) => { > + store.set_data(data); > + store.set_sorter(default_sorter); > + } > + Err(error) => { > + error_msg = Some(error.to_string()); > + } > + } > + > + Self { > + store, > + selection, > + error_msg, > + } > + } > + > + fn view(&self, _ctx: &Context) -> Html { > + let columns = Self::columns(self.store.clone()); could be saved in the component > + > + let table = DataTable::new(columns, self.store.clone()) > + .striped(false) > + .selection(self.selection.clone()) > + .row_render_callback(|args: &mut DataTableRowRenderArgs| { > + if let VrfTreeEntry::Vrf(_) = args.record() { > + args.add_class("pwt-bg-color-surface"); > + } > + }) > + .class(css::FlexFit); > + > + let mut column = Column::new().class(pwt::css::FlexFit).with_child(table); > + > + if let Some(msg) = &self.error_msg { > + column.add_child(error_message(msg.as_ref())); > + } > + > + column.into() > + } > + > + fn changed(&mut self, ctx: &Context, old_props: &Self::Properties) -> bool { > + if !Rc::ptr_eq(&ctx.props().zones, &old_props.zones) > + || !Rc::ptr_eq(&ctx.props().vnets, &old_props.vnets) > + || !Rc::ptr_eq(&ctx.props().controllers, &old_props.controllers) > + { > + match zones_to_vrf_view( > + &ctx.props().controllers, > + &ctx.props().zones, > + &ctx.props().vnets, > + ) { > + Ok(data) => { > + let expanded_state = self > + .store > + .read() > + .root() > + .map(|root| root.extract_expanded_state()); > + > + self.store.set_data(data); > + > + if let Some(expanded_state) = expanded_state { > + if let Some(mut root) = self.store.write().root_mut() { > + root.apply_expanded_state(&expanded_state); > + } > + } can be done with update_root_tree > + > + self.store.set_sorter(default_sorter); > + > + self.error_msg = None; > + } > + Err(error) => { > + self.error_msg = Some(error.to_string()); > + } > + } > + > + return true; > + } > + > + false > + } > +} > diff --git a/ui/src/sdn/mod.rs b/ui/src/sdn/mod.rs > new file mode 100644 > index 0000000..ef2eab9 > --- /dev/null > +++ b/ui/src/sdn/mod.rs > @@ -0,0 +1 @@ > +pub mod evpn; _______________________________________________ pdm-devel mailing list pdm-devel@lists.proxmox.com https://lists.proxmox.com/cgi-bin/mailman/listinfo/pdm-devel