From mboxrd@z Thu Jan 1 00:00:00 1970 Return-Path: Received: from firstgate.proxmox.com (firstgate.proxmox.com [212.224.123.68]) by lore.proxmox.com (Postfix) with ESMTPS id A3FB91FF187 for ; Tue, 18 Nov 2025 15:13:33 +0100 (CET) Received: from firstgate.proxmox.com (localhost [127.0.0.1]) by firstgate.proxmox.com (Proxmox) with ESMTP id 0DF3D1634B; Tue, 18 Nov 2025 15:13:38 +0100 (CET) Date: Tue, 18 Nov 2025 15:13:02 +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-9-s.hanreich@proxmox.com> In-Reply-To: <20251107085934.118815-9-s.hanreich@proxmox.com> X-Bm-Milter-Handled: 55990f41-d878-4baa-be0a-ee34c49e34d2 X-Bm-Transport-Timestamp: 1763475152353 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 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" 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? > + 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