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 077DB1FF191 for ; Tue, 9 Sep 2025 15:41:27 +0200 (CEST) Received: from firstgate.proxmox.com (localhost [127.0.0.1]) by firstgate.proxmox.com (Proxmox) with ESMTP id 1FD8EAF6F; Tue, 9 Sep 2025 15:41:30 +0200 (CEST) Message-ID: <78de94a6-ee2b-41e9-b53f-d2019f9a98db@proxmox.com> Date: Tue, 9 Sep 2025 15:41:25 +0200 MIME-Version: 1.0 User-Agent: Mozilla Thunderbird Beta To: Proxmox Datacenter Manager development discussion , Stefan Hanreich References: <20250909100838.234778-1-s.hanreich@proxmox.com> <20250909100838.234778-7-s.hanreich@proxmox.com> Content-Language: en-US From: Dominik Csapak In-Reply-To: <20250909100838.234778-7-s.hanreich@proxmox.com> X-Bm-Milter-Handled: 55990f41-d878-4baa-be0a-ee34c49e34d2 X-Bm-Transport-Timestamp: 1757425262365 X-SPAM-LEVEL: Spam detection results: 0 AWL 0.022 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 URIBL_BLOCKED 0.001 ADMINISTRATOR NOTICE: The query to URIBL was blocked. See http://wiki.apache.org/spamassassin/DnsBlocklists#dnsbl-block for more information. [mod.rs] Subject: Re: [pdm-devel] [PATCH proxmox-datacenter-manager 5/5] ui: sdn: add zone tree 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-Transfer-Encoding: 7bit Content-Type: text/plain; charset="us-ascii"; Format="flowed" Errors-To: pdm-devel-bounces@lists.proxmox.com Sender: "pdm-devel" looks mostly fine to me (see some comments inline) but the toolbar looks weird if it's empty this way i know we always have the refresh button on the right, but maybe putting it on the left for this single panel could make sense, just so the toolbar isn't empty on the left hand side? alternatively we could put a short title there? On 9/9/25 12:08 PM, Stefan Hanreich wrote: > This shows an overview of the state of all zones across all remotes, > similar to the current overview in Proxmox VE, in the SDN tab. Add it > as a top-level container and move the EVPN section below that, to > mimic the menu structure from Proxmox VE. > > Signed-off-by: Stefan Hanreich > --- > ui/src/main_menu.rs | 15 +- > ui/src/sdn/mod.rs | 3 + > ui/src/sdn/zone_tree.rs | 299 ++++++++++++++++++++++++++++++++++++++++ > 3 files changed, 316 insertions(+), 1 deletion(-) > create mode 100644 ui/src/sdn/zone_tree.rs > > diff --git a/ui/src/main_menu.rs b/ui/src/main_menu.rs > index 7eac775..f440be8 100644 > --- a/ui/src/main_menu.rs > +++ b/ui/src/main_menu.rs > @@ -18,6 +18,7 @@ use pdm_api_types::remotes::RemoteType; > > use crate::remotes::RemotesPanel; > use crate::sdn::evpn::EvpnPanel; > +use crate::sdn::ZoneTree; > use crate::{ > AccessControl, CertificatesPanel, Dashboard, RemoteList, ServerAdministration, > SystemConfiguration, > @@ -248,8 +249,10 @@ impl Component for PdmMainMenu { > admin_submenu, > ); > > + let mut sdn_submenu = Menu::new(); > + > register_view( > - &mut menu, > + &mut sdn_submenu, > &mut content, > tr!("EVPN"), > "evpn", > @@ -257,6 +260,16 @@ impl Component for PdmMainMenu { > |_| EvpnPanel::new().into(), > ); > > + register_submenu( > + &mut menu, > + &mut content, > + tr!("SDN"), > + "sdn", > + Some("fa fa-sdn"), > + |_| ZoneTree::new().into(), > + sdn_submenu, > + ); > + > let mut remote_submenu = Menu::new(); > > for remote in self.remote_list_cache.iter() { > diff --git a/ui/src/sdn/mod.rs b/ui/src/sdn/mod.rs > index ef2eab9..b6ab8ad 100644 > --- a/ui/src/sdn/mod.rs > +++ b/ui/src/sdn/mod.rs > @@ -1 +1,4 @@ > pub mod evpn; > + > +mod zone_tree; > +pub use zone_tree::ZoneTree; > diff --git a/ui/src/sdn/zone_tree.rs b/ui/src/sdn/zone_tree.rs > new file mode 100644 > index 0000000..55a5889 > --- /dev/null > +++ b/ui/src/sdn/zone_tree.rs > @@ -0,0 +1,299 @@ > +use futures::Future; > +use std::cmp::Ordering; > +use std::pin::Pin; > +use std::rc::Rc; > + > +use yew::virtual_dom::{Key, VComp, VNode}; > +use yew::{Html, Properties}; > + > +use pdm_api_types::resource::{ > + PveSdnResource, RemoteResources, ResourceType, SdnStatus, SdnZoneResource, > +}; > +use pdm_client::types::Resource; > +use proxmox_yew_comp::{LoadableComponent, LoadableComponentContext, LoadableComponentMaster}; > +use pwt::props::EventSubscriber; > +use pwt::widget::{Button, Toolbar}; > +use pwt::{ > + css, > + css::FontColor, > + props::{ContainerBuilder, ExtractPrimaryKey, WidgetBuilder}, > + state::{Selection, SlabTree, TreeStore}, > + tr, > + widget::{ > + data_table::{DataTable, DataTableColumn, DataTableHeader}, > + error_message, Column, Fa, Row, > + }, > +}; > + > +use crate::pdm_client; > + > +#[derive(PartialEq, Properties)] > +pub struct ZoneTree {} > + > +impl ZoneTree { > + pub fn new() -> Self { > + yew::props!(Self {}) > + } > +} > + > +impl From for VNode { > + fn from(value: ZoneTree) -> Self { > + let comp = VComp::new::>(Rc::new(value), None); > + VNode::from(comp) > + } > +} > + > +#[derive(Clone, PartialEq, Debug)] > +struct ZoneData { > + remote: String, > + node: String, > + name: String, > + status: SdnStatus, > +} > + > +#[derive(Clone, PartialEq, Debug)] > +enum ZoneTreeEntry { > + Root, > + Remote(String), > + Node(String, String), > + Zone(ZoneData), > +} > + > +impl ZoneTreeEntry { > + fn from_zone_resource(remote: String, value: SdnZoneResource) -> Self { > + Self::Zone(ZoneData { > + remote, > + node: value.node.clone(), > + name: value.name.clone(), > + status: value.status, > + }) > + } > + > + fn name(&self) -> &str { > + match &self { > + Self::Root => "", > + Self::Remote(name) => name, > + Self::Node(_, name) => name, > + Self::Zone(zone) => &zone.name, > + } > + } > +} > + > +impl ExtractPrimaryKey for ZoneTreeEntry { > + fn extract_key(&self) -> yew::virtual_dom::Key { > + Key::from(match self { > + ZoneTreeEntry::Root => "/".to_string(), > + ZoneTreeEntry::Remote(name) => format!("/{name}"), > + ZoneTreeEntry::Node(remote_name, name) => format!("/{remote_name}/{name}"), > + ZoneTreeEntry::Zone(zone) => format!("/{}/{}/{}", zone.remote, zone.node, zone.name), > + }) > + } > +} > + > +pub enum ZoneTreeMsg { > + LoadFinished(Vec), > + Reload, > +} > + > +pub struct ZoneTreeComponent { > + store: TreeStore, > + selection: Selection, > + remote_errors: Vec, > + columns: Rc>>, > +} > + > +fn default_sorter(a: &ZoneTreeEntry, b: &ZoneTreeEntry) -> Ordering { > + a.name().cmp(b.name()) > +} > + > +impl ZoneTreeComponent { > + fn columns(store: TreeStore) -> Rc>> { > + Rc::new(vec![ > + DataTableColumn::new(tr!("Name")) > + .tree_column(store) > + .render(|entry: &ZoneTreeEntry| { > + let mut row = Row::new().class(css::AlignItems::Baseline).gap(2); > + let name = entry.name(); > + > + row = match entry { > + ZoneTreeEntry::Remote(_) => row.with_child(Fa::new("server")), > + ZoneTreeEntry::Node(_, _) => row.with_child(Fa::new("building")), > + ZoneTreeEntry::Zone(_) => row.with_child(Fa::new("th")), > + _ => row, > + }; > + > + row.with_child(name).into() i mean it works, but i'd probably write this part a bit differently: let icon = match entry { ... => Some("server"), ... => Some("building"), ... => Some("th"), _ => None, }; Row::new() .class(...) .gap(...) .with_optional_child(icon.map(|icon|Fa::new(icon)) .with_child(name).into() would that too work? > + }) > + .sorter(default_sorter) > + .into(), > + DataTableColumn::new(tr!("Status")) > + .render(|entry: &ZoneTreeEntry| { > + let mut row = Row::new().class(css::AlignItems::Baseline).gap(2); > + > + if let ZoneTreeEntry::Zone(zone) = entry { > + row = match zone.status { > + SdnStatus::Available => { > + row.with_child(Fa::new("check").class(FontColor::Success)) > + } > + SdnStatus::Error => { > + row.with_child(Fa::new("times-circle").class(FontColor::Error)) > + } > + _ => row, > + }; > + > + row = row.with_child(zone.status); > + } else { > + row = row.with_child(""); > + } > + > + row.into() > + }) > + .into(), > + ]) > + } > +} > + > +fn build_store_from_response( > + remote_resources: Vec, > +) -> (SlabTree, Vec) { > + let mut tree = SlabTree::new(); > + > + let mut root = tree.set_root(ZoneTreeEntry::Root); > + root.set_expanded(true); > + > + let mut remote_errors = Vec::new(); > + > + for resources in remote_resources { > + if let Some(error) = resources.error { > + remote_errors.push(format!( > + "could not fetch resources from remote {}: {error}", > + resources.remote, > + )); > + continue; > + } > + > + let mut remote = root.append(ZoneTreeEntry::Remote(resources.remote.clone())); > + remote.set_expanded(true); > + > + for resource in resources.resources { > + if let Resource::PveSdn(PveSdnResource::Zone(zone_resource)) = resource { > + let node_entry = remote.children_mut().find(|entry| { > + if let ZoneTreeEntry::Node(_, name) = entry.record() { > + if name == &zone_resource.node { > + return true; > + } > + } > + > + false > + }); > + > + let node_name = zone_resource.node.clone(); > + > + let entry = > + ZoneTreeEntry::from_zone_resource(resources.remote.clone(), zone_resource); > + > + match node_entry { > + Some(mut node_entry) => { > + node_entry.append(entry); > + } > + None => { > + let mut node_entry = > + remote.append(ZoneTreeEntry::Node(resources.remote.clone(), node_name)); > + > + node_entry.set_expanded(true); > + > + node_entry.append(entry); > + } > + }; > + } > + } > + } > + > + (tree, remote_errors) > +} > + > +impl LoadableComponent for ZoneTreeComponent { > + type Properties = ZoneTree; > + type Message = ZoneTreeMsg; > + type ViewState = (); > + > + fn create(_ctx: &LoadableComponentContext) -> Self { > + let store = TreeStore::new().view_root(false); > + store.set_sorter(default_sorter); > + > + let selection = Selection::new(); > + > + Self { > + store: store.clone(), > + selection, > + remote_errors: Vec::new(), > + columns: Self::columns(store), > + } > + } > + > + fn load( > + &self, > + ctx: &LoadableComponentContext, > + ) -> Pin>>> { > + let link = ctx.link().clone(); > + > + Box::pin(async move { > + let client = pdm_client(); > + let remote_resources = client > + .resources_by_type(None, ResourceType::PveSdnZone) > + .await?; > + link.send_message(Self::Message::LoadFinished(remote_resources)); > + > + Ok(()) > + }) > + } > + > + fn update(&mut self, ctx: &LoadableComponentContext, msg: Self::Message) -> bool { > + match msg { > + Self::Message::LoadFinished(remote_resources) => { > + let (data, remote_errors) = build_store_from_response(remote_resources); > + self.store.write().update_root_tree(data); > + self.store.set_sorter(default_sorter); > + > + self.remote_errors = remote_errors; > + > + return true; > + } > + Self::Message::Reload => { > + ctx.link().send_reload(); > + } > + } > + > + false > + } > + > + #[allow(unused_variables)] is this necessary? or is there some clippy weirdness going on? > + fn toolbar(&self, ctx: &LoadableComponentContext) -> Option { > + let on_refresh = ctx.link().callback(|_| ZoneTreeMsg::Reload); > + > + Some( > + Toolbar::new() > + .class("pwt-w-100") > + .class("pwt-overflow-hidden") > + .class("pwt-border-bottom") > + .with_flex_spacer() > + .with_child(Button::refresh(ctx.loading()).onclick(on_refresh)) > + .into(), > + ) > + } > + > + fn main_view(&self, _ctx: &LoadableComponentContext) -> yew::Html { > + let table = DataTable::new(self.columns.clone(), self.store.clone()) > + .selection(self.selection.clone()) > + .striped(false) > + .class(css::FlexFit); > + > + let mut column = Column::new().class(pwt::css::FlexFit).with_child(table); > + > + for remote_error in &self.remote_errors { > + column.add_child(error_message(remote_error)); > + } > + > + column.into() > + } > +} _______________________________________________ pdm-devel mailing list pdm-devel@lists.proxmox.com https://lists.proxmox.com/cgi-bin/mailman/listinfo/pdm-devel