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 8BCCB1FF187 for ; Tue, 18 Nov 2025 15:13:00 +0100 (CET) Received: from firstgate.proxmox.com (localhost [127.0.0.1]) by firstgate.proxmox.com (Proxmox) with ESMTP id E50A9162D5; Tue, 18 Nov 2025 15:13:04 +0100 (CET) Date: Tue, 18 Nov 2025 15:12:59 +0100 Message-Id: From: "Lukas Wagner" To: "Proxmox Datacenter Manager development discussion" Mime-Version: 1.0 X-Mailer: aerc 0.21.0-0-g5549850facc2-dirty References: <20251107085934.118815-1-s.hanreich@proxmox.com> <20251107085934.118815-8-s.hanreich@proxmox.com> In-Reply-To: <20251107085934.118815-8-s.hanreich@proxmox.com> X-Bm-Milter-Handled: 55990f41-d878-4baa-be0a-ee34c49e34d2 X-Bm-Transport-Timestamp: 1763475149295 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 Subject: Re: [pdm-devel] [PATCH proxmox-datacenter-manager 4/5] ui: sdn: evpn: add vnet 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" Looks good to me, one small nit inline. Reviewed-by: Lukas Wagner On Fri Nov 7, 2025 at 9:59 AM CET, Stefan Hanreich wrote: > This panel shows the status of the MAC-VRF of an EVPN vnet. It lists > the neighbor table of the vnet on a given node, which can be selected > via the dropdown in the panel. It will be used in the remote tree and > the vrf tree of the EVPN view to display detailed information about > the selected EVPN vnet. > > Signed-off-by: Stefan Hanreich > --- > ui/src/sdn/evpn/mod.rs | 3 + > ui/src/sdn/evpn/vnet_status.rs | 253 +++++++++++++++++++++++++++++++++ > 2 files changed, 256 insertions(+) > create mode 100644 ui/src/sdn/evpn/vnet_status.rs > > diff --git a/ui/src/sdn/evpn/mod.rs b/ui/src/sdn/evpn/mod.rs > index 6c919ba..3320c0f 100644 > --- a/ui/src/sdn/evpn/mod.rs > +++ b/ui/src/sdn/evpn/mod.rs > @@ -16,6 +16,9 @@ pub use add_zone::AddZoneWindow; > mod zone_status; > pub use zone_status::ZoneStatusTable; > > +mod vnet_status; > +pub use vnet_status::VnetStatusTable; > + > #[derive(Clone, Copy, Debug, PartialEq, Eq, Ord, PartialOrd, Hash)] > pub struct EvpnRouteTarget { > asn: u32, > diff --git a/ui/src/sdn/evpn/vnet_status.rs b/ui/src/sdn/evpn/vnet_status.rs > new file mode 100644 > index 0000000..6f59890 > --- /dev/null > +++ b/ui/src/sdn/evpn/vnet_status.rs > @@ -0,0 +1,253 @@ > +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::ExtractPrimaryKey; > +use yew::virtual_dom::{Key, VComp, VNode}; > +use yew::{AttrValue, Properties}; > + > +use pdm_client::types::SdnVnetMacVrf; > +use pwt::props::{ > + ContainerBuilder, EventSubscriber, 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 VnetStatusTable { > + remote: String, > + vnet: String, > + nodes: Option, > +} > + > +impl VnetStatusTable { > + pub fn new(remote: String, vnet: String, nodes: Option) -> Self { > + yew::props!(Self { > + vnet, > + remote, > + nodes > + }) > + } > +} > + > +impl From for VNode { > + fn from(value: VnetStatusTable) -> Self { > + let comp = VComp::new::>(Rc::new(value), None); > + VNode::from(comp) > + } > +} > + > +#[derive(Clone, PartialEq)] > +#[repr(transparent)] > +pub struct MacVrfEntry(pub SdnVnetMacVrf); > + > +impl From for MacVrfEntry { > + fn from(value: SdnVnetMacVrf) -> Self { > + Self(value) > + } > +} > + > +impl ExtractPrimaryKey for MacVrfEntry { > + fn extract_key(&self) -> Key { > + Key::from(self.0.ip.as_str()) > + } > +} > + > +fn default_sorter(a: &MacVrfEntry, b: &MacVrfEntry) -> Ordering { > + a.0.ip.cmp(&b.0.ip) > +} > + v Can be private, I think? > +pub struct VnetStatusComponent { > + store: Store, > + columns: Rc>>, > + nodes: Option>>, > + selected_node: Option, > + error_msg: Option, > + vrf_loading: bool, > +} > + > +impl VnetStatusComponent { > + fn columns() -> Rc>> { > + Rc::new(vec![ > + DataTableColumn::new(tr!("IP Address")) > + .get_property(|entry: &MacVrfEntry| &entry.0.ip) > + .into(), > + DataTableColumn::new(tr!("MAC Address")) > + .get_property(|entry: &MacVrfEntry| &entry.0.mac) > + .into(), > + DataTableColumn::new(tr!("via")) > + .get_property(|entry: &MacVrfEntry| &entry.0.nexthop) > + .into(), > + ]) > + } > +} > + v can be private, I think? > +#[derive(Debug)] > +pub enum VnetStatusComponentMsg { > + NodeSelected(Option), > + NodeListLoaded(Rc>), > + VnetStatusLoaded(Result, Error>), > +} > + > +impl LoadableComponent for VnetStatusComponent { > + type Message = VnetStatusComponentMsg; > + type Properties = VnetStatusTable; > + 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_vnet_get_mac_vrf(&props.remote, &node_name, &props.vnet) > + .await; > + > + link.send_message(Self::Message::VnetStatusLoaded( > + status_result.with_context(|| "could not load vnet status".to_string()), > + )); > + }); > + } > + } > + Self::Message::VnetStatusLoaded(vnet_status_result) => { > + self.vrf_loading = false; > + > + match vnet_status_result { > + Ok(vnet_status) => { > + self.store > + .write() > + .set_data(vnet_status.into_iter().map(MacVrfEntry::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