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 500F91FF183 for ; Wed, 19 Nov 2025 13:17:30 +0100 (CET) Received: from firstgate.proxmox.com (localhost [127.0.0.1]) by firstgate.proxmox.com (Proxmox) with ESMTP id DDFCE5C4C; Wed, 19 Nov 2025 13:17:34 +0100 (CET) Message-ID: <5498c06d-49d1-4e32-ad6c-a9fe50ade06d@proxmox.com> Date: Wed, 19 Nov 2025 13:16:59 +0100 MIME-Version: 1.0 User-Agent: Mozilla Thunderbird To: Proxmox Datacenter Manager development discussion , Lukas Wagner References: <20251107085934.118815-1-s.hanreich@proxmox.com> <20251107085934.118815-9-s.hanreich@proxmox.com> Content-Language: en-US From: Stefan Hanreich In-Reply-To: X-SPAM-LEVEL: Spam detection results: 0 AWL 0.723 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 5/5] sdn: evpn: add detail panel to the evpn 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" On 11/18/25 3:13 PM, Lukas Wagner wrote: > One note inline. > > Reviewed-by: Lukas Wagner > > On Fri Nov 7, 2025 at 9:59 AM CET, Stefan Hanreich wrote: >> Extend the EVPN panel, so it can display detailed information about >> selected elements in the trees. When selecting a specific zone / vnet, >> the detail panel will show the IP / MAC-VRF of that zone / vnet. This >> requires some refactoring of the existing EVPN panel to allow >> displaying a second panel next to it. It is now structured like the >> remote view, which also offers a panel that shows an overview of the >> remote and details of the selected node. >> >> Signed-off-by: Stefan Hanreich >> --- >> ui/src/sdn/evpn/evpn_panel.rs | 129 ++++++++++++++++++++++++++++++--- >> ui/src/sdn/evpn/remote_tree.rs | 71 +++++++++++++----- >> ui/src/sdn/evpn/vrf_tree.rs | 29 +++++++- >> 3 files changed, 196 insertions(+), 33 deletions(-) >> >> diff --git a/ui/src/sdn/evpn/evpn_panel.rs b/ui/src/sdn/evpn/evpn_panel.rs >> index 89e2e58..31c8795 100644 >> --- a/ui/src/sdn/evpn/evpn_panel.rs >> +++ b/ui/src/sdn/evpn/evpn_panel.rs >> @@ -1,5 +1,6 @@ >> use futures::try_join; >> use std::rc::Rc; >> +use std::str::FromStr; >> >> use anyhow::Error; >> use yew::virtual_dom::{VComp, VNode}; >> @@ -9,14 +10,20 @@ use pdm_client::types::{ListController, ListControllersType, ListVnet, ListZone, >> use proxmox_yew_comp::{LoadableComponent, LoadableComponentContext, LoadableComponentMaster}; >> >> use pwt::css::{AlignItems, FlexFit, JustifyContent}; >> -use pwt::props::{ContainerBuilder, EventSubscriber, StorageLocation, WidgetBuilder}; >> -use pwt::state::NavigationContainer; >> +use pwt::props::{ >> + ContainerBuilder, EventSubscriber, StorageLocation, WidgetBuilder, WidgetStyleBuilder, >> +}; >> +use pwt::state::{NavigationContainer, Selection}; >> use pwt::tr; >> use pwt::widget::menu::{Menu, MenuButton, MenuItem}; >> -use pwt::widget::{Button, Column, MiniScrollMode, TabBarItem, TabPanel, Toolbar}; >> +use pwt::widget::{ >> + Button, Column, Container, MiniScrollMode, Panel, Row, TabBarItem, TabPanel, Toolbar, >> +}; >> >> use crate::pdm_client; >> -use crate::sdn::evpn::{AddVnetWindow, AddZoneWindow, RemoteTree, VrfTree}; >> +use crate::sdn::evpn::{ >> + AddVnetWindow, AddZoneWindow, NodeList, RemoteTree, VnetStatusTable, VrfTree, ZoneStatusTable, >> +}; >> >> #[derive(PartialEq, Properties)] >> pub struct EvpnPanel {} >> @@ -40,6 +47,11 @@ impl From for VNode { >> } >> } >> >> +pub enum DetailPanel { >> + Zone { remote: String, zone: String }, >> + Vnet { remote: String, vnet: String }, >> +} >> + >> pub enum EvpnPanelMsg { >> Reload, >> LoadFinished { >> @@ -47,6 +59,7 @@ pub enum EvpnPanelMsg { >> zones: Rc>, >> vnets: Rc>, >> }, >> + DetailSelection(Option), >> } >> >> #[derive(Debug, PartialEq)] >> @@ -82,6 +95,8 @@ pub struct EvpnPanelComponent { >> zones: Rc>, >> vnets: Rc>, >> initial_load: bool, >> + selected_detail: Option, >> + selected_tab: Selection, >> } >> >> impl EvpnPanelComponent { >> @@ -123,12 +138,19 @@ impl LoadableComponent for EvpnPanelComponent { >> type Message = EvpnPanelMsg; >> type ViewState = EvpnPanelViewState; >> >> - fn create(_ctx: &LoadableComponentContext) -> Self { >> + fn create(ctx: &LoadableComponentContext) -> Self { >> + let link = ctx.link().clone(); >> + >> + let selected_tab = Selection::new() >> + .on_select(move |_| link.send_message(Self::Message::DetailSelection(None))); >> + >> Self { >> initial_load: true, >> controllers: Default::default(), >> zones: Default::default(), >> vnets: Default::default(), >> + selected_detail: None, >> + selected_tab, >> } >> } >> >> @@ -166,6 +188,10 @@ impl LoadableComponent for EvpnPanelComponent { >> >> return true; >> } >> + Self::Message::DetailSelection(data) => { >> + self.selected_detail = data; >> + return true; >> + } >> Self::Message::Reload => { >> ctx.link().send_reload(); >> } >> @@ -175,11 +201,12 @@ impl LoadableComponent for EvpnPanelComponent { >> } >> >> fn main_view(&self, ctx: &LoadableComponentContext) -> Html { >> - let panel = TabPanel::new() >> + let tab_panel = TabPanel::new() >> .state_id(StorageLocation::session("EvpnPanelState")) >> .class(pwt::css::FlexFit) >> .router(true) >> .scroll_mode(MiniScrollMode::Arrow) >> + .selection(self.selected_tab.clone()) >> .with_item( >> TabBarItem::new() >> .key("remotes") >> @@ -201,6 +228,9 @@ impl LoadableComponent for EvpnPanelComponent { >> self.zones.clone(), >> self.vnets.clone(), >> self.controllers.clone(), >> + ctx.link().callback(|panel: Option| { >> + Self::Message::DetailSelection(panel) >> + }), >> )) >> }), >> ) >> @@ -225,16 +255,93 @@ impl LoadableComponent for EvpnPanelComponent { >> self.zones.clone(), >> self.vnets.clone(), >> self.controllers.clone(), >> + ctx.link().callback(|panel: Option| { >> + Self::Message::DetailSelection(panel) >> + }), >> )) >> }), >> ); >> >> - let navigation_container = NavigationContainer::new().with_child(panel); >> + let navigation_container = NavigationContainer::new().with_child(tab_panel); >> + >> + let mut container = Container::new() >> + .class("pwt-content-spacer") >> + .class(FlexFit) >> + .class("pwt-flex-direction-row") >> + .with_child(Panel::new().flex(1.0).with_child(navigation_container)); >> + >> + let (title, detail_html) = if let Some(detail) = &self.selected_detail { >> + match detail { >> + DetailPanel::Vnet { >> + remote, >> + vnet: vnet_id, >> + } => { >> + let vnet = self.vnets.iter().find(|list_vnet| { >> + list_vnet.vnet.vnet.as_str() == vnet_id.as_str() >> + && list_vnet.remote.as_str() == remote >> + }); >> + >> + if let Some(vnet) = vnet { >> + let zone = self.zones.iter().find(|list_zone| { > > Maybe add a comment why it is safe to use .unwrap() here - assuming that > it is? Should be, a vnet has to always have a zone - nevertheless it might be better to do unwrap_or_default() instead, which should then fail the comparison... >> + list_zone.zone.zone.as_str() == vnet.vnet.zone.as_ref().unwrap() >> + && list_zone.remote.as_str() == remote.as_str() >> + }); >> + >> + let node_list = zone.as_ref().and_then(|zone| { >> + let nodes = zone.zone.nodes.as_ref()?; >> + NodeList::from_str(nodes).ok() >> + }); >> + >> + ( >> + Some(format!("MAC-VRF for vnet '{vnet_id}' (Remote {remote})")), >> + VnetStatusTable::new(remote.clone(), vnet_id.clone(), node_list).into(), >> + ) >> + } else { >> + (None, html! {"Could not find vnet {vnet_id}!"}) >> + } >> + } >> + DetailPanel::Zone { >> + remote, >> + zone: zone_id, >> + } => { >> + let zone = self.zones.iter().find(|list_zone| { >> + list_zone.zone.zone.as_str() == zone_id.as_str() >> + && list_zone.remote.as_str() == remote.as_str() >> + }); >> + >> + let node_list = zone.as_ref().and_then(|zone| { >> + let nodes = zone.zone.nodes.as_ref()?; >> + NodeList::from_str(nodes).ok() >> + }); >> + >> + ( >> + Some(format!("IP-VRF for zone '{zone_id}' (Remote {remote})")), >> + ZoneStatusTable::new(remote.clone(), zone_id.clone(), node_list).into(), >> + ) >> + } >> + } >> + } else { >> + ( >> + None, >> + Row::new() >> + .class(pwt::css::FlexFit) >> + .class(pwt::css::JustifyContent::Center) >> + .class(pwt::css::AlignItems::Center) >> + .with_child(html! { tr!("Select a Zone or VNet for more details.") }) >> + .into(), >> + ) >> + }; >> >> - Column::new() >> - .class(pwt::css::FlexFit) >> - .with_child(navigation_container) >> - .into() >> + let mut panel = Panel::new().width(600); >> + >> + if let Some(title) = title { >> + panel.set_title(title); >> + } >> + >> + panel.add_child(detail_html); >> + container.add_child(panel); >> + >> + container.into() >> } >> >> fn dialog_view( >> diff --git a/ui/src/sdn/evpn/remote_tree.rs b/ui/src/sdn/evpn/remote_tree.rs >> index 1799917..ee57b33 100644 >> --- a/ui/src/sdn/evpn/remote_tree.rs >> +++ b/ui/src/sdn/evpn/remote_tree.rs >> @@ -6,7 +6,7 @@ use std::str::FromStr; >> use anyhow::{anyhow, Error}; >> use pwt::widget::{error_message, Column}; >> use yew::virtual_dom::{Key, VNode}; >> -use yew::{Component, Context, Html, Properties}; >> +use yew::{Callback, Component, Context, Html, Properties}; >> >> use pdm_client::types::{ListController, ListVnet, ListZone, SdnObjectState}; >> use pwt::css; >> @@ -19,14 +19,16 @@ use pwt::widget::data_table::{ >> use pwt::widget::{Fa, Row}; >> use pwt_macros::widget; >> >> +use crate::sdn::evpn::evpn_panel::DetailPanel; >> use crate::sdn::evpn::EvpnRouteTarget; >> >> #[widget(comp=RemoteTreeComponent)] >> -#[derive(Clone, PartialEq, Properties, Default)] >> +#[derive(Clone, PartialEq, Properties)] >> pub struct RemoteTree { >> zones: Rc>, >> vnets: Rc>, >> controllers: Rc>, >> + on_select: Callback>, >> } >> >> impl RemoteTree { >> @@ -34,11 +36,13 @@ impl RemoteTree { >> zones: Rc>, >> vnets: Rc>, >> controllers: Rc>, >> + on_select: Callback>, >> ) -> Self { >> yew::props!(Self { >> zones, >> vnets, >> controllers, >> + on_select, >> }) >> } >> } >> @@ -416,7 +420,34 @@ impl Component for RemoteTreeComponent { >> let store = TreeStore::new().view_root(false); >> let columns = Self::columns(store.clone()); >> >> - let selection = Selection::new(); >> + let on_select = ctx.props().on_select.clone(); >> + let selection_store = store.clone(); >> + let selection = Selection::new().on_select(move |selection: Selection| { >> + if let Some(selected_key) = selection.selected_key() { >> + let read_guard = selection_store.read(); >> + >> + if let Some(node) = read_guard.lookup_node(&selected_key) { >> + match node.record() { >> + RemoteTreeEntry::Zone(zone) => { >> + on_select.emit(Some(DetailPanel::Zone { >> + remote: zone.remote.clone(), >> + zone: zone.id.clone(), >> + })); >> + } >> + RemoteTreeEntry::Vnet(vnet) => { >> + on_select.emit(Some(DetailPanel::Vnet { >> + remote: vnet.remote.clone(), >> + vnet: vnet.id.clone(), >> + })); >> + } >> + _ => on_select.emit(None), >> + } >> + } >> + } else { >> + on_select.emit(None); >> + } >> + }); >> + >> let mut error_msg = None; >> >> match zones_to_remote_view( >> @@ -442,27 +473,27 @@ impl Component for RemoteTreeComponent { >> } >> >> fn view(&self, _ctx: &Context) -> Html { >> - let table = DataTable::new(self.columns.clone(), self.store.clone()) >> - .striped(false) >> - .selection(self.selection.clone()) >> - .row_render_callback(|args: &mut DataTableRowRenderArgs| { >> - match args.record() { >> - RemoteTreeEntry::Vnet(vnet) if vnet.external || vnet.imported => { >> - args.add_class("pwt-opacity-50"); >> - } >> - RemoteTreeEntry::Remote(_) => args.add_class("pwt-bg-color-surface"), >> - _ => (), >> - }; >> - }) >> - .class(css::FlexFit); >> - >> - let mut column = Column::new().class(pwt::css::FlexFit).with_child(table); >> + let mut table_column = Column::new().class(pwt::css::FlexFit).with_child( >> + DataTable::new(self.columns.clone(), self.store.clone()) >> + .striped(false) >> + .selection(self.selection.clone()) >> + .row_render_callback(|args: &mut DataTableRowRenderArgs| { >> + match args.record() { >> + RemoteTreeEntry::Vnet(vnet) if vnet.external || vnet.imported => { >> + args.add_class("pwt-opacity-50"); >> + } >> + RemoteTreeEntry::Remote(_) => args.add_class("pwt-bg-color-surface"), >> + _ => (), >> + }; >> + }) >> + .class(css::FlexFit), >> + ); >> >> if let Some(msg) = &self.error_msg { >> - column.add_child(error_message(msg.as_ref())); >> + table_column.add_child(error_message(msg.as_ref())); >> } >> >> - column.into() >> + table_column.into() >> } >> >> fn changed(&mut self, ctx: &Context, old_props: &Self::Properties) -> bool { >> diff --git a/ui/src/sdn/evpn/vrf_tree.rs b/ui/src/sdn/evpn/vrf_tree.rs >> index 0de4145..8481dfc 100644 >> --- a/ui/src/sdn/evpn/vrf_tree.rs >> +++ b/ui/src/sdn/evpn/vrf_tree.rs >> @@ -4,7 +4,7 @@ use std::rc::Rc; >> >> use anyhow::{anyhow, Error}; >> use yew::virtual_dom::{Key, VNode}; >> -use yew::{Component, Context, Html, Properties}; >> +use yew::{Callback, Component, Context, Html, Properties}; >> >> use pdm_client::types::{ListController, ListVnet, ListZone}; >> use pwt::css; >> @@ -17,6 +17,7 @@ use pwt::widget::data_table::{ >> use pwt::widget::{error_message, Column, Fa, Row}; >> use pwt_macros::widget; >> >> +use crate::sdn::evpn::evpn_panel::DetailPanel; >> use crate::sdn::evpn::EvpnRouteTarget; >> >> #[widget(comp=VrfTreeComponent)] >> @@ -25,6 +26,7 @@ pub struct VrfTree { >> zones: Rc>, >> vnets: Rc>, >> controllers: Rc>, >> + on_select: Callback>, >> } >> >> impl VrfTree { >> @@ -32,11 +34,13 @@ impl VrfTree { >> zones: Rc>, >> vnets: Rc>, >> controllers: Rc>, >> + on_select: Callback>, >> ) -> Self { >> yew::props!(Self { >> zones, >> vnets, >> controllers, >> + on_select, >> }) >> } >> } >> @@ -333,7 +337,28 @@ impl Component for VrfTreeComponent { >> let store = TreeStore::new().view_root(false); >> let columns = Self::columns(store.clone()); >> >> - let selection = Selection::new(); >> + let on_select = ctx.props().on_select.clone(); >> + let selection_store = store.clone(); >> + let selection = Selection::new().on_select(move |selection: Selection| { >> + if let Some(selected_key) = selection.selected_key() { >> + let read_guard = selection_store.read(); >> + >> + if let Some(node) = read_guard.lookup_node(&selected_key) { >> + match node.record() { >> + VrfTreeEntry::Remote(remote) => { >> + on_select.emit(Some(DetailPanel::Vnet { >> + remote: remote.remote.clone(), >> + vnet: remote.vnet.clone(), >> + })); >> + } >> + _ => on_select.emit(None), >> + } >> + } >> + } else { >> + on_select.emit(None); >> + } >> + }); >> + >> let mut error_msg = None; >> >> match zones_to_vrf_view( > > > > _______________________________________________ > pdm-devel mailing list > pdm-devel@lists.proxmox.com > https://lists.proxmox.com/cgi-bin/mailman/listinfo/pdm-devel > > _______________________________________________ pdm-devel mailing list pdm-devel@lists.proxmox.com https://lists.proxmox.com/cgi-bin/mailman/listinfo/pdm-devel