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 782AC1FF187 for ; Tue, 18 Nov 2025 15:12:55 +0100 (CET) Received: from firstgate.proxmox.com (localhost [127.0.0.1]) by firstgate.proxmox.com (Proxmox) with ESMTP id D31F51627B; Tue, 18 Nov 2025 15:12:59 +0100 (CET) Date: Tue, 18 Nov 2025 15:12:55 +0100 Message-Id: From: "Lukas Wagner" To: "Proxmox Datacenter Manager development discussion" , "Stefan Hanreich" Mime-Version: 1.0 X-Mailer: aerc 0.21.0-0-g5549850facc2-dirty References: <20251107085934.118815-1-s.hanreich@proxmox.com> <20251107085934.118815-7-s.hanreich@proxmox.com> In-Reply-To: <20251107085934.118815-7-s.hanreich@proxmox.com> X-Bm-Milter-Handled: 55990f41-d878-4baa-be0a-ee34c49e34d2 X-Bm-Transport-Timestamp: 1763475145192 X-SPAM-LEVEL: Spam detection results: 0 AWL 0.031 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 URIBL_BLOCKED 0.001 ADMINISTRATOR NOTICE: The query to URIBL was blocked. See http://wiki.apache.org/spamassassin/DnsBlocklists#dnsbl-block for more information. [self.store, mod.rs] Subject: Re: [pdm-devel] [PATCH proxmox-datacenter-manager 3/5] ui: sdn: evpn: add zone status panel 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" some notes inline, apart from this: Reviewed-by: Lukas Wagner On Fri Nov 7, 2025 at 9:59 AM CET, Stefan Hanreich wrote: > This panel shows the status of the IP-VRF of an EVPN zone. It lists > the routing table of a given node, which can be selected via the > dropdown in the panel. It will be used in the remote tree of the EVPN > view to display detailed information about the selected EVPN zone. > > Signed-off-by: Stefan Hanreich > --- > ui/src/sdn/evpn/mod.rs | 33 +++++ > ui/src/sdn/evpn/zone_status.rs | 261 +++++++++++++++++++++++++++++++++ > 2 files changed, 294 insertions(+) > create mode 100644 ui/src/sdn/evpn/zone_status.rs > > diff --git a/ui/src/sdn/evpn/mod.rs b/ui/src/sdn/evpn/mod.rs > index 1948ecf..6c919ba 100644 > --- a/ui/src/sdn/evpn/mod.rs > +++ b/ui/src/sdn/evpn/mod.rs > @@ -13,6 +13,9 @@ pub use add_vnet::AddVnetWindow; > mod add_zone; > pub use add_zone::AddZoneWindow; > > +mod zone_status; > +pub use zone_status::ZoneStatusTable; > + > #[derive(Clone, Copy, Debug, PartialEq, Eq, Ord, PartialOrd, Hash)] > pub struct EvpnRouteTarget { > asn: u32, > @@ -39,3 +42,33 @@ impl std::fmt::Display for EvpnRouteTarget { > write!(f, "{}:{}", self.asn, self.vni) > } > } > + > +#[derive(Debug, Clone, PartialEq, Default)] > +#[repr(transparent)] > +pub struct NodeList(Vec); > + > +impl std::ops::Deref for NodeList { > + type Target = Vec; > + > + fn deref(&self) -> &Self::Target { > + &self.0 > + } > +} > + > +impl std::str::FromStr for NodeList { > + type Err = anyhow::Error; > + > + fn from_str(value: &str) -> Result { > + if value.is_empty() { > + anyhow::bail!("node list cannot be an empty string"); > + } > + > + Ok(Self(value.split(",").map(String::from).collect())) > + } > +} > + > +impl FromIterator for NodeList { > + fn from_iter>(iter: I) -> Self { > + Self(iter.into_iter().collect()) > + } > +} > diff --git a/ui/src/sdn/evpn/zone_status.rs b/ui/src/sdn/evpn/zone_status.rs > new file mode 100644 > index 0000000..a78fd7d > --- /dev/null > +++ b/ui/src/sdn/evpn/zone_status.rs > @@ -0,0 +1,261 @@ > +use std::cmp::Ordering; > +use std::future::Future; > +use std::pin::Pin; > +use std::rc::Rc; > + > +use anyhow::{Context, Error}; > +use proxmox_yew_comp::{LoadableComponent, LoadableComponentContext, LoadableComponentMaster}; > +use pwt::props::{EventSubscriber, ExtractPrimaryKey}; > +use yew::virtual_dom::{Key, VComp, VNode}; > +use yew::{html, AttrValue, Properties}; nit: include ordering (std -> 3rd party -> proxmox -> crate level) > + > +use pdm_client::types::SdnZoneIpVrf; > +use pwt::props::{ContainerBuilder, FieldBuilder, WidgetBuilder, WidgetStyleBuilder}; > +use pwt::state::Store; > +use pwt::tr; > +use pwt::widget::data_table::{DataTable, DataTableColumn, DataTableHeader}; > +use pwt::widget::form::Combobox; > +use pwt::widget::{error_message, Button, Column, InputPanel, Toolbar}; > + > +use crate::pdm_client; > +use crate::sdn::evpn::NodeList; > + > +#[derive(Clone, PartialEq, Properties, Default)] > +pub struct ZoneStatusTable { > + remote: String, > + zone: String, > + nodes: Option, > +} > + > +impl ZoneStatusTable { > + pub fn new(remote: String, zone: String, nodes: Option) -> Self { > + yew::props!(Self { > + zone, > + remote, > + nodes > + }) > + } > +} > + > +impl From for VNode { > + fn from(value: ZoneStatusTable) -> Self { > + let comp = VComp::new::>(Rc::new(value), None); > + VNode::from(comp) > + } > +} > + > +#[derive(Clone, PartialEq)] > +#[repr(transparent)] > +pub struct IpVrfEntry(pub SdnZoneIpVrf); > + > +impl From for IpVrfEntry { > + fn from(value: SdnZoneIpVrf) -> Self { > + Self(value) > + } > +} > + > +impl ExtractPrimaryKey for IpVrfEntry { > + fn extract_key(&self) -> Key { > + Key::from(self.0.ip.as_str()) > + } > +} > + > +fn default_sorter(a: &IpVrfEntry, b: &IpVrfEntry) -> Ordering { > + (&a.0.ip, &a.0.metric).cmp(&(&b.0.ip, &b.0.metric)) > +} > + > +pub struct ZoneStatusComponent { > + store: Store, > + columns: Rc>>, > + nodes: Option>>, > + selected_node: Option, > + error_msg: Option, > + vrf_loading: bool, > +} > + > +impl ZoneStatusComponent { > + fn columns() -> Rc>> { > + Rc::new(vec![ > + DataTableColumn::new(tr!("Destination")) > + .get_property(|entry: &IpVrfEntry| &entry.0.ip) > + .into(), > + DataTableColumn::new(tr!("Nexthops")) > + .render(|entry: &IpVrfEntry| { > + let mut column = Column::new(); > + > + for nexthop in &entry.0.nexthops { > + column.add_child(html! {
{ nexthop }
}); > + } > + > + column.into() > + }) > + .into(), > + DataTableColumn::new(tr!("Protocol")) > + .get_property(|entry: &IpVrfEntry| &entry.0.protocol) > + .into(), > + DataTableColumn::new(tr!("Metric")) > + .get_property(|entry: &IpVrfEntry| &entry.0.metric) > + .into(), > + ]) > + } > +} > + > +#[derive(Debug)] > +pub enum ZoneStatusComponentMsg { > + NodeSelected(Option), > + NodeListLoaded(Rc>), > + ZoneStatusLoaded(Result, Error>), > +} nit: ZoneStatusComponentMsg and ZoneStatusComponent can probably be private? > + > +impl LoadableComponent for ZoneStatusComponent { > + type Message = ZoneStatusComponentMsg; > + type Properties = ZoneStatusTable; > + type ViewState = (); > + > + fn create(_ctx: &LoadableComponentContext) -> Self { > + Self { > + store: Store::new(), > + columns: Self::columns(), > + selected_node: None, > + nodes: Default::default(), > + error_msg: None, > + vrf_loading: false, > + } > + } > + > + fn load( > + &self, > + ctx: &proxmox_yew_comp::LoadableComponentContext, > + ) -> Pin>>> { > + let link = ctx.link().clone(); > + let props = ctx.props().clone(); > + > + Box::pin(async move { > + let node_list = if let Some(nodes) = props.nodes { > + nodes.iter().cloned().map(AttrValue::from).collect() > + } else { > + pdm_client() > + .pve_list_nodes(&props.remote) > + .await? > + .into_iter() > + .map(|node_index| AttrValue::from(node_index.node)) > + .collect() > + }; > + > + link.send_message(Self::Message::NodeListLoaded(Rc::new(node_list))); > + > + Ok(()) > + }) > + } > + > + fn update(&mut self, ctx: &LoadableComponentContext, msg: Self::Message) -> bool { > + match msg { > + Self::Message::NodeListLoaded(node_list) => { > + let selected_node = node_list.iter().next().cloned(); > + > + self.nodes = Some(node_list); > + > + if let Some(node) = selected_node { > + ctx.link() > + .send_message(Self::Message::NodeSelected(Some(node.to_string()))); > + } > + } > + Self::Message::NodeSelected(node_name) => { > + if let Some(node_name) = node_name { > + self.vrf_loading = true; > + self.selected_node = Some(node_name.clone().into()); > + > + let link = ctx.link().clone(); > + let props = ctx.props().clone(); > + > + ctx.link().spawn(async move { > + let status_result = pdm_client() > + .pve_sdn_zone_get_ip_vrf(&props.remote, &node_name, &props.zone) > + .await; > + > + link.send_message(Self::Message::ZoneStatusLoaded( > + status_result.with_context(|| "could not load zone status".to_string()), > + )); > + }); > + } > + } > + Self::Message::ZoneStatusLoaded(zone_status_result) => { > + self.vrf_loading = false; > + > + match zone_status_result { > + Ok(zone_status) => { > + self.store > + .write() > + .set_data(zone_status.into_iter().map(IpVrfEntry::from).collect()); > + > + self.store.set_sorter(default_sorter); > + > + self.error_msg = None; > + } > + Err(error) => { > + self.store.write().clear(); > + self.error_msg = Some(format!("{error:?}")); > + } > + } > + } > + } > + > + true > + } > + > + fn main_view(&self, ctx: &proxmox_yew_comp::LoadableComponentContext) -> yew::Html { > + let selected_node = self.selected_node.clone(); > + > + let toolbar = Toolbar::new() > + .class("pwt-w-100") > + .class("pwt-overflow-hidden") > + .class("pwt-border-bottom") > + .with_child( > + InputPanel::new().with_field( > + tr!("Node"), > + Combobox::new() > + .min_width(100) > + .required(true) > + .value(self.selected_node.clone()) > + .items(self.nodes.clone().unwrap_or_default()) > + .on_change( > + ctx.link() > + .callback(|node| Self::Message::NodeSelected(Some(node))), > + ), > + ), > + ) > + .with_flex_spacer() > + .with_child(Button::refresh(ctx.loading() || self.vrf_loading).onclick( > + ctx.link().callback(move |_| { > + Self::Message::NodeSelected(selected_node.as_ref().map(ToString::to_string)) > + }), > + )); > + > + let table = > + DataTable::new(self.columns.clone(), self.store.clone()).class(pwt::css::FlexFit); > + > + let mut column = Column::new() > + .class(pwt::css::FlexFit) > + .with_child(toolbar) > + .with_child(table); > + > + if let Some(msg) = &self.error_msg { > + column.add_child(error_message(msg)); > + } > + > + column.into() > + } > + > + fn changed( > + &mut self, > + ctx: &LoadableComponentContext, > + _old_props: &Self::Properties, > + ) -> bool { > + self.selected_node = None; > + self.nodes = None; > + > + ctx.link().send_reload(); > + > + true > + } > +} _______________________________________________ pdm-devel mailing list pdm-devel@lists.proxmox.com https://lists.proxmox.com/cgi-bin/mailman/listinfo/pdm-devel