From: Stefan Hanreich <s.hanreich@proxmox.com>
To: pdm-devel@lists.proxmox.com
Subject: [pdm-devel] [PATCH proxmox-datacenter-manager v2 5/5] ui: sdn: add zone tree
Date: Tue, 9 Sep 2025 17:54:20 +0200 [thread overview]
Message-ID: <20250909155423.526917-9-s.hanreich@proxmox.com> (raw)
In-Reply-To: <20250909155423.526917-1-s.hanreich@proxmox.com>
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 <s.hanreich@proxmox.com>
---
ui/src/main_menu.rs | 15 +-
ui/src/sdn/mod.rs | 3 +
ui/src/sdn/zone_tree.rs | 300 ++++++++++++++++++++++++++++++++++++++++
3 files changed, 317 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..0cda1a4
--- /dev/null
+++ b/ui/src/sdn/zone_tree.rs
@@ -0,0 +1,300 @@
+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<ZoneTree> for VNode {
+ fn from(value: ZoneTree) -> Self {
+ let comp = VComp::new::<LoadableComponentMaster<ZoneTreeComponent>>(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<RemoteResources>),
+ Reload,
+}
+
+pub struct ZoneTreeComponent {
+ store: TreeStore<ZoneTreeEntry>,
+ selection: Selection,
+ remote_errors: Vec<String>,
+ columns: Rc<Vec<DataTableHeader<ZoneTreeEntry>>>,
+}
+
+fn default_sorter(a: &ZoneTreeEntry, b: &ZoneTreeEntry) -> Ordering {
+ a.name().cmp(b.name())
+}
+
+impl ZoneTreeComponent {
+ fn columns(store: TreeStore<ZoneTreeEntry>) -> Rc<Vec<DataTableHeader<ZoneTreeEntry>>> {
+ Rc::new(vec![
+ DataTableColumn::new(tr!("Name"))
+ .tree_column(store)
+ .render(|entry: &ZoneTreeEntry| {
+ let icon = match entry {
+ ZoneTreeEntry::Remote(_) => Some("server"),
+ ZoneTreeEntry::Node(_, _) => Some("building"),
+ ZoneTreeEntry::Zone(_) => Some("th"),
+ _ => None,
+ };
+
+ Row::new()
+ .class(css::AlignItems::Baseline)
+ .gap(2)
+ .with_optional_child(icon.map(|icon| Fa::new(icon)))
+ .with_child(entry.name())
+ .into()
+ })
+ .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<RemoteResources>,
+) -> (SlabTree<ZoneTreeEntry>, Vec<String>) {
+ 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>) -> 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<Self>,
+ ) -> Pin<Box<dyn Future<Output = Result<(), anyhow::Error>>>> {
+ 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<Self>, 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
+ }
+
+ fn toolbar(&self, ctx: &LoadableComponentContext<Self>) -> Option<Html> {
+ 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<Self>) -> 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()
+ }
+}
--
2.47.3
_______________________________________________
pdm-devel mailing list
pdm-devel@lists.proxmox.com
https://lists.proxmox.com/cgi-bin/mailman/listinfo/pdm-devel
next prev parent reply other threads:[~2025-09-09 15:54 UTC|newest]
Thread overview: 11+ messages / expand[flat|nested] mbox.gz Atom feed top
2025-09-09 15:54 [pdm-devel] [PATCH manager/proxmox{-api-types, -datacenter-manager} v2 0/8] Add SDN resources to dashboard + SDN zone overview tree Stefan Hanreich
2025-09-09 15:54 ` [pdm-devel] [PATCH pve-manager v2 1/1] cluster: resources: add sdn property to cluster resources schema Stefan Hanreich
2025-09-10 8:20 ` [pdm-devel] applied: " Thomas Lamprecht
2025-09-09 15:54 ` [pdm-devel] [PATCH proxmox-api-types v2 1/2] cluster: resource: add sdn property Stefan Hanreich
2025-09-09 15:54 ` [pdm-devel] [PATCH proxmox-api-types v2 2/2] regenerate Stefan Hanreich
2025-09-09 15:54 ` [pdm-devel] [PATCH proxmox-datacenter-manager v2 1/5] pdm-api-types: add sdn cluster resource Stefan Hanreich
2025-09-09 15:54 ` [pdm-devel] [PATCH proxmox-datacenter-manager v2 2/5] server: api: add resource-type parameter to list_resources Stefan Hanreich
2025-09-09 15:54 ` [pdm-devel] [PATCH proxmox-datacenter-manager v2 3/5] ui: add sdn status report to dashboard Stefan Hanreich
2025-09-09 15:54 ` [pdm-devel] [PATCH proxmox-datacenter-manager v2 4/5] ui: images: add sdn icon Stefan Hanreich
2025-09-09 15:54 ` Stefan Hanreich [this message]
2025-09-10 10:27 ` [pdm-devel] applied: [PATCH manager/proxmox{-api-types, -datacenter-manager} v2 0/8] Add SDN resources to dashboard + SDN zone overview tree Thomas Lamprecht
Reply instructions:
You may reply publicly to this message via plain-text email
using any one of the following methods:
* Save the following mbox file, import it into your mail client,
and reply-to-all from there: mbox
Avoid top-posting and favor interleaved quoting:
https://en.wikipedia.org/wiki/Posting_style#Interleaved_style
* Reply using the --to, --cc, and --in-reply-to
switches of git-send-email(1):
git send-email \
--in-reply-to=20250909155423.526917-9-s.hanreich@proxmox.com \
--to=s.hanreich@proxmox.com \
--cc=pdm-devel@lists.proxmox.com \
/path/to/YOUR_REPLY
https://kernel.org/pub/software/scm/git/docs/git-send-email.html
* If your mail client supports setting the In-Reply-To header
via mailto: links, try the mailto: link
Be sure your reply has a Subject: header at the top and a blank line
before the message body.
This is a public inbox, see mirroring instructions
for how to clone and mirror all data and code used for this inbox