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 9E2A21FF16B for ; Fri, 7 Nov 2025 09:59:31 +0100 (CET) Received: from firstgate.proxmox.com (localhost [127.0.0.1]) by firstgate.proxmox.com (Proxmox) with ESMTP id 7F1CCBA66; Fri, 7 Nov 2025 10:00:12 +0100 (CET) From: Stefan Hanreich To: pdm-devel@lists.proxmox.com Date: Fri, 7 Nov 2025 09:59:28 +0100 Message-ID: <20251107085934.118815-9-s.hanreich@proxmox.com> X-Mailer: git-send-email 2.47.3 In-Reply-To: <20251107085934.118815-1-s.hanreich@proxmox.com> References: <20251107085934.118815-1-s.hanreich@proxmox.com> MIME-Version: 1.0 X-SPAM-LEVEL: Spam detection results: 0 AWL -0.180 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 KAM_LAZY_DOMAIN_SECURITY 1 Sending domain does not have any anti-forgery methods RDNS_NONE 0.793 Delivered to internal network by a host with no rDNS SPF_HELO_NONE 0.001 SPF: HELO does not publish an SPF Record SPF_NONE 0.001 SPF: sender does not publish an SPF Record Subject: [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" 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| { + 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( -- 2.47.3 _______________________________________________ pdm-devel mailing list pdm-devel@lists.proxmox.com https://lists.proxmox.com/cgi-bin/mailman/listinfo/pdm-devel