all lists on lists.proxmox.com
 help / color / mirror / Atom feed
* [pdm-devel] [PATCH manager/proxmox-datacenter-manager 0/6] Add SDN resources to dashboard + SDN zone overview tree
@ 2025-09-09 10:08 Stefan Hanreich
  2025-09-09 10:08 ` [pdm-devel] [PATCH pve-manager 1/1] cluster: resources: add sdn property to cluster resources schema Stefan Hanreich
                   ` (6 more replies)
  0 siblings, 7 replies; 18+ messages in thread
From: Stefan Hanreich @ 2025-09-09 10:08 UTC (permalink / raw)
  To: pdm-devel

## Introduction

This patch series adds the SDN cluster resources to the existing resource
infrastructure in PDM. It also adds a small panel to the dashboard that gives an
aggregated count of the status of SDN zones across all remotes. It also adds the
SDN resources to the resource tree.

It adds a new menu entry: SDN that acts as the top-level for all SDN-related
menu entries. The menu entry shows a tree of all SDN zones across all remotes,
as well as their current status.

I've decided to model the SDN entities as an enum, since in the future we want
to add additional SDN entities, and they all might have different properties.
This avoids a type where all additional properties have Option<> as well as
polluting the root Resource type with Sdn<Entity> variants.

## Additional API endpoints:

* GET /resources/type/{resource_type}

## Notes for reviewers:
* is the structure for the SDN resources okay, or should we introduce a
  dedicated resource for different SDN entities (i.e. PveSdnZone,
  PveSdnFabric, .. instead of PveSdn(Zone::(_)))?
* is the new API endpoint okay or should we just use the existing search
  infrastructure for returning SDN resources instead of introducing a dedicated
  API endpoint?

pve-manager:

Stefan Hanreich (1):
  cluster: resources: add sdn property to cluster resources schema

 PVE/API2/Cluster.pm | 5 +++++
 1 file changed, 5 insertions(+)


proxmox-datacenter-manager:

Stefan Hanreich (5):
  pdm-api-types: add sdn cluster resource
  server: api: add resources_by_type api call
  ui: add sdn status report to dashboard
  ui: images: add sdn icon
  ui: sdn: add zone tree

 cli/client/src/resources.rs                  |  14 +
 lib/pdm-api-types/src/resource.rs            | 159 +++++++++-
 lib/pdm-client/src/lib.rs                    |  14 +-
 lib/proxmox-api-types                        |   2 +-
 server/src/api/resources.rs                  | 131 +++++++-
 server/src/metric_collection/top_entities.rs |   1 +
 ui/Makefile                                  |   1 +
 ui/css/pdm.scss                              |  27 +-
 ui/images/icon-sdn.svg                       |  70 +++++
 ui/src/dashboard/mod.rs                      |  17 +-
 ui/src/dashboard/sdn_zone_panel.rs           | 155 ++++++++++
 ui/src/lib.rs                                |  13 +-
 ui/src/main_menu.rs                          |  15 +-
 ui/src/pve/remote.rs                         |   1 +
 ui/src/pve/tree.rs                           |   1 +
 ui/src/pve/utils.rs                          |  16 +-
 ui/src/renderer.rs                           |   4 +
 ui/src/sdn/mod.rs                            |   3 +
 ui/src/sdn/zone_tree.rs                      | 299 +++++++++++++++++++
 19 files changed, 929 insertions(+), 14 deletions(-)
 create mode 100644 ui/images/icon-sdn.svg
 create mode 100644 ui/src/dashboard/sdn_zone_panel.rs
 create mode 100644 ui/src/sdn/zone_tree.rs


Summary over all repositories:
  20 files changed, 934 insertions(+), 14 deletions(-)

-- 
Generated by git-murpp 0.8.0

_______________________________________________
pdm-devel mailing list
pdm-devel@lists.proxmox.com
https://lists.proxmox.com/cgi-bin/mailman/listinfo/pdm-devel


^ permalink raw reply	[flat|nested] 18+ messages in thread

* [pdm-devel] [PATCH pve-manager 1/1] cluster: resources: add sdn property to cluster resources schema
  2025-09-09 10:08 [pdm-devel] [PATCH manager/proxmox-datacenter-manager 0/6] Add SDN resources to dashboard + SDN zone overview tree Stefan Hanreich
@ 2025-09-09 10:08 ` Stefan Hanreich
  2025-09-09 10:08 ` [pdm-devel] [PATCH proxmox-datacenter-manager 1/5] pdm-api-types: add sdn cluster resource Stefan Hanreich
                   ` (5 subsequent siblings)
  6 siblings, 0 replies; 18+ messages in thread
From: Stefan Hanreich @ 2025-09-09 10:08 UTC (permalink / raw)
  To: pdm-devel

SDN entities return their name in the sdn property. Add this property
to the schema so it is shown in the documentation, as well for
generating proper types in proxmox-api-types.

Signed-off-by: Stefan Hanreich <s.hanreich@proxmox.com>
---
 PVE/API2/Cluster.pm | 5 +++++
 1 file changed, 5 insertions(+)

diff --git a/PVE/API2/Cluster.pm b/PVE/API2/Cluster.pm
index 529f59120..e7d245fb5 100644
--- a/PVE/API2/Cluster.pm
+++ b/PVE/API2/Cluster.pm
@@ -411,6 +411,11 @@ __PACKAGE__->register_method({
                     type => 'integer',
                     optional => 1,
                 },
+                sdn => {
+                    description => "The name of an SDN entity (for type 'sdn')",
+                    type => "string",
+                    optional => 1,
+                },
                 tags => {
                     description => "The guest's tags (for types 'qemu' and 'lxc')",
                     type => "string",
-- 
2.47.3


_______________________________________________
pdm-devel mailing list
pdm-devel@lists.proxmox.com
https://lists.proxmox.com/cgi-bin/mailman/listinfo/pdm-devel


^ permalink raw reply	[flat|nested] 18+ messages in thread

* [pdm-devel] [PATCH proxmox-datacenter-manager 1/5] pdm-api-types: add sdn cluster resource
  2025-09-09 10:08 [pdm-devel] [PATCH manager/proxmox-datacenter-manager 0/6] Add SDN resources to dashboard + SDN zone overview tree Stefan Hanreich
  2025-09-09 10:08 ` [pdm-devel] [PATCH pve-manager 1/1] cluster: resources: add sdn property to cluster resources schema Stefan Hanreich
@ 2025-09-09 10:08 ` Stefan Hanreich
  2025-09-09 11:13   ` Stefan Hanreich
  2025-09-09 10:08 ` [pdm-devel] [PATCH proxmox-datacenter-manager 2/5] server: api: add resources_by_type api call Stefan Hanreich
                   ` (4 subsequent siblings)
  6 siblings, 1 reply; 18+ messages in thread
From: Stefan Hanreich @ 2025-09-09 10:08 UTC (permalink / raw)
  To: pdm-devel

SDN has its own cluster resource that reports the status of SDN zones.
Add it to the existing Resource types, so it can be used in PDM. With
the introduction of fabrics, another type of SDN resource will follow.
In order to avoid creating a different Resource variant for each SDN
entitiy, use a Resource::PveSdn variant instead, that itself is an
enum. That allows for type-safety when introducing new SDN entities,
without polluting the root Resource type.

The status for localnetwork and other zones, if they are ok, diverges.
localnetwork returns ok, while zones return available. In order to
handle this, introduce a special SdnStatus that merges both of them
into one variant: Available. That eases handling and prevents mistakes
while maintaining backwards compatibility.

Signed-off-by: Stefan Hanreich <s.hanreich@proxmox.com>
---
 cli/client/src/resources.rs                  |  14 ++
 lib/pdm-api-types/src/resource.rs            | 144 ++++++++++++++++++-
 lib/proxmox-api-types                        |   2 +-
 server/src/api/resources.rs                  |  37 ++++-
 server/src/metric_collection/top_entities.rs |   1 +
 ui/src/lib.rs                                |   1 +
 ui/src/pve/remote.rs                         |   1 +
 ui/src/pve/tree.rs                           |   1 +
 ui/src/renderer.rs                           |   2 +
 9 files changed, 200 insertions(+), 3 deletions(-)

diff --git a/cli/client/src/resources.rs b/cli/client/src/resources.rs
index cbc616a..dbf9f26 100644
--- a/cli/client/src/resources.rs
+++ b/cli/client/src/resources.rs
@@ -52,6 +52,7 @@ async fn get_resources(max_age: Option<u64>) -> Result<(), Error> {
                     Resource::PveQemu(r) => println!("{}", PrintResource(r)),
                     Resource::PveLxc(r) => println!("{}", PrintResource(r)),
                     Resource::PveNode(r) => println!("{}", PrintResource(r)),
+                    Resource::PveSdn(r) => println!("{}", PrintResource(r)),
                     Resource::PbsNode(r) => println!("{}", PrintResource(r)),
                     Resource::PbsDatastore(r) => println!("{}", PrintResource(r)),
                 }
@@ -69,6 +70,7 @@ fn resource_order(item: &Resource) -> usize {
         Resource::PveStorage(_) => 1,
         Resource::PveLxc(_) => 2,
         Resource::PveQemu(_) => 3,
+        Resource::PveSdn(_) => 4,
 
         Resource::PbsNode(_) => 0,
         Resource::PbsDatastore(_) => 1,
@@ -148,6 +150,18 @@ impl fmt::Display for PrintResource<resource::PveNodeResource> {
     }
 }
 
+impl fmt::Display for PrintResource<resource::PveSdnResource> {
+    fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result {
+        write!(
+            f,
+            "    sdn zone {name} ({status}) on {node}",
+            name = self.0.name(),
+            status = self.0.status(),
+            node = self.0.node(),
+        )
+    }
+}
+
 impl fmt::Display for PrintResource<resource::PbsNodeResource> {
     fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result {
         let resource::PbsNodeResource {
diff --git a/lib/pdm-api-types/src/resource.rs b/lib/pdm-api-types/src/resource.rs
index fd2d49b..f274451 100644
--- a/lib/pdm-api-types/src/resource.rs
+++ b/lib/pdm-api-types/src/resource.rs
@@ -1,7 +1,9 @@
+use std::convert::Infallible;
+
 use anyhow::{bail, Error};
 use serde::{Deserialize, Serialize};
 
-use proxmox_schema::api;
+use proxmox_schema::{api, ApiStringFormat, ApiType, EnumEntry, OneOfSchema, Schema, StringSchema};
 
 use super::remotes::REMOTE_ID_SCHEMA;
 
@@ -20,6 +22,7 @@ pub enum Resource {
     PveQemu(PveQemuResource),
     PveLxc(PveLxcResource),
     PveNode(PveNodeResource),
+    PveSdn(PveSdnResource),
     PbsNode(PbsNodeResource),
     PbsDatastore(PbsDatastoreResource),
 }
@@ -33,6 +36,7 @@ impl Resource {
             Resource::PveQemu(r) => format!("qemu/{}", r.vmid),
             Resource::PveLxc(r) => format!("lxc/{}", r.vmid),
             Resource::PveNode(r) => format!("node/{}", r.node),
+            Resource::PveSdn(PveSdnResource::Zone(r)) => format!("sdn/{}/{}", r.node, r.name),
             Resource::PbsNode(r) => format!("node/{}", r.name),
             Resource::PbsDatastore(r) => r.name.clone(),
         }
@@ -46,6 +50,7 @@ impl Resource {
             Resource::PveQemu(r) => r.id.as_str(),
             Resource::PveLxc(r) => r.id.as_str(),
             Resource::PveNode(r) => r.id.as_str(),
+            Resource::PveSdn(r) => r.id(),
             Resource::PbsNode(r) => r.id.as_str(),
             Resource::PbsDatastore(r) => r.id.as_str(),
         }
@@ -59,6 +64,7 @@ impl Resource {
             Resource::PveQemu(r) => r.name.as_str(),
             Resource::PveLxc(r) => r.name.as_str(),
             Resource::PveNode(r) => r.node.as_str(),
+            Resource::PveSdn(r) => r.name(),
             Resource::PbsNode(r) => r.name.as_str(),
             Resource::PbsDatastore(r) => r.name.as_str(),
         }
@@ -69,6 +75,7 @@ impl Resource {
             Resource::PveStorage(_) => ResourceType::PveStorage,
             Resource::PveQemu(_) => ResourceType::PveQemu,
             Resource::PveLxc(_) => ResourceType::PveLxc,
+            Resource::PveSdn(PveSdnResource::Zone(_)) => ResourceType::PveSdnZone,
             Resource::PveNode(_) | Resource::PbsNode(_) => ResourceType::Node,
             Resource::PbsDatastore(_) => ResourceType::PbsDatastore,
         }
@@ -80,6 +87,7 @@ impl Resource {
             Resource::PveQemu(r) => r.status.as_str(),
             Resource::PveLxc(r) => r.status.as_str(),
             Resource::PveNode(r) => r.status.as_str(),
+            Resource::PveSdn(r) => r.status().as_str(),
             Resource::PbsNode(r) => {
                 if r.uptime > 0 {
                     "online"
@@ -93,10 +101,12 @@ impl Resource {
 }
 
 #[derive(Clone, Copy, Debug, PartialEq, Eq)]
+/// Type of a PDM resource.
 pub enum ResourceType {
     PveStorage,
     PveQemu,
     PveLxc,
+    PveSdnZone,
     PbsDatastore,
     Node,
 }
@@ -108,6 +118,7 @@ impl ResourceType {
             ResourceType::PveStorage => "storage",
             ResourceType::PveQemu => "qemu",
             ResourceType::PveLxc => "lxc",
+            ResourceType::PveSdnZone => "sdn-zone",
             ResourceType::PbsDatastore => "datastore",
             ResourceType::Node => "node",
         }
@@ -128,6 +139,7 @@ impl std::str::FromStr for ResourceType {
             "storage" => ResourceType::PveStorage,
             "qemu" => ResourceType::PveQemu,
             "lxc" => ResourceType::PveLxc,
+            "sdn-zone" => ResourceType::PveSdnZone,
             "datastore" => ResourceType::PbsDatastore,
             "node" => ResourceType::Node,
             _ => bail!("invalid resource type"),
@@ -151,6 +163,7 @@ pub enum PveResource {
     Qemu(PveQemuResource),
     Lxc(PveLxcResource),
     Node(PveNodeResource),
+    Sdn(PveSdnResource),
 }
 
 #[api(
@@ -297,6 +310,121 @@ pub struct PveStorageResource {
     pub status: String,
 }
 
+#[api]
+#[derive(Clone, Debug, Deserialize, Serialize, PartialEq)]
+#[serde(rename_all = "kebab-case")]
+/// SDN Zone
+pub struct SdnZoneResource {
+    /// Resource ID
+    pub id: String,
+    /// Name of the resource
+    pub name: String,
+    /// Cluster node name
+    pub node: String,
+    /// SDN status (available / error)
+    pub status: SdnStatus,
+}
+
+#[derive(Clone, Debug, Serialize, PartialEq, Copy, Default)]
+#[serde(rename_all = "lowercase")]
+/// the status of SDN entities
+///
+/// On the PVE side we have Ok and Available, since SDN Zones have status available if they're ok, but the
+/// localnetwork special zone has status ok. This enum merges both into the Available variant.
+pub enum SdnStatus {
+    Available,
+    Error,
+    #[serde(other)]
+    #[default]
+    Unknown,
+}
+
+impl std::str::FromStr for SdnStatus {
+    type Err = Infallible;
+
+    fn from_str(value: &str) -> Result<Self, Infallible> {
+        Ok(match value {
+            "ok" | "available" => Self::Available,
+            "error" => Self::Error,
+            _ => Self::Unknown,
+        })
+    }
+}
+
+proxmox_serde::forward_deserialize_to_from_str!(SdnStatus);
+proxmox_serde::forward_display_to_serialize!(SdnStatus);
+
+impl SdnStatus {
+    pub fn as_str(&self) -> &'static str {
+        match self {
+            Self::Available => "available",
+            Self::Error => "error",
+            Self::Unknown => "unknown",
+        }
+    }
+}
+
+impl ApiType for SdnStatus {
+    const API_SCHEMA: Schema = StringSchema::new("SDN status").schema();
+}
+
+#[derive(Clone, Debug, Deserialize, Serialize, PartialEq)]
+#[serde(tag = "sdn_type", rename_all = "lowercase")]
+/// SDN resource in PDM
+pub enum PveSdnResource {
+    Zone(SdnZoneResource),
+}
+
+impl ApiType for PveSdnResource {
+    const API_SCHEMA: Schema = OneOfSchema::new(
+        "PVE SDN resource",
+        &(
+            "sdn_type",
+            false,
+            &StringSchema::new("PVE SDN resource type")
+                .format(&ApiStringFormat::Enum(&[EnumEntry::new(
+                    "zone",
+                    "An SDN zone.",
+                )]))
+                .schema(),
+        ),
+        &[("zone", &SdnZoneResource::API_SCHEMA)],
+    )
+    .schema();
+}
+
+impl PveSdnResource {
+    pub fn id(&self) -> &str {
+        match self {
+            Self::Zone(zone) => zone.id.as_str(),
+        }
+    }
+
+    pub fn name(&self) -> &str {
+        match self {
+            Self::Zone(zone) => zone.name.as_str(),
+        }
+    }
+
+    pub fn node(&self) -> &str {
+        match self {
+            Self::Zone(zone) => zone.node.as_str(),
+        }
+    }
+
+    pub fn status(&self) -> SdnStatus {
+        match self {
+            Self::Zone(zone) => zone.status,
+        }
+    }
+
+    pub fn sdn_type(&self) -> &'static str {
+        match self {
+            Self::Zone(_) => "sdn-zone",
+        }
+    }
+}
+
 #[api]
 #[derive(Clone, Debug, Deserialize, Serialize, PartialEq)]
 #[serde(rename_all = "kebab-case")]
@@ -397,6 +525,18 @@ pub struct StorageStatusCount {
     pub unknown: u64,
 }
 
+#[api]
+#[derive(Default, Serialize, Deserialize, Clone, PartialEq)]
+/// Amount of SDN zones in certain states
+pub struct SdnZoneCount {
+    /// Amount of available / ok zones
+    pub available: u64,
+    /// Amount of erroneous sdn zones
+    pub error: u64,
+    /// Amount of sdn zones with an unknown status
+    pub unknown: u64,
+}
+
 #[api]
 #[derive(Default, Serialize, Deserialize, Clone, PartialEq)]
 /// Describes the status of seen resources
@@ -413,6 +553,8 @@ pub struct ResourcesStatus {
     pub lxc: GuestStatusCount,
     /// Status of storage status
     pub storages: StorageStatusCount,
+    /// Status of storage status
+    pub sdn_zones: SdnZoneCount,
     /// Status of PBS Nodes
     pub pbs_nodes: NodeStatusCount,
     /// Status of PBS Datastores
diff --git a/lib/proxmox-api-types b/lib/proxmox-api-types
index cdb4dcf..973ed53 160000
--- a/lib/proxmox-api-types
+++ b/lib/proxmox-api-types
@@ -1 +1 @@
-Subproject commit cdb4dcfe0791c7960ccd0ead36ec2381c6e8e0be
+Subproject commit 973ed53cd737f6b76351e66b061635678f104098
diff --git a/server/src/api/resources.rs b/server/src/api/resources.rs
index 98c4dea..736bfb9 100644
--- a/server/src/api/resources.rs
+++ b/server/src/api/resources.rs
@@ -1,4 +1,5 @@
 use std::collections::HashMap;
+use std::str::FromStr;
 use std::sync::{LazyLock, RwLock};
 
 use anyhow::{bail, format_err, Error};
@@ -9,7 +10,8 @@ use pbs_api_types::{DataStoreStatusListItem, NodeStatus};
 use pdm_api_types::remotes::{Remote, RemoteType};
 use pdm_api_types::resource::{
     PbsDatastoreResource, PbsNodeResource, PveLxcResource, PveNodeResource, PveQemuResource,
-    PveStorageResource, RemoteResources, Resource, ResourcesStatus, TopEntities,
+    PveSdnResource, PveStorageResource, RemoteResources, Resource, ResourceType, ResourcesStatus,
+    SdnStatus, SdnZoneResource, TopEntities,
 };
 use pdm_api_types::subscription::{
     NodeSubscriptionInfo, RemoteSubscriptionState, RemoteSubscriptions, SubscriptionLevel,
@@ -347,6 +349,21 @@ pub async fn get_status(
                     "offline" => counts.pve_nodes.offline += 1,
                     _ => counts.pve_nodes.unknown += 1,
                 },
+                Resource::PveSdn(r) => {
+                    if let PveSdnResource::Zone(_) = &r {
+                        match r.status() {
+                            SdnStatus::Available => {
+                                counts.sdn_zones.available += 1;
+                            }
+                            SdnStatus::Error => {
+                                counts.sdn_zones.error += 1;
+                            }
+                            SdnStatus::Unknown => {
+                                counts.sdn_zones.unknown += 1;
+                            }
+                        }
+                    }
+                }
                 // FIXME better status for pbs/datastores
                 Resource::PbsNode(_) => counts.pbs_nodes.online += 1,
                 Resource::PbsDatastore(_) => counts.pbs_datastores.available += 1,
@@ -836,12 +853,30 @@ pub(super) fn map_pve_storage(
     }
 }
 
+pub(super) fn map_pve_sdn(remote: &str, resource: ClusterResource) -> Option<PveSdnResource> {
+    match resource.ty {
+        ClusterResourceType::Sdn => {
+            let node = resource.node.unwrap_or_default();
+
+            Some(PveSdnResource::Zone(SdnZoneResource {
+                id: format!("remote/{remote}/sdn/{}", &resource.id),
+                name: resource.sdn.unwrap_or_default(),
+                node,
+                status: SdnStatus::from_str(resource.status.unwrap_or_default().as_str())
+                    .unwrap_or_default(),
+            }))
+        }
+        _ => None,
+    }
+}
+
 fn map_pve_resource(remote: &str, resource: ClusterResource) -> Option<Resource> {
     match resource.ty {
         ClusterResourceType::Node => map_pve_node(remote, resource).map(Resource::PveNode),
         ClusterResourceType::Lxc => map_pve_lxc(remote, resource).map(Resource::PveLxc),
         ClusterResourceType::Qemu => map_pve_qemu(remote, resource).map(Resource::PveQemu),
         ClusterResourceType::Storage => map_pve_storage(remote, resource).map(Resource::PveStorage),
+        ClusterResourceType::Sdn => map_pve_sdn(remote, resource).map(Resource::PveSdn),
         _ => None,
     }
 }
diff --git a/server/src/metric_collection/top_entities.rs b/server/src/metric_collection/top_entities.rs
index 31e36c3..47fda24 100644
--- a/server/src/metric_collection/top_entities.rs
+++ b/server/src/metric_collection/top_entities.rs
@@ -100,6 +100,7 @@ pub fn calculate_top(
                             }
                         }
                     }
+                    Resource::PveSdn(_) => {}
                     Resource::PbsNode(_) => {}
                     Resource::PbsDatastore(_) => {}
                 }
diff --git a/ui/src/lib.rs b/ui/src/lib.rs
index 37e6458..5ffbff3 100644
--- a/ui/src/lib.rs
+++ b/ui/src/lib.rs
@@ -167,6 +167,7 @@ pub(crate) fn get_resource_node(resource: &Resource) -> Option<&str> {
         Resource::PveQemu(qemu) => Some(&qemu.node),
         Resource::PveLxc(lxc) => Some(&lxc.node),
         Resource::PveNode(node) => Some(&node.node),
+        Resource::PveSdn(sdn) => Some(&sdn.sdn),
         Resource::PbsNode(_) => None,
         Resource::PbsDatastore(_) => None,
     }
diff --git a/ui/src/pve/remote.rs b/ui/src/pve/remote.rs
index e9e3a84..5c53515 100644
--- a/ui/src/pve/remote.rs
+++ b/ui/src/pve/remote.rs
@@ -115,6 +115,7 @@ impl RemotePanelComp {
                         _ => level = Some(""),
                     }
                 }
+                PveResource::Sdn(_) => {}
             }
         }
         // render, but this would be all better with some actual types...
diff --git a/ui/src/pve/tree.rs b/ui/src/pve/tree.rs
index 168e322..d161fc4 100644
--- a/ui/src/pve/tree.rs
+++ b/ui/src/pve/tree.rs
@@ -199,6 +199,7 @@ impl PveTreeComp {
                     }
                     node.append(PveTreeNode::Storage(storage.clone()));
                 }
+                PveResource::Sdn(_) => {}
             }
         }
         if !self.loaded {
diff --git a/ui/src/renderer.rs b/ui/src/renderer.rs
index e74ab3d..5ebd9a3 100644
--- a/ui/src/renderer.rs
+++ b/ui/src/renderer.rs
@@ -17,6 +17,7 @@ pub fn render_resource_name(resource: &Resource, vmid_first: bool) -> String {
         Resource::PveQemu(qemu) => pve::utils::render_qemu_name(qemu, vmid_first),
         Resource::PveLxc(lxc) => pve::utils::render_lxc_name(lxc, vmid_first),
         Resource::PveNode(node) => node.node.clone(),
+        Resource::PveSdn(sdn) => sdn.sdn.clone(),
         Resource::PbsNode(node) => node.name.clone(),
         Resource::PbsDatastore(store) => store.name.clone(),
     }
@@ -28,6 +29,7 @@ pub fn render_resource_icon(resource: &Resource) -> Fa {
         Resource::PveQemu(_) => "desktop",
         Resource::PveLxc(_) => "cube",
         Resource::PveNode(_) => "building",
+        Resource::PveSdn(_) => "fa-sdn",
         Resource::PbsNode(_) => "building-o",
         Resource::PbsDatastore(_) => "floppy-o",
     };
-- 
2.47.3


_______________________________________________
pdm-devel mailing list
pdm-devel@lists.proxmox.com
https://lists.proxmox.com/cgi-bin/mailman/listinfo/pdm-devel


^ permalink raw reply	[flat|nested] 18+ messages in thread

* [pdm-devel] [PATCH proxmox-datacenter-manager 2/5] server: api: add resources_by_type api call
  2025-09-09 10:08 [pdm-devel] [PATCH manager/proxmox-datacenter-manager 0/6] Add SDN resources to dashboard + SDN zone overview tree Stefan Hanreich
  2025-09-09 10:08 ` [pdm-devel] [PATCH pve-manager 1/1] cluster: resources: add sdn property to cluster resources schema Stefan Hanreich
  2025-09-09 10:08 ` [pdm-devel] [PATCH proxmox-datacenter-manager 1/5] pdm-api-types: add sdn cluster resource Stefan Hanreich
@ 2025-09-09 10:08 ` Stefan Hanreich
  2025-09-09 10:08 ` [pdm-devel] [PATCH proxmox-datacenter-manager 3/5] ui: add sdn status report to dashboard Stefan Hanreich
                   ` (3 subsequent siblings)
  6 siblings, 0 replies; 18+ messages in thread
From: Stefan Hanreich @ 2025-09-09 10:08 UTC (permalink / raw)
  To: pdm-devel

Add an API call that returns resources of a given type. While the
search endpoint could be used for that, this endpoint is more
efficient since it doesn't instantiate the whole search logic and then
runs string comparison on all resources, but rather directly filters
by the type of the resource.

Signed-off-by: Stefan Hanreich <s.hanreich@proxmox.com>
---
 lib/pdm-api-types/src/resource.rs | 15 ++++-
 lib/pdm-client/src/lib.rs         | 14 ++++-
 server/src/api/resources.rs       | 94 +++++++++++++++++++++++++++++++
 3 files changed, 121 insertions(+), 2 deletions(-)

diff --git a/lib/pdm-api-types/src/resource.rs b/lib/pdm-api-types/src/resource.rs
index f274451..b219250 100644
--- a/lib/pdm-api-types/src/resource.rs
+++ b/lib/pdm-api-types/src/resource.rs
@@ -100,14 +100,27 @@ impl Resource {
     }
 }
 
-#[derive(Clone, Copy, Debug, PartialEq, Eq)]
+#[api]
+#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)]
 /// Type of a PDM resource.
 pub enum ResourceType {
+    /// PVE Storage Resource
+    #[serde(rename = "storage")]
     PveStorage,
+    /// PVE Qemu Resource
+    #[serde(rename = "qemu")]
     PveQemu,
+    /// PVE LXC Resource
+    #[serde(rename = "lxc")]
     PveLxc,
+    /// PVE SDN Resource
+    #[serde(rename = "sdn-zone")]
     PveSdnZone,
+    /// PBS Datastore Resource
+    #[serde(rename = "datastore")]
     PbsDatastore,
+    /// Node resource
+    #[serde(rename = "node")]
     Node,
 }
 
diff --git a/lib/pdm-client/src/lib.rs b/lib/pdm-client/src/lib.rs
index 845738f..ad1852e 100644
--- a/lib/pdm-client/src/lib.rs
+++ b/lib/pdm-client/src/lib.rs
@@ -4,7 +4,7 @@ use std::collections::HashMap;
 use std::time::Duration;
 
 use pdm_api_types::remotes::TlsProbeOutcome;
-use pdm_api_types::resource::{PveResource, RemoteResources, TopEntities};
+use pdm_api_types::resource::{PveResource, RemoteResources, ResourceType, TopEntities};
 use pdm_api_types::rrddata::{
     LxcDataPoint, NodeDataPoint, PbsDatastoreDataPoint, PbsNodeDataPoint, PveStorageDataPoint,
     QemuDataPoint,
@@ -865,6 +865,18 @@ impl<T: HttpApiClient> PdmClient<T> {
         Ok(self.0.get(&path).await?.expect_json()?.data)
     }
 
+    pub async fn resources_by_type(
+        &self,
+        max_age: Option<u64>,
+        resource_type: ResourceType,
+    ) -> Result<Vec<RemoteResources>, Error> {
+        let path = ApiPathBuilder::new(format!("/api2/extjs/resources/type/{resource_type}"))
+            .maybe_arg("max-age", &max_age)
+            .build();
+
+        Ok(self.0.get(&path).await?.expect_json()?.data)
+    }
+
     pub async fn pve_list_networks(
         &self,
         remote: &str,
diff --git a/server/src/api/resources.rs b/server/src/api/resources.rs
index 736bfb9..03ad03a 100644
--- a/server/src/api/resources.rs
+++ b/server/src/api/resources.rs
@@ -27,6 +27,7 @@ use proxmox_schema::{api, parse_boolean};
 use proxmox_sortable_macro::sortable;
 use proxmox_subscription::SubscriptionStatus;
 use pve_api_types::{ClusterResource, ClusterResourceType};
+use tokio::task::JoinSet;
 
 use crate::connection;
 use crate::metric_collection::top_entities;
@@ -35,9 +36,15 @@ pub const ROUTER: Router = Router::new()
     .get(&list_subdirs_api_method!(SUBDIRS))
     .subdirs(SUBDIRS);
 
+pub const TYPE_ROUTER: Router = Router::new().match_all(
+    "resource-type",
+    &Router::new().get(&API_METHOD_GET_RESOURCES_BY_TYPE),
+);
+
 #[sortable]
 const SUBDIRS: SubdirMap = &sorted!([
     ("list", &Router::new().get(&API_METHOD_GET_RESOURCES)),
+    ("type", &TYPE_ROUTER),
     ("status", &Router::new().get(&API_METHOD_GET_STATUS)),
     (
         "top-entities",
@@ -966,3 +973,90 @@ mod tests {
         }
     }
 }
+
+#[api(
+    // FIXME:: see list-like API calls in resource routers, we probably want more fine-grained
+    // checks..
+    access: {
+        permission: &Permission::Anybody,
+    },
+    input: {
+        properties: {
+            "max-age": {
+                description: "Maximum age (in seconds) of cached remote resources.",
+                // TODO: What is a sensible default max-age?
+                default: 30,
+                optional: true,
+            },
+            "resource-type": {
+                type: ResourceType,
+            },
+        }
+    },
+    returns: {
+        description: "Array of resources, grouped by remote",
+        type: Array,
+        items: {
+            type: RemoteResources,
+        }
+    },
+)]
+/// List all resources of with specific type(s).
+pub async fn get_resources_by_type(
+    max_age: u64,
+    resource_type: ResourceType,
+    rpcenv: &mut dyn RpcEnvironment,
+) -> Result<Vec<RemoteResources>, Error> {
+    let user_info = CachedUserInfo::new()?;
+
+    let auth_id: Authid = rpcenv
+        .get_auth_id()
+        .ok_or_else(|| format_err!("no authid available"))?
+        .parse()?;
+
+    if !user_info.any_privs_below(&auth_id, &["resource"], PRIV_RESOURCE_AUDIT)? {
+        http_bail!(UNAUTHORIZED, "user has no access to resources");
+    }
+
+    let (remotes_config, _) = pdm_config::remotes::config()?;
+
+    let mut join_set = JoinSet::new();
+
+    for (remote_name, remote) in remotes_config {
+        let remote_privs = user_info.lookup_privs(&auth_id, &["resource", &remote_name]);
+
+        if remote_privs & PRIV_RESOURCE_AUDIT == 0 {
+            continue;
+        }
+
+        join_set.spawn(async move {
+            let (resources, error) = match get_resources_for_remote(remote, max_age).await {
+                Ok(mut resources) => {
+                    resources.retain(|resource| resource.resource_type() == resource_type);
+                    (resources, None)
+                }
+                Err(error) => (Vec::new(), Some(error.to_string())),
+            };
+
+            RemoteResources {
+                remote: remote_name,
+                resources,
+                error,
+            }
+        });
+    }
+
+    let mut result = Vec::new();
+    while let Some(res) = join_set.join_next().await {
+        match res {
+            Ok(resources) => {
+                result.push(resources);
+            }
+            Err(error) => {
+                proxmox_log::error!("could not join get_resources task: {error:#}");
+            }
+        }
+    }
+
+    Ok(result)
+}
-- 
2.47.3


_______________________________________________
pdm-devel mailing list
pdm-devel@lists.proxmox.com
https://lists.proxmox.com/cgi-bin/mailman/listinfo/pdm-devel


^ permalink raw reply	[flat|nested] 18+ messages in thread

* [pdm-devel] [PATCH proxmox-datacenter-manager 3/5] ui: add sdn status report to dashboard
  2025-09-09 10:08 [pdm-devel] [PATCH manager/proxmox-datacenter-manager 0/6] Add SDN resources to dashboard + SDN zone overview tree Stefan Hanreich
                   ` (2 preceding siblings ...)
  2025-09-09 10:08 ` [pdm-devel] [PATCH proxmox-datacenter-manager 2/5] server: api: add resources_by_type api call Stefan Hanreich
@ 2025-09-09 10:08 ` Stefan Hanreich
  2025-09-09 13:10   ` Dominik Csapak
  2025-09-09 10:08 ` [pdm-devel] [PATCH proxmox-datacenter-manager 4/5] ui: images: add sdn icon Stefan Hanreich
                   ` (2 subsequent siblings)
  6 siblings, 1 reply; 18+ messages in thread
From: Stefan Hanreich @ 2025-09-09 10:08 UTC (permalink / raw)
  To: pdm-devel

This also includes support for external links, searching and
navigating to the dedicated SDN overview. For now, add it to the task
summary row, since there are issues with only showing one element in a
row due to the CSS rules. While this breaks a little bit with the
current grouping, the widget is quite small so it would look weird in
a single row and we can always decide to move it around as soon as we
add more elements to the dashboard.

Signed-off-by: Stefan Hanreich <s.hanreich@proxmox.com>
---
 ui/src/dashboard/mod.rs            |  17 +++-
 ui/src/dashboard/sdn_zone_panel.rs | 155 +++++++++++++++++++++++++++++
 ui/src/lib.rs                      |  14 ++-
 ui/src/pve/utils.rs                |  16 ++-
 ui/src/renderer.rs                 |   4 +-
 5 files changed, 199 insertions(+), 7 deletions(-)
 create mode 100644 ui/src/dashboard/sdn_zone_panel.rs

diff --git a/ui/src/dashboard/mod.rs b/ui/src/dashboard/mod.rs
index 0659dc0..626a6bf 100644
--- a/ui/src/dashboard/mod.rs
+++ b/ui/src/dashboard/mod.rs
@@ -25,7 +25,7 @@ use pwt::{
 };
 
 use pdm_api_types::{
-    resource::{GuestStatusCount, NodeStatusCount, ResourcesStatus},
+    resource::{GuestStatusCount, NodeStatusCount, ResourcesStatus, SdnZoneCount},
     TaskStatistics,
 };
 use pdm_client::types::TopEntity;
@@ -46,6 +46,9 @@ use remote_panel::RemotePanel;
 mod guest_panel;
 use guest_panel::GuestPanel;
 
+mod sdn_zone_panel;
+use sdn_zone_panel::SdnZonePanel;
+
 mod status_row;
 use status_row::DashboardStatusRow;
 
@@ -242,6 +245,15 @@ impl PdmDashboard {
             ))
     }
 
+    fn create_sdn_panel(&self, status: &SdnZoneCount) -> Panel {
+        Panel::new()
+            .flex(1.0)
+            .width(200)
+            .title(self.create_title_with_icon("sdn", tr!("SDN Zones")))
+            .border(true)
+            .with_child(SdnZonePanel::new((!self.loading).then_some(status.clone())))
+    }
+
     fn create_task_summary_panel(
         &self,
         statistics: &StatisticsOptions,
@@ -620,7 +632,8 @@ impl Component for PdmDashboard {
                     .class(pwt::css::Flex::Fill)
                     .class(FlexWrap::Wrap)
                     .with_child(self.create_task_summary_panel(&self.statistics, None))
-                    .with_child(self.create_task_summary_panel(&self.statistics, Some(5))),
+                    .with_child(self.create_task_summary_panel(&self.statistics, Some(5)))
+                    .with_child(self.create_sdn_panel(&self.status.sdn_zones)),
             );
 
         Panel::new()
diff --git a/ui/src/dashboard/sdn_zone_panel.rs b/ui/src/dashboard/sdn_zone_panel.rs
new file mode 100644
index 0000000..bcac36b
--- /dev/null
+++ b/ui/src/dashboard/sdn_zone_panel.rs
@@ -0,0 +1,155 @@
+use std::rc::Rc;
+
+use pdm_api_types::resource::{ResourceType, SdnStatus, SdnZoneCount};
+use pdm_search::{Search, SearchTerm};
+use pwt::{
+    css::{self, FontColor, TextAlign},
+    prelude::*,
+    widget::{Container, Fa, List, ListTile},
+};
+use yew::{
+    virtual_dom::{VComp, VNode},
+    Properties,
+};
+
+use crate::search_provider::get_search_provider;
+
+use super::loading_column;
+
+#[derive(PartialEq, Clone, Properties)]
+pub struct SdnZonePanel {
+    status: Option<SdnZoneCount>,
+}
+
+impl SdnZonePanel {
+    pub fn new(status: Option<SdnZoneCount>) -> Self {
+        yew::props!(Self { status })
+    }
+}
+
+impl From<SdnZonePanel> for VNode {
+    fn from(value: SdnZonePanel) -> Self {
+        let comp = VComp::new::<SdnZonePanelComponent>(Rc::new(value), None);
+        VNode::from(comp)
+    }
+}
+
+#[derive(PartialEq, Clone)]
+pub enum StatusRow {
+    State(SdnStatus, u64),
+    All(u64),
+}
+
+impl StatusRow {
+    fn icon(&self) -> Fa {
+        let (icon, color) = match self {
+            Self::All(_) => ("th", None),
+            Self::State(SdnStatus::Available, _) => ("check", Some(FontColor::Success)),
+            Self::State(SdnStatus::Error, _) => ("times-circle", Some(FontColor::Error)),
+            Self::State(SdnStatus::Unknown, _) => ("question", None),
+        };
+
+        let mut icon = Fa::new(icon);
+
+        if let Some(color) = color {
+            icon = icon.class(color);
+        }
+
+        icon
+    }
+}
+
+pub struct SdnZonePanelComponent {}
+
+impl yew::Component for SdnZonePanelComponent {
+    type Message = Search;
+    type Properties = SdnZonePanel;
+
+    fn create(_ctx: &yew::Context<Self>) -> Self {
+        Self {}
+    }
+
+    fn update(&mut self, ctx: &Context<Self>, msg: Self::Message) -> bool {
+        if let Some(provider) = get_search_provider(ctx) {
+            provider.search(msg);
+        }
+
+        false
+    }
+
+    fn view(&self, ctx: &yew::Context<Self>) -> yew::Html {
+        let props = ctx.props();
+
+        let Some(status) = &props.status else {
+            return loading_column().into();
+        };
+
+        let data = vec![
+            StatusRow::State(SdnStatus::Available, status.available),
+            StatusRow::State(SdnStatus::Error, status.error),
+            StatusRow::All(status.available + status.error + status.unknown),
+        ];
+
+        let tiles: Vec<_> = data
+            .into_iter()
+            .filter_map(|row| create_list_tile(ctx.link(), row))
+            .collect();
+
+        let list = List::new(tiles.len() as u64, move |idx: u64| {
+            tiles[idx as usize].clone()
+        })
+        .padding(4)
+        .class(css::Flex::Fill)
+        .grid_template_columns("auto auto 1fr auto");
+
+        list.into()
+    }
+}
+
+fn create_list_tile(
+    link: &html::Scope<SdnZonePanelComponent>,
+    status_row: StatusRow,
+) -> Option<ListTile> {
+    let (icon, status, count) = match status_row {
+        StatusRow::State(sdn_status, count) => (status_row.icon(), Some(sdn_status), count),
+        StatusRow::All(count) => (status_row.icon(), None, count),
+    };
+
+    let name = status
+        .map(|status| status.to_string())
+        .unwrap_or_else(|| "All".to_string());
+
+    Some(
+        ListTile::new()
+            .tabindex(0)
+            .interactive(true)
+            .with_child(icon)
+            .with_child(Container::new().padding_x(2).with_child(name))
+            .with_child(
+                Container::new()
+                    .class(TextAlign::Right)
+                    .padding_end(2)
+                    .with_child(count),
+            )
+            .with_child(Fa::new("search"))
+            .onclick(link.callback(move |_| create_sdn_zone_search_term(status)))
+            .onkeydown(link.batch_callback(
+                move |event: KeyboardEvent| match event.key().as_str() {
+                    "Enter" | " " => Some(create_sdn_zone_search_term(status)),
+                    _ => None,
+                },
+            )),
+    )
+}
+
+fn create_sdn_zone_search_term(status: Option<SdnStatus>) -> Search {
+    let resource_type: ResourceType = ResourceType::PveSdnZone;
+
+    let mut terms = vec![SearchTerm::new(resource_type.as_str()).category(Some("type"))];
+
+    if let Some(status) = status {
+        terms.push(SearchTerm::new(status.to_string()).category(Some("status")));
+    }
+
+    Search::with_terms(terms)
+}
diff --git a/ui/src/lib.rs b/ui/src/lib.rs
index 5ffbff3..e4bfbb7 100644
--- a/ui/src/lib.rs
+++ b/ui/src/lib.rs
@@ -1,4 +1,4 @@
-use pdm_api_types::resource::{PveLxcResource, PveQemuResource};
+use pdm_api_types::resource::{PveLxcResource, PveQemuResource, PveSdnResource};
 use pdm_client::types::Resource;
 use serde::{Deserialize, Serialize};
 
@@ -151,13 +151,21 @@ pub(crate) fn navigate_to<C: yew::Component>(
                     pdm_client::types::Resource::PveStorage(storage) => {
                         format!("storage+{}+{}", storage.node, storage.storage)
                     }
+                    pdm_client::types::Resource::PveSdn(PveSdnResource::Zone(_)) => {
+                        "sdn/zones".to_string()
+                    }
                     pdm_client::types::Resource::PbsDatastore(store) => store.name.clone(),
                     // FIXME: implement
                     _ => return None,
                 })
             })
             .unwrap_or_default();
-        nav.push(&yew_router::AnyRoute::new(format!("/remote-{remote}/{id}")));
+
+        if let Some(pdm_client::types::Resource::PveSdn(_)) = resource {
+            nav.push(&yew_router::AnyRoute::new(format!("/{id}")));
+        } else {
+            nav.push(&yew_router::AnyRoute::new(format!("/remote-{remote}/{id}")));
+        }
     }
 }
 
@@ -167,7 +175,7 @@ pub(crate) fn get_resource_node(resource: &Resource) -> Option<&str> {
         Resource::PveQemu(qemu) => Some(&qemu.node),
         Resource::PveLxc(lxc) => Some(&lxc.node),
         Resource::PveNode(node) => Some(&node.node),
-        Resource::PveSdn(sdn) => Some(&sdn.sdn),
+        Resource::PveSdn(sdn) => Some(sdn.node()),
         Resource::PbsNode(_) => None,
         Resource::PbsDatastore(_) => None,
     }
diff --git a/ui/src/pve/utils.rs b/ui/src/pve/utils.rs
index 7663734..a49205d 100644
--- a/ui/src/pve/utils.rs
+++ b/ui/src/pve/utils.rs
@@ -1,6 +1,7 @@
 use anyhow::Error;
 use pdm_api_types::resource::{
-    PveLxcResource, PveNodeResource, PveQemuResource, PveStorageResource,
+    PveLxcResource, PveNodeResource, PveQemuResource, PveStorageResource, SdnStatus,
+    SdnZoneResource,
 };
 use pdm_client::types::{
     LxcConfig, LxcConfigMp, LxcConfigRootfs, LxcConfigUnused, PveQmIde, QemuConfig, QemuConfigSata,
@@ -88,6 +89,19 @@ pub fn render_node_status_icon(node: &PveNodeResource) -> Container {
         .with_child(Fa::from(extra).fixed_width().class("status-icon"))
 }
 
+/// Renders the status icon for a PveNode
+pub fn render_sdn_status_icon(zone: &SdnZoneResource) -> Container {
+    let extra = match zone.status {
+        SdnStatus::Available => NodeState::Online,
+        SdnStatus::Error => NodeState::Offline,
+        _ => NodeState::Unknown,
+    };
+    Container::new()
+        .class("pdm-type-icon")
+        .with_child(Fa::new("th").fixed_width())
+        .with_child(Fa::from(extra).fixed_width().class("status-icon"))
+}
+
 /// Renders the status icon for a PveStorage
 pub fn render_storage_status_icon(node: &PveStorageResource) -> Container {
     let extra = match node.status.as_str() {
diff --git a/ui/src/renderer.rs b/ui/src/renderer.rs
index 5ebd9a3..e179cd5 100644
--- a/ui/src/renderer.rs
+++ b/ui/src/renderer.rs
@@ -1,3 +1,4 @@
+use pdm_api_types::resource::PveSdnResource;
 use pwt::{
     css,
     prelude::*,
@@ -17,7 +18,7 @@ pub fn render_resource_name(resource: &Resource, vmid_first: bool) -> String {
         Resource::PveQemu(qemu) => pve::utils::render_qemu_name(qemu, vmid_first),
         Resource::PveLxc(lxc) => pve::utils::render_lxc_name(lxc, vmid_first),
         Resource::PveNode(node) => node.node.clone(),
-        Resource::PveSdn(sdn) => sdn.sdn.clone(),
+        Resource::PveSdn(sdn) => sdn.name().to_string(),
         Resource::PbsNode(node) => node.name.clone(),
         Resource::PbsDatastore(store) => store.name.clone(),
     }
@@ -43,6 +44,7 @@ pub fn render_status_icon(resource: &Resource) -> Container {
         Resource::PveQemu(qemu) => pve::utils::render_qemu_status_icon(qemu),
         Resource::PveLxc(lxc) => pve::utils::render_lxc_status_icon(lxc),
         Resource::PveNode(node) => pve::utils::render_node_status_icon(node),
+        Resource::PveSdn(PveSdnResource::Zone(zone)) => pve::utils::render_sdn_status_icon(zone),
         // FIXME: implement remaining types
         _ => Container::new().with_child(render_resource_icon(resource)),
     }
-- 
2.47.3


_______________________________________________
pdm-devel mailing list
pdm-devel@lists.proxmox.com
https://lists.proxmox.com/cgi-bin/mailman/listinfo/pdm-devel


^ permalink raw reply	[flat|nested] 18+ messages in thread

* [pdm-devel] [PATCH proxmox-datacenter-manager 4/5] ui: images: add sdn icon
  2025-09-09 10:08 [pdm-devel] [PATCH manager/proxmox-datacenter-manager 0/6] Add SDN resources to dashboard + SDN zone overview tree Stefan Hanreich
                   ` (3 preceding siblings ...)
  2025-09-09 10:08 ` [pdm-devel] [PATCH proxmox-datacenter-manager 3/5] ui: add sdn status report to dashboard Stefan Hanreich
@ 2025-09-09 10:08 ` Stefan Hanreich
  2025-09-09 13:16   ` Dominik Csapak
  2025-09-09 10:08 ` [pdm-devel] [PATCH proxmox-datacenter-manager 5/5] ui: sdn: add zone tree Stefan Hanreich
  2025-09-09 13:43 ` [pdm-devel] [PATCH manager/proxmox-datacenter-manager 0/6] Add SDN resources to dashboard + SDN zone overview tree Dominik Csapak
  6 siblings, 1 reply; 18+ messages in thread
From: Stefan Hanreich @ 2025-09-09 10:08 UTC (permalink / raw)
  To: pdm-devel

Add the icon used for SDN in Proxmox VE manually, since it isn't
included in our distribution of font awesome. SVG icons have the issue
that their color cannot be simply changed via the CSS color
attribute. This is problematic when showing an icon in the navigation
or on the dashboard, where we use the color attribute to change their
color. To work around that, use the icon as a mask and fill the
background with the desired color. In the future it would make sense
to ship our own custom font with those icons to circumvent those
issues completely.

Signed-off-by: Stefan Hanreich <s.hanreich@proxmox.com>
---
 ui/Makefile            |  1 +
 ui/css/pdm.scss        | 27 ++++++++++++++--
 ui/images/icon-sdn.svg | 70 ++++++++++++++++++++++++++++++++++++++++++
 3 files changed, 95 insertions(+), 3 deletions(-)
 create mode 100644 ui/images/icon-sdn.svg

diff --git a/ui/Makefile b/ui/Makefile
index 7d2e654..61e5c77 100644
--- a/ui/Makefile
+++ b/ui/Makefile
@@ -60,6 +60,7 @@ install: $(COMPILED_OUTPUT) index.hbs
 	install -m0644 images/icon-cpu.svg $(DESTDIR)$(UIDIR)/images
 	install -m0644 images/icon-memory.svg $(DESTDIR)$(UIDIR)/images
 	install -m0644 images/icon-sdn-vnet.svg $(DESTDIR)$(UIDIR)/images
+	install -m0644 images/icon-sdn.svg $(DESTDIR)$(UIDIR)/images
 	install -m0644 images/proxmox_logo.svg $(DESTDIR)$(UIDIR)/images
 	install -m0644 images/proxmox_logo_white.svg $(DESTDIR)$(UIDIR)/images
 
diff --git a/ui/css/pdm.scss b/ui/css/pdm.scss
index 6d87b1c..63095c3 100644
--- a/ui/css/pdm.scss
+++ b/ui/css/pdm.scss
@@ -59,15 +59,36 @@
 
 .fa-sdn-vnet::before {
     content: " ";
-    background-image: url(./images/icon-sdn-vnet.svg);
-    background-size: 16px 16px;
-    background-repeat: no-repeat;
+    mask-image: url(./images/icon-sdn-vnet.svg);
+    mask-size: 16px 16px;
+    mask-repeat: no-repeat;
+    background-color: var(--pwt-color);
     width: 16px;
     height: 16px;
     vertical-align: bottom;
     display: inline-block;
 }
 
+.fa-sdn:before {
+    content: " ";
+    mask-image: url(../images/icon-sdn.svg);
+    mask-size: 16px 16px;
+    mask-repeat: no-repeat;
+    background-color: var(--pwt-color);
+    width: 16px;
+    height: 16px;
+    vertical-align: middle;
+    display: inline-block;
+}
+
+.pwt-nav-menu .pwt-nav-link.active .fa-sdn:before {
+    background-color: var(--pwt-accent-color);
+}
+
+.pwt-panel-header-text .fa-sdn:before {
+    background-color: var(--pwt-accent-color-background);
+}
+
 :root.pwt-dark-mode {
     .fa-memory,
     .fa-cpu,
diff --git a/ui/images/icon-sdn.svg b/ui/images/icon-sdn.svg
new file mode 100644
index 0000000..9782e8b
--- /dev/null
+++ b/ui/images/icon-sdn.svg
@@ -0,0 +1,70 @@
+<?xml version="1.0" encoding="UTF-8" standalone="no"?>
+<!DOCTYPE svg PUBLIC "-//W3C//DTD SVG 1.1//EN"
+ "http://www.w3.org/Graphics/SVG/1.1/DTD/svg11.dtd">
+<!-- Generated by graphviz version 2.40.1 (20161225.0304)
+ -->
+<!-- Title: sdn Pages: 1 -->
+<svg width="142pt" height="140pt"
+ viewBox="0.00 0.00 142.00 140.00" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink">
+    <style type="text/css">
+        .filled { fill: #000; stroke: #000; }
+    </style>
+<g id="graph0" class="graph" transform="scale(1 1) rotate(0) translate(4 136)">
+<title>sdn</title>
+<!-- a -->
+<g id="node1" class="filled">
+<title>a</title>
+<ellipse cx="64.9158" cy="-69.1316" rx="18" ry="18"/>
+</g>
+<!-- b -->
+<g id="node2" class="filled">
+<title>b</title>
+<ellipse cx="116.0453" cy="-37.947" rx="18" ry="18"/>
+</g>
+<!-- a&#45;&#45;b -->
+<g id="edge1" class="filled">
+<title>a&#45;&#45;b</title>
+<path stroke-width="6" d="M80.5342,-59.6057C86.8776,-55.7368 94.189,-51.2775 100.5226,-47.4145"/>
+</g>
+<!-- d -->
+<g id="node3" class="filled">
+<title>d</title>
+<ellipse cx="65.7782" cy="-17.6758" rx="18" ry="18"/>
+</g>
+<!-- a&#45;&#45;d -->
+<g id="edge5" class="filled">
+<title>a&#45;&#45;d</title>
+<path stroke-width="6" d="M65.2223,-50.8453C65.3036,-45.9924 65.3914,-40.753 65.4727,-35.9032"/>
+</g>
+<!-- c -->
+<g id="node4" class="filled">
+<title>c</title>
+<ellipse cx="17.954" cy="-50.8477" rx="18" ry="18"/>
+</g>
+<!-- a&#45;&#45;c -->
+<g id="edge3" class="filled">
+<title>a&#45;&#45;c</title>
+<path stroke-width="6" d="M47.9615,-62.5307C43.7579,-60.8941 39.2511,-59.1394 35.0411,-57.5003"/>
+</g>
+<!-- e -->
+<g id="node5" class="filled">
+<title>e</title>
+<ellipse cx="101.4409" cy="-114.0579" rx="18" ry="18"/>
+</g>
+<!-- a&#45;&#45;e -->
+<g id="edge6" class="filled">
+<title>a&#45;&#45;e</title>
+<path stroke-width="6" d="M76.4726,-83.3466C80.8198,-88.6936 85.7556,-94.7648 90.0844,-100.0892"/>
+</g>
+<!-- b&#45;&#45;d -->
+<g id="edge2" class="filled">
+<title>b&#45;&#45;d</title>
+<path stroke-width="6" d="M99.3071,-31.197C93.9378,-29.0318 87.991,-26.6336 82.6147,-24.4655"/>
+</g>
+<!-- c&#45;&#45;d -->
+<g id="edge4" class="filled">
+<title>c&#45;&#45;d</title>
+<path stroke-width="6" d="M32.8237,-40.5338C38.527,-36.5778 45.0352,-32.0636 50.7529,-28.0977"/>
+</g>
+</g>
+</svg>
-- 
2.47.3


_______________________________________________
pdm-devel mailing list
pdm-devel@lists.proxmox.com
https://lists.proxmox.com/cgi-bin/mailman/listinfo/pdm-devel


^ permalink raw reply	[flat|nested] 18+ messages in thread

* [pdm-devel] [PATCH proxmox-datacenter-manager 5/5] ui: sdn: add zone tree
  2025-09-09 10:08 [pdm-devel] [PATCH manager/proxmox-datacenter-manager 0/6] Add SDN resources to dashboard + SDN zone overview tree Stefan Hanreich
                   ` (4 preceding siblings ...)
  2025-09-09 10:08 ` [pdm-devel] [PATCH proxmox-datacenter-manager 4/5] ui: images: add sdn icon Stefan Hanreich
@ 2025-09-09 10:08 ` Stefan Hanreich
  2025-09-09 13:41   ` Dominik Csapak
  2025-09-09 13:43 ` [pdm-devel] [PATCH manager/proxmox-datacenter-manager 0/6] Add SDN resources to dashboard + SDN zone overview tree Dominik Csapak
  6 siblings, 1 reply; 18+ messages in thread
From: Stefan Hanreich @ 2025-09-09 10:08 UTC (permalink / raw)
  To: pdm-devel

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 | 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<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 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()
+                })
+                .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
+    }
+
+    #[allow(unused_variables)]
+    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


^ permalink raw reply	[flat|nested] 18+ messages in thread

* Re: [pdm-devel] [PATCH proxmox-datacenter-manager 1/5] pdm-api-types: add sdn cluster resource
  2025-09-09 10:08 ` [pdm-devel] [PATCH proxmox-datacenter-manager 1/5] pdm-api-types: add sdn cluster resource Stefan Hanreich
@ 2025-09-09 11:13   ` Stefan Hanreich
  2025-09-09 11:24     ` Thomas Lamprecht
  0 siblings, 1 reply; 18+ messages in thread
From: Stefan Hanreich @ 2025-09-09 11:13 UTC (permalink / raw)
  To: pdm-devel

On 9/9/25 12:08 PM, Stefan Hanreich wrote:

[snip]

> diff --git a/lib/proxmox-api-types b/lib/proxmox-api-types
> index cdb4dcf..973ed53 160000
> --- a/lib/proxmox-api-types
> +++ b/lib/proxmox-api-types
> @@ -1 +1 @@
> -Subproject commit cdb4dcfe0791c7960ccd0ead36ec2381c6e8e0be
> +Subproject commit 973ed53cd737f6b76351e66b061635678f104098

seems like this got added during my rebase, I can send a v2 that fixes
this if nothing else comes up

[snip]


_______________________________________________
pdm-devel mailing list
pdm-devel@lists.proxmox.com
https://lists.proxmox.com/cgi-bin/mailman/listinfo/pdm-devel


^ permalink raw reply	[flat|nested] 18+ messages in thread

* Re: [pdm-devel] [PATCH proxmox-datacenter-manager 1/5] pdm-api-types: add sdn cluster resource
  2025-09-09 11:13   ` Stefan Hanreich
@ 2025-09-09 11:24     ` Thomas Lamprecht
  2025-09-09 11:26       ` Stefan Hanreich
  0 siblings, 1 reply; 18+ messages in thread
From: Thomas Lamprecht @ 2025-09-09 11:24 UTC (permalink / raw)
  To: Proxmox Datacenter Manager development discussion, Stefan Hanreich

Am 09.09.25 um 13:12 schrieb Stefan Hanreich:
> On 9/9/25 12:08 PM, Stefan Hanreich wrote:
> 
> [snip]
> 
>> diff --git a/lib/proxmox-api-types b/lib/proxmox-api-types
>> index cdb4dcf..973ed53 160000
>> --- a/lib/proxmox-api-types
>> +++ b/lib/proxmox-api-types
>> @@ -1 +1 @@
>> -Subproject commit cdb4dcfe0791c7960ccd0ead36ec2381c6e8e0be
>> +Subproject commit 973ed53cd737f6b76351e66b061635678f104098
> seems like this got added during my rebase, I can send a v2 that fixes
> this if nothing else comes up

And the fix would be just dropping this change, I figure?


_______________________________________________
pdm-devel mailing list
pdm-devel@lists.proxmox.com
https://lists.proxmox.com/cgi-bin/mailman/listinfo/pdm-devel


^ permalink raw reply	[flat|nested] 18+ messages in thread

* Re: [pdm-devel] [PATCH proxmox-datacenter-manager 1/5] pdm-api-types: add sdn cluster resource
  2025-09-09 11:24     ` Thomas Lamprecht
@ 2025-09-09 11:26       ` Stefan Hanreich
  0 siblings, 0 replies; 18+ messages in thread
From: Stefan Hanreich @ 2025-09-09 11:26 UTC (permalink / raw)
  To: Thomas Lamprecht, Proxmox Datacenter Manager development discussion


On 9/9/25 1:23 PM, Thomas Lamprecht wrote:
> Am 09.09.25 um 13:12 schrieb Stefan Hanreich:
>> On 9/9/25 12:08 PM, Stefan Hanreich wrote:
>>
>> [snip]
>>
>>> diff --git a/lib/proxmox-api-types b/lib/proxmox-api-types
>>> index cdb4dcf..973ed53 160000
>>> --- a/lib/proxmox-api-types
>>> +++ b/lib/proxmox-api-types
>>> @@ -1 +1 @@
>>> -Subproject commit cdb4dcfe0791c7960ccd0ead36ec2381c6e8e0be
>>> +Subproject commit 973ed53cd737f6b76351e66b061635678f104098
>> seems like this got added during my rebase, I can send a v2 that fixes
>> this if nothing else comes up
> 
> And the fix would be just dropping this change, I figure?

Yes, at least current state on my upstream branch is cdb4dcf


_______________________________________________
pdm-devel mailing list
pdm-devel@lists.proxmox.com
https://lists.proxmox.com/cgi-bin/mailman/listinfo/pdm-devel


^ permalink raw reply	[flat|nested] 18+ messages in thread

* Re: [pdm-devel] [PATCH proxmox-datacenter-manager 3/5] ui: add sdn status report to dashboard
  2025-09-09 10:08 ` [pdm-devel] [PATCH proxmox-datacenter-manager 3/5] ui: add sdn status report to dashboard Stefan Hanreich
@ 2025-09-09 13:10   ` Dominik Csapak
  2025-09-09 13:22     ` Stefan Hanreich
  0 siblings, 1 reply; 18+ messages in thread
From: Dominik Csapak @ 2025-09-09 13:10 UTC (permalink / raw)
  To: Proxmox Datacenter Manager development discussion, Stefan Hanreich

two comments inline

On 9/9/25 12:08 PM, Stefan Hanreich wrote:
> This also includes support for external links, searching and
> navigating to the dedicated SDN overview. For now, add it to the task
> summary row, since there are issues with only showing one element in a
> row due to the CSS rules. While this breaks a little bit with the
> current grouping, the widget is quite small so it would look weird in
> a single row and we can always decide to move it around as soon as we
> add more elements to the dashboard.
> 
> Signed-off-by: Stefan Hanreich <s.hanreich@proxmox.com>
> ---
>   ui/src/dashboard/mod.rs            |  17 +++-
>   ui/src/dashboard/sdn_zone_panel.rs | 155 +++++++++++++++++++++++++++++
>   ui/src/lib.rs                      |  14 ++-
>   ui/src/pve/utils.rs                |  16 ++-
>   ui/src/renderer.rs                 |   4 +-
>   5 files changed, 199 insertions(+), 7 deletions(-)
>   create mode 100644 ui/src/dashboard/sdn_zone_panel.rs
> 
> diff --git a/ui/src/dashboard/mod.rs b/ui/src/dashboard/mod.rs
> index 0659dc0..626a6bf 100644
> --- a/ui/src/dashboard/mod.rs
> +++ b/ui/src/dashboard/mod.rs
> @@ -25,7 +25,7 @@ use pwt::{
>   };
>   
>   use pdm_api_types::{
> -    resource::{GuestStatusCount, NodeStatusCount, ResourcesStatus},
> +    resource::{GuestStatusCount, NodeStatusCount, ResourcesStatus, SdnZoneCount},
>       TaskStatistics,
>   };
>   use pdm_client::types::TopEntity;
> @@ -46,6 +46,9 @@ use remote_panel::RemotePanel;
>   mod guest_panel;
>   use guest_panel::GuestPanel;
>   
> +mod sdn_zone_panel;
> +use sdn_zone_panel::SdnZonePanel;
> +
>   mod status_row;
>   use status_row::DashboardStatusRow;
>   
> @@ -242,6 +245,15 @@ impl PdmDashboard {
>               ))
>       }
>   
> +    fn create_sdn_panel(&self, status: &SdnZoneCount) -> Panel {
> +        Panel::new()
> +            .flex(1.0)
> +            .width(200)
> +            .title(self.create_title_with_icon("sdn", tr!("SDN Zones")))
> +            .border(true)
> +            .with_child(SdnZonePanel::new((!self.loading).then_some(status.clone())))
> +    }
> +
>       fn create_task_summary_panel(
>           &self,
>           statistics: &StatisticsOptions,
> @@ -620,7 +632,8 @@ impl Component for PdmDashboard {
>                       .class(pwt::css::Flex::Fill)
>                       .class(FlexWrap::Wrap)
>                       .with_child(self.create_task_summary_panel(&self.statistics, None))
> -                    .with_child(self.create_task_summary_panel(&self.statistics, Some(5))),
> +                    .with_child(self.create_task_summary_panel(&self.statistics, Some(5)))
> +                    .with_child(self.create_sdn_panel(&self.status.sdn_zones)),
>               );
>   
>           Panel::new()
> diff --git a/ui/src/dashboard/sdn_zone_panel.rs b/ui/src/dashboard/sdn_zone_panel.rs
> new file mode 100644
> index 0000000..bcac36b
> --- /dev/null
> +++ b/ui/src/dashboard/sdn_zone_panel.rs
> @@ -0,0 +1,155 @@
> +use std::rc::Rc;
> +
> +use pdm_api_types::resource::{ResourceType, SdnStatus, SdnZoneCount};
> +use pdm_search::{Search, SearchTerm};
> +use pwt::{
> +    css::{self, FontColor, TextAlign},
> +    prelude::*,
> +    widget::{Container, Fa, List, ListTile},
> +};
> +use yew::{
> +    virtual_dom::{VComp, VNode},
> +    Properties,
> +};
> +
> +use crate::search_provider::get_search_provider;
> +
> +use super::loading_column;
> +
> +#[derive(PartialEq, Clone, Properties)]
> +pub struct SdnZonePanel {
> +    status: Option<SdnZoneCount>,
> +}
> +
> +impl SdnZonePanel {
> +    pub fn new(status: Option<SdnZoneCount>) -> Self {
> +        yew::props!(Self { status })
> +    }
> +}
> +
> +impl From<SdnZonePanel> for VNode {
> +    fn from(value: SdnZonePanel) -> Self {
> +        let comp = VComp::new::<SdnZonePanelComponent>(Rc::new(value), None);
> +        VNode::from(comp)
> +    }
> +}
> +
> +#[derive(PartialEq, Clone)]
> +pub enum StatusRow {
> +    State(SdnStatus, u64),
> +    All(u64),
> +}
> +
> +impl StatusRow {
> +    fn icon(&self) -> Fa {
> +        let (icon, color) = match self {
> +            Self::All(_) => ("th", None),
> +            Self::State(SdnStatus::Available, _) => ("check", Some(FontColor::Success)),
> +            Self::State(SdnStatus::Error, _) => ("times-circle", Some(FontColor::Error)),
> +            Self::State(SdnStatus::Unknown, _) => ("question", None),
> +        };
> +
> +        let mut icon = Fa::new(icon);
> +
> +        if let Some(color) = color {
> +            icon = icon.class(color);
> +        }
> +
> +        icon
> +    }
> +}
> +
> +pub struct SdnZonePanelComponent {}
> +
> +impl yew::Component for SdnZonePanelComponent {
> +    type Message = Search;
> +    type Properties = SdnZonePanel;
> +
> +    fn create(_ctx: &yew::Context<Self>) -> Self {
> +        Self {}
> +    }
> +
> +    fn update(&mut self, ctx: &Context<Self>, msg: Self::Message) -> bool {
> +        if let Some(provider) = get_search_provider(ctx) {
> +            provider.search(msg);
> +        }
> +
> +        false
> +    }
> +
> +    fn view(&self, ctx: &yew::Context<Self>) -> yew::Html {
> +        let props = ctx.props();
> +
> +        let Some(status) = &props.status else {
> +            return loading_column().into();
> +        };
> +
> +        let data = vec![
> +            StatusRow::State(SdnStatus::Available, status.available),
> +            StatusRow::State(SdnStatus::Error, status.error),
> +            StatusRow::All(status.available + status.error + status.unknown),
> +        ];
> +
> +        let tiles: Vec<_> = data
> +            .into_iter()
> +            .filter_map(|row| create_list_tile(ctx.link(), row))
> +            .collect();
> +
> +        let list = List::new(tiles.len() as u64, move |idx: u64| {
> +            tiles[idx as usize].clone()
> +        })
> +        .padding(4)
> +        .class(css::Flex::Fill)
> +        .grid_template_columns("auto auto 1fr auto");
> +
> +        list.into()
> +    }
> +}
> +
> +fn create_list_tile(
> +    link: &html::Scope<SdnZonePanelComponent>,
> +    status_row: StatusRow,
> +) -> Option<ListTile> {
> +    let (icon, status, count) = match status_row {
> +        StatusRow::State(sdn_status, count) => (status_row.icon(), Some(sdn_status), count),
> +        StatusRow::All(count) => (status_row.icon(), None, count),
> +    };
> +
> +    let name = status
> +        .map(|status| status.to_string())
> +        .unwrap_or_else(|| "All".to_string());
> +
> +    Some(
> +        ListTile::new()
> +            .tabindex(0)
> +            .interactive(true)
> +            .with_child(icon)
> +            .with_child(Container::new().padding_x(2).with_child(name))
> +            .with_child(
> +                Container::new()
> +                    .class(TextAlign::Right)
> +                    .padding_end(2)
> +                    .with_child(count),
> +            )
> +            .with_child(Fa::new("search"))
> +            .onclick(link.callback(move |_| create_sdn_zone_search_term(status)))
> +            .onkeydown(link.batch_callback(
> +                move |event: KeyboardEvent| match event.key().as_str() {
> +                    "Enter" | " " => Some(create_sdn_zone_search_term(status)),
> +                    _ => None,
> +                },
> +            )),
> +    )
> +}
> +
> +fn create_sdn_zone_search_term(status: Option<SdnStatus>) -> Search {
> +    let resource_type: ResourceType = ResourceType::PveSdnZone;
> +
> +    let mut terms = vec![SearchTerm::new(resource_type.as_str()).category(Some("type"))];
> +
> +    if let Some(status) = status {
> +        terms.push(SearchTerm::new(status.to_string()).category(Some("status")));
> +    }
> +
> +    Search::with_terms(terms)
> +}
> diff --git a/ui/src/lib.rs b/ui/src/lib.rs
> index 5ffbff3..e4bfbb7 100644
> --- a/ui/src/lib.rs
> +++ b/ui/src/lib.rs
> @@ -1,4 +1,4 @@
> -use pdm_api_types::resource::{PveLxcResource, PveQemuResource};
> +use pdm_api_types::resource::{PveLxcResource, PveQemuResource, PveSdnResource};
>   use pdm_client::types::Resource;
>   use serde::{Deserialize, Serialize};
>   
> @@ -151,13 +151,21 @@ pub(crate) fn navigate_to<C: yew::Component>(
>                       pdm_client::types::Resource::PveStorage(storage) => {
>                           format!("storage+{}+{}", storage.node, storage.storage)
>                       }
> +                    pdm_client::types::Resource::PveSdn(PveSdnResource::Zone(_)) => {
> +                        "sdn/zones".to_string()
> +                    }
>                       pdm_client::types::Resource::PbsDatastore(store) => store.name.clone(),
>                       // FIXME: implement
>                       _ => return None,
>                   })
>               })
>               .unwrap_or_default();
> -        nav.push(&yew_router::AnyRoute::new(format!("/remote-{remote}/{id}")));
> +
> +        if let Some(pdm_client::types::Resource::PveSdn(_)) = resource {
> +            nav.push(&yew_router::AnyRoute::new(format!("/{id}")));
> +        } else {
> +            nav.push(&yew_router::AnyRoute::new(format!("/remote-{remote}/{id}")));
> +        }

i don't really like the special casing of sdn here. I think it would be 
better, e.g. if the match would return a tuple of the (optional) remote 
and the id

>       }
>   }
>   
> @@ -167,7 +175,7 @@ pub(crate) fn get_resource_node(resource: &Resource) -> Option<&str> {
>           Resource::PveQemu(qemu) => Some(&qemu.node),
>           Resource::PveLxc(lxc) => Some(&lxc.node),
>           Resource::PveNode(node) => Some(&node.node),
> -        Resource::PveSdn(sdn) => Some(&sdn.sdn),
> +        Resource::PveSdn(sdn) => Some(sdn.node()),
>           Resource::PbsNode(_) => None,
>           Resource::PbsDatastore(_) => None,
>       }
> diff --git a/ui/src/pve/utils.rs b/ui/src/pve/utils.rs
> index 7663734..a49205d 100644
> --- a/ui/src/pve/utils.rs
> +++ b/ui/src/pve/utils.rs
> @@ -1,6 +1,7 @@
>   use anyhow::Error;
>   use pdm_api_types::resource::{
> -    PveLxcResource, PveNodeResource, PveQemuResource, PveStorageResource,
> +    PveLxcResource, PveNodeResource, PveQemuResource, PveStorageResource, SdnStatus,
> +    SdnZoneResource,
>   };
>   use pdm_client::types::{
>       LxcConfig, LxcConfigMp, LxcConfigRootfs, LxcConfigUnused, PveQmIde, QemuConfig, QemuConfigSata,
> @@ -88,6 +89,19 @@ pub fn render_node_status_icon(node: &PveNodeResource) -> Container {
>           .with_child(Fa::from(extra).fixed_width().class("status-icon"))
>   }
>   
> +/// Renders the status icon for a PveNode

it's not for a PveNode ;)

> +pub fn render_sdn_status_icon(zone: &SdnZoneResource) -> Container {
> +    let extra = match zone.status {
> +        SdnStatus::Available => NodeState::Online,
> +        SdnStatus::Error => NodeState::Offline,
> +        _ => NodeState::Unknown,
> +    };
> +    Container::new()
> +        .class("pdm-type-icon")
> +        .with_child(Fa::new("th").fixed_width())
> +        .with_child(Fa::from(extra).fixed_width().class("status-icon"))
> +}
> +
>   /// Renders the status icon for a PveStorage
>   pub fn render_storage_status_icon(node: &PveStorageResource) -> Container {
>       let extra = match node.status.as_str() {
> diff --git a/ui/src/renderer.rs b/ui/src/renderer.rs
> index 5ebd9a3..e179cd5 100644
> --- a/ui/src/renderer.rs
> +++ b/ui/src/renderer.rs
> @@ -1,3 +1,4 @@
> +use pdm_api_types::resource::PveSdnResource;
>   use pwt::{
>       css,
>       prelude::*,
> @@ -17,7 +18,7 @@ pub fn render_resource_name(resource: &Resource, vmid_first: bool) -> String {
>           Resource::PveQemu(qemu) => pve::utils::render_qemu_name(qemu, vmid_first),
>           Resource::PveLxc(lxc) => pve::utils::render_lxc_name(lxc, vmid_first),
>           Resource::PveNode(node) => node.node.clone(),
> -        Resource::PveSdn(sdn) => sdn.sdn.clone(),
> +        Resource::PveSdn(sdn) => sdn.name().to_string(),
>           Resource::PbsNode(node) => node.name.clone(),
>           Resource::PbsDatastore(store) => store.name.clone(),
>       }
> @@ -43,6 +44,7 @@ pub fn render_status_icon(resource: &Resource) -> Container {
>           Resource::PveQemu(qemu) => pve::utils::render_qemu_status_icon(qemu),
>           Resource::PveLxc(lxc) => pve::utils::render_lxc_status_icon(lxc),
>           Resource::PveNode(node) => pve::utils::render_node_status_icon(node),
> +        Resource::PveSdn(PveSdnResource::Zone(zone)) => pve::utils::render_sdn_status_icon(zone),
>           // FIXME: implement remaining types
>           _ => Container::new().with_child(render_resource_icon(resource)),
>       }



_______________________________________________
pdm-devel mailing list
pdm-devel@lists.proxmox.com
https://lists.proxmox.com/cgi-bin/mailman/listinfo/pdm-devel


^ permalink raw reply	[flat|nested] 18+ messages in thread

* Re: [pdm-devel] [PATCH proxmox-datacenter-manager 4/5] ui: images: add sdn icon
  2025-09-09 10:08 ` [pdm-devel] [PATCH proxmox-datacenter-manager 4/5] ui: images: add sdn icon Stefan Hanreich
@ 2025-09-09 13:16   ` Dominik Csapak
  2025-09-09 13:21     ` Stefan Hanreich
  0 siblings, 1 reply; 18+ messages in thread
From: Dominik Csapak @ 2025-09-09 13:16 UTC (permalink / raw)
  To: Proxmox Datacenter Manager development discussion, Stefan Hanreich

while this is good enough for now imho

we should really think about creating our own font with the missing
icons, or think about changing to an iconset that includes the ones we
need/want. The approach here fixes the color issue, but we still
have a scaling problem, as in, the svg does not scale with the
set font-size, like the rest of the font-awesome icons.

This is ok for the uses we have now, but might not always work out
(e.g. the font-size in panel headers is 20px for desktop and
14px for crisp, so the 16px here sit somewhere in between, so it
does not stand out that much)

On 9/9/25 12:08 PM, Stefan Hanreich wrote:
> Add the icon used for SDN in Proxmox VE manually, since it isn't
> included in our distribution of font awesome. SVG icons have the issue
> that their color cannot be simply changed via the CSS color
> attribute. This is problematic when showing an icon in the navigation
> or on the dashboard, where we use the color attribute to change their
> color. To work around that, use the icon as a mask and fill the
> background with the desired color. In the future it would make sense
> to ship our own custom font with those icons to circumvent those
> issues completely.
> 
> Signed-off-by: Stefan Hanreich <s.hanreich@proxmox.com>
> ---
>   ui/Makefile            |  1 +
>   ui/css/pdm.scss        | 27 ++++++++++++++--
>   ui/images/icon-sdn.svg | 70 ++++++++++++++++++++++++++++++++++++++++++
>   3 files changed, 95 insertions(+), 3 deletions(-)
>   create mode 100644 ui/images/icon-sdn.svg
> 
> diff --git a/ui/Makefile b/ui/Makefile
> index 7d2e654..61e5c77 100644
> --- a/ui/Makefile
> +++ b/ui/Makefile
> @@ -60,6 +60,7 @@ install: $(COMPILED_OUTPUT) index.hbs
>   	install -m0644 images/icon-cpu.svg $(DESTDIR)$(UIDIR)/images
>   	install -m0644 images/icon-memory.svg $(DESTDIR)$(UIDIR)/images
>   	install -m0644 images/icon-sdn-vnet.svg $(DESTDIR)$(UIDIR)/images
> +	install -m0644 images/icon-sdn.svg $(DESTDIR)$(UIDIR)/images
>   	install -m0644 images/proxmox_logo.svg $(DESTDIR)$(UIDIR)/images
>   	install -m0644 images/proxmox_logo_white.svg $(DESTDIR)$(UIDIR)/images
>   
> diff --git a/ui/css/pdm.scss b/ui/css/pdm.scss
> index 6d87b1c..63095c3 100644
> --- a/ui/css/pdm.scss
> +++ b/ui/css/pdm.scss
> @@ -59,15 +59,36 @@
>   
>   .fa-sdn-vnet::before {
>       content: " ";
> -    background-image: url(./images/icon-sdn-vnet.svg);
> -    background-size: 16px 16px;
> -    background-repeat: no-repeat;
> +    mask-image: url(./images/icon-sdn-vnet.svg);
> +    mask-size: 16px 16px;
> +    mask-repeat: no-repeat;
> +    background-color: var(--pwt-color);
>       width: 16px;
>       height: 16px;
>       vertical-align: bottom;
>       display: inline-block;
>   }
>   
> +.fa-sdn:before {
> +    content: " ";
> +    mask-image: url(../images/icon-sdn.svg);
> +    mask-size: 16px 16px;
> +    mask-repeat: no-repeat;
> +    background-color: var(--pwt-color);
> +    width: 16px;
> +    height: 16px;
> +    vertical-align: middle;
> +    display: inline-block;
> +}
> +
> +.pwt-nav-menu .pwt-nav-link.active .fa-sdn:before {
> +    background-color: var(--pwt-accent-color);
> +}
> +
> +.pwt-panel-header-text .fa-sdn:before {
> +    background-color: var(--pwt-accent-color-background);
> +}
> +
>   :root.pwt-dark-mode {
>       .fa-memory,
>       .fa-cpu,
> diff --git a/ui/images/icon-sdn.svg b/ui/images/icon-sdn.svg
> new file mode 100644
> index 0000000..9782e8b
> --- /dev/null
> +++ b/ui/images/icon-sdn.svg
> @@ -0,0 +1,70 @@
> +<?xml version="1.0" encoding="UTF-8" standalone="no"?>
> +<!DOCTYPE svg PUBLIC "-//W3C//DTD SVG 1.1//EN"
> + "http://www.w3.org/Graphics/SVG/1.1/DTD/svg11.dtd">
> +<!-- Generated by graphviz version 2.40.1 (20161225.0304)
> + -->
> +<!-- Title: sdn Pages: 1 -->
> +<svg width="142pt" height="140pt"
> + viewBox="0.00 0.00 142.00 140.00" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink">
> +    <style type="text/css">
> +        .filled { fill: #000; stroke: #000; }
> +    </style>
> +<g id="graph0" class="graph" transform="scale(1 1) rotate(0) translate(4 136)">
> +<title>sdn</title>
> +<!-- a -->
> +<g id="node1" class="filled">
> +<title>a</title>
> +<ellipse cx="64.9158" cy="-69.1316" rx="18" ry="18"/>
> +</g>
> +<!-- b -->
> +<g id="node2" class="filled">
> +<title>b</title>
> +<ellipse cx="116.0453" cy="-37.947" rx="18" ry="18"/>
> +</g>
> +<!-- a&#45;&#45;b -->
> +<g id="edge1" class="filled">
> +<title>a&#45;&#45;b</title>
> +<path stroke-width="6" d="M80.5342,-59.6057C86.8776,-55.7368 94.189,-51.2775 100.5226,-47.4145"/>
> +</g>
> +<!-- d -->
> +<g id="node3" class="filled">
> +<title>d</title>
> +<ellipse cx="65.7782" cy="-17.6758" rx="18" ry="18"/>
> +</g>
> +<!-- a&#45;&#45;d -->
> +<g id="edge5" class="filled">
> +<title>a&#45;&#45;d</title>
> +<path stroke-width="6" d="M65.2223,-50.8453C65.3036,-45.9924 65.3914,-40.753 65.4727,-35.9032"/>
> +</g>
> +<!-- c -->
> +<g id="node4" class="filled">
> +<title>c</title>
> +<ellipse cx="17.954" cy="-50.8477" rx="18" ry="18"/>
> +</g>
> +<!-- a&#45;&#45;c -->
> +<g id="edge3" class="filled">
> +<title>a&#45;&#45;c</title>
> +<path stroke-width="6" d="M47.9615,-62.5307C43.7579,-60.8941 39.2511,-59.1394 35.0411,-57.5003"/>
> +</g>
> +<!-- e -->
> +<g id="node5" class="filled">
> +<title>e</title>
> +<ellipse cx="101.4409" cy="-114.0579" rx="18" ry="18"/>
> +</g>
> +<!-- a&#45;&#45;e -->
> +<g id="edge6" class="filled">
> +<title>a&#45;&#45;e</title>
> +<path stroke-width="6" d="M76.4726,-83.3466C80.8198,-88.6936 85.7556,-94.7648 90.0844,-100.0892"/>
> +</g>
> +<!-- b&#45;&#45;d -->
> +<g id="edge2" class="filled">
> +<title>b&#45;&#45;d</title>
> +<path stroke-width="6" d="M99.3071,-31.197C93.9378,-29.0318 87.991,-26.6336 82.6147,-24.4655"/>
> +</g>
> +<!-- c&#45;&#45;d -->
> +<g id="edge4" class="filled">
> +<title>c&#45;&#45;d</title>
> +<path stroke-width="6" d="M32.8237,-40.5338C38.527,-36.5778 45.0352,-32.0636 50.7529,-28.0977"/>
> +</g>
> +</g>
> +</svg>



_______________________________________________
pdm-devel mailing list
pdm-devel@lists.proxmox.com
https://lists.proxmox.com/cgi-bin/mailman/listinfo/pdm-devel


^ permalink raw reply	[flat|nested] 18+ messages in thread

* Re: [pdm-devel] [PATCH proxmox-datacenter-manager 4/5] ui: images: add sdn icon
  2025-09-09 13:16   ` Dominik Csapak
@ 2025-09-09 13:21     ` Stefan Hanreich
  0 siblings, 0 replies; 18+ messages in thread
From: Stefan Hanreich @ 2025-09-09 13:21 UTC (permalink / raw)
  To: Dominik Csapak, Proxmox Datacenter Manager development discussion

On 9/9/25 3:16 PM, Dominik Csapak wrote:
> while this is good enough for now imho
> 
> we should really think about creating our own font with the missing
> icons, or think about changing to an iconset that includes the ones we
> need/want. The approach here fixes the color issue, but we still
> have a scaling problem, as in, the svg does not scale with the
> set font-size, like the rest of the font-awesome icons.
> 
> This is ok for the uses we have now, but might not always work out
> (e.g. the font-size in panel headers is 20px for desktop and
> 14px for crisp, so the 16px here sit somewhere in between, so it
> does not stand out that much)

Yeah, I think so as well - I stated it in the commit message for that
purpose. Didn't even think of the size not applying as well tbh - that
basically extends to every property that affects text I suppose
(line-height comes to mind as potentially problematic as well for
instance). Might want to look into that sooner rather than later...


_______________________________________________
pdm-devel mailing list
pdm-devel@lists.proxmox.com
https://lists.proxmox.com/cgi-bin/mailman/listinfo/pdm-devel


^ permalink raw reply	[flat|nested] 18+ messages in thread

* Re: [pdm-devel] [PATCH proxmox-datacenter-manager 3/5] ui: add sdn status report to dashboard
  2025-09-09 13:10   ` Dominik Csapak
@ 2025-09-09 13:22     ` Stefan Hanreich
  0 siblings, 0 replies; 18+ messages in thread
From: Stefan Hanreich @ 2025-09-09 13:22 UTC (permalink / raw)
  To: Dominik Csapak, Proxmox Datacenter Manager development discussion

thanks! will address both in a new version

On 9/9/25 3:10 PM, Dominik Csapak wrote:
> two comments inline
> 
> On 9/9/25 12:08 PM, Stefan Hanreich wrote:
>> This also includes support for external links, searching and
>> navigating to the dedicated SDN overview. For now, add it to the task
>> summary row, since there are issues with only showing one element in a
>> row due to the CSS rules. While this breaks a little bit with the
>> current grouping, the widget is quite small so it would look weird in
>> a single row and we can always decide to move it around as soon as we
>> add more elements to the dashboard.
>>
>> Signed-off-by: Stefan Hanreich <s.hanreich@proxmox.com>
>> ---
>>   ui/src/dashboard/mod.rs            |  17 +++-
>>   ui/src/dashboard/sdn_zone_panel.rs | 155 +++++++++++++++++++++++++++++
>>   ui/src/lib.rs                      |  14 ++-
>>   ui/src/pve/utils.rs                |  16 ++-
>>   ui/src/renderer.rs                 |   4 +-
>>   5 files changed, 199 insertions(+), 7 deletions(-)
>>   create mode 100644 ui/src/dashboard/sdn_zone_panel.rs
>>
>> diff --git a/ui/src/dashboard/mod.rs b/ui/src/dashboard/mod.rs
>> index 0659dc0..626a6bf 100644
>> --- a/ui/src/dashboard/mod.rs
>> +++ b/ui/src/dashboard/mod.rs
>> @@ -25,7 +25,7 @@ use pwt::{
>>   };
>>     use pdm_api_types::{
>> -    resource::{GuestStatusCount, NodeStatusCount, ResourcesStatus},
>> +    resource::{GuestStatusCount, NodeStatusCount, ResourcesStatus,
>> SdnZoneCount},
>>       TaskStatistics,
>>   };
>>   use pdm_client::types::TopEntity;
>> @@ -46,6 +46,9 @@ use remote_panel::RemotePanel;
>>   mod guest_panel;
>>   use guest_panel::GuestPanel;
>>   +mod sdn_zone_panel;
>> +use sdn_zone_panel::SdnZonePanel;
>> +
>>   mod status_row;
>>   use status_row::DashboardStatusRow;
>>   @@ -242,6 +245,15 @@ impl PdmDashboard {
>>               ))
>>       }
>>   +    fn create_sdn_panel(&self, status: &SdnZoneCount) -> Panel {
>> +        Panel::new()
>> +            .flex(1.0)
>> +            .width(200)
>> +            .title(self.create_title_with_icon("sdn", tr!("SDN Zones")))
>> +            .border(true)
>> +            .with_child(SdnZonePanel::new((!
>> self.loading).then_some(status.clone())))
>> +    }
>> +
>>       fn create_task_summary_panel(
>>           &self,
>>           statistics: &StatisticsOptions,
>> @@ -620,7 +632,8 @@ impl Component for PdmDashboard {
>>                       .class(pwt::css::Flex::Fill)
>>                       .class(FlexWrap::Wrap)
>>                       .with_child(self.create_task_summary_panel(&self.statistics, None))
>> -
>>                     .with_child(self.create_task_summary_panel(&self.statistics, Some(5))),
>> +                    .with_child(self.create_task_summary_panel(&self.statistics, Some(5)))
>> +                    .with_child(self.create_sdn_panel(&self.status.sdn_zones)),
>>               );
>>             Panel::new()
>> diff --git a/ui/src/dashboard/sdn_zone_panel.rs b/ui/src/dashboard/
>> sdn_zone_panel.rs
>> new file mode 100644
>> index 0000000..bcac36b
>> --- /dev/null
>> +++ b/ui/src/dashboard/sdn_zone_panel.rs
>> @@ -0,0 +1,155 @@
>> +use std::rc::Rc;
>> +
>> +use pdm_api_types::resource::{ResourceType, SdnStatus, SdnZoneCount};
>> +use pdm_search::{Search, SearchTerm};
>> +use pwt::{
>> +    css::{self, FontColor, TextAlign},
>> +    prelude::*,
>> +    widget::{Container, Fa, List, ListTile},
>> +};
>> +use yew::{
>> +    virtual_dom::{VComp, VNode},
>> +    Properties,
>> +};
>> +
>> +use crate::search_provider::get_search_provider;
>> +
>> +use super::loading_column;
>> +
>> +#[derive(PartialEq, Clone, Properties)]
>> +pub struct SdnZonePanel {
>> +    status: Option<SdnZoneCount>,
>> +}
>> +
>> +impl SdnZonePanel {
>> +    pub fn new(status: Option<SdnZoneCount>) -> Self {
>> +        yew::props!(Self { status })
>> +    }
>> +}
>> +
>> +impl From<SdnZonePanel> for VNode {
>> +    fn from(value: SdnZonePanel) -> Self {
>> +        let comp =
>> VComp::new::<SdnZonePanelComponent>(Rc::new(value), None);
>> +        VNode::from(comp)
>> +    }
>> +}
>> +
>> +#[derive(PartialEq, Clone)]
>> +pub enum StatusRow {
>> +    State(SdnStatus, u64),
>> +    All(u64),
>> +}
>> +
>> +impl StatusRow {
>> +    fn icon(&self) -> Fa {
>> +        let (icon, color) = match self {
>> +            Self::All(_) => ("th", None),
>> +            Self::State(SdnStatus::Available, _) => ("check",
>> Some(FontColor::Success)),
>> +            Self::State(SdnStatus::Error, _) => ("times-circle",
>> Some(FontColor::Error)),
>> +            Self::State(SdnStatus::Unknown, _) => ("question", None),
>> +        };
>> +
>> +        let mut icon = Fa::new(icon);
>> +
>> +        if let Some(color) = color {
>> +            icon = icon.class(color);
>> +        }
>> +
>> +        icon
>> +    }
>> +}
>> +
>> +pub struct SdnZonePanelComponent {}
>> +
>> +impl yew::Component for SdnZonePanelComponent {
>> +    type Message = Search;
>> +    type Properties = SdnZonePanel;
>> +
>> +    fn create(_ctx: &yew::Context<Self>) -> Self {
>> +        Self {}
>> +    }
>> +
>> +    fn update(&mut self, ctx: &Context<Self>, msg: Self::Message) ->
>> bool {
>> +        if let Some(provider) = get_search_provider(ctx) {
>> +            provider.search(msg);
>> +        }
>> +
>> +        false
>> +    }
>> +
>> +    fn view(&self, ctx: &yew::Context<Self>) -> yew::Html {
>> +        let props = ctx.props();
>> +
>> +        let Some(status) = &props.status else {
>> +            return loading_column().into();
>> +        };
>> +
>> +        let data = vec![
>> +            StatusRow::State(SdnStatus::Available, status.available),
>> +            StatusRow::State(SdnStatus::Error, status.error),
>> +            StatusRow::All(status.available + status.error +
>> status.unknown),
>> +        ];
>> +
>> +        let tiles: Vec<_> = data
>> +            .into_iter()
>> +            .filter_map(|row| create_list_tile(ctx.link(), row))
>> +            .collect();
>> +
>> +        let list = List::new(tiles.len() as u64, move |idx: u64| {
>> +            tiles[idx as usize].clone()
>> +        })
>> +        .padding(4)
>> +        .class(css::Flex::Fill)
>> +        .grid_template_columns("auto auto 1fr auto");
>> +
>> +        list.into()
>> +    }
>> +}
>> +
>> +fn create_list_tile(
>> +    link: &html::Scope<SdnZonePanelComponent>,
>> +    status_row: StatusRow,
>> +) -> Option<ListTile> {
>> +    let (icon, status, count) = match status_row {
>> +        StatusRow::State(sdn_status, count) => (status_row.icon(),
>> Some(sdn_status), count),
>> +        StatusRow::All(count) => (status_row.icon(), None, count),
>> +    };
>> +
>> +    let name = status
>> +        .map(|status| status.to_string())
>> +        .unwrap_or_else(|| "All".to_string());
>> +
>> +    Some(
>> +        ListTile::new()
>> +            .tabindex(0)
>> +            .interactive(true)
>> +            .with_child(icon)
>> +            .with_child(Container::new().padding_x(2).with_child(name))
>> +            .with_child(
>> +                Container::new()
>> +                    .class(TextAlign::Right)
>> +                    .padding_end(2)
>> +                    .with_child(count),
>> +            )
>> +            .with_child(Fa::new("search"))
>> +            .onclick(link.callback(move |_|
>> create_sdn_zone_search_term(status)))
>> +            .onkeydown(link.batch_callback(
>> +                move |event: KeyboardEvent| match event.key().as_str() {
>> +                    "Enter" | " " =>
>> Some(create_sdn_zone_search_term(status)),
>> +                    _ => None,
>> +                },
>> +            )),
>> +    )
>> +}
>> +
>> +fn create_sdn_zone_search_term(status: Option<SdnStatus>) -> Search {
>> +    let resource_type: ResourceType = ResourceType::PveSdnZone;
>> +
>> +    let mut terms = vec!
>> [SearchTerm::new(resource_type.as_str()).category(Some("type"))];
>> +
>> +    if let Some(status) = status {
>> +       
>> terms.push(SearchTerm::new(status.to_string()).category(Some("status")));
>> +    }
>> +
>> +    Search::with_terms(terms)
>> +}
>> diff --git a/ui/src/lib.rs b/ui/src/lib.rs
>> index 5ffbff3..e4bfbb7 100644
>> --- a/ui/src/lib.rs
>> +++ b/ui/src/lib.rs
>> @@ -1,4 +1,4 @@
>> -use pdm_api_types::resource::{PveLxcResource, PveQemuResource};
>> +use pdm_api_types::resource::{PveLxcResource, PveQemuResource,
>> PveSdnResource};
>>   use pdm_client::types::Resource;
>>   use serde::{Deserialize, Serialize};
>>   @@ -151,13 +151,21 @@ pub(crate) fn navigate_to<C: yew::Component>(
>>                       pdm_client::types::Resource::PveStorage(storage)
>> => {
>>                           format!("storage+{}+{}", storage.node,
>> storage.storage)
>>                       }
>> +                   
>> pdm_client::types::Resource::PveSdn(PveSdnResource::Zone(_)) => {
>> +                        "sdn/zones".to_string()
>> +                    }
>>                       pdm_client::types::Resource::PbsDatastore(store)
>> => store.name.clone(),
>>                       // FIXME: implement
>>                       _ => return None,
>>                   })
>>               })
>>               .unwrap_or_default();
>> -        nav.push(&yew_router::AnyRoute::new(format!("/remote-
>> {remote}/{id}")));
>> +
>> +        if let Some(pdm_client::types::Resource::PveSdn(_)) = resource {
>> +            nav.push(&yew_router::AnyRoute::new(format!("/{id}")));
>> +        } else {
>> +            nav.push(&yew_router::AnyRoute::new(format!("/remote-
>> {remote}/{id}")));
>> +        }
> 
> i don't really like the special casing of sdn here. I think it would be
> better, e.g. if the match would return a tuple of the (optional) remote
> and the id

Yeah, makes sense I'll try to adjust it

>>       }
>>   }
>>   @@ -167,7 +175,7 @@ pub(crate) fn get_resource_node(resource:
>> &Resource) -> Option<&str> {
>>           Resource::PveQemu(qemu) => Some(&qemu.node),
>>           Resource::PveLxc(lxc) => Some(&lxc.node),
>>           Resource::PveNode(node) => Some(&node.node),
>> -        Resource::PveSdn(sdn) => Some(&sdn.sdn),
>> +        Resource::PveSdn(sdn) => Some(sdn.node()),
>>           Resource::PbsNode(_) => None,
>>           Resource::PbsDatastore(_) => None,
>>       }
>> diff --git a/ui/src/pve/utils.rs b/ui/src/pve/utils.rs
>> index 7663734..a49205d 100644
>> --- a/ui/src/pve/utils.rs
>> +++ b/ui/src/pve/utils.rs
>> @@ -1,6 +1,7 @@
>>   use anyhow::Error;
>>   use pdm_api_types::resource::{
>> -    PveLxcResource, PveNodeResource, PveQemuResource,
>> PveStorageResource,
>> +    PveLxcResource, PveNodeResource, PveQemuResource,
>> PveStorageResource, SdnStatus,
>> +    SdnZoneResource,
>>   };
>>   use pdm_client::types::{
>>       LxcConfig, LxcConfigMp, LxcConfigRootfs, LxcConfigUnused,
>> PveQmIde, QemuConfig, QemuConfigSata,
>> @@ -88,6 +89,19 @@ pub fn render_node_status_icon(node:
>> &PveNodeResource) -> Container {
>>           .with_child(Fa::from(extra).fixed_width().class("status-icon"))
>>   }
>>   +/// Renders the status icon for a PveNode
> 
> it's not for a PveNode ;)

copy-paste mistake :(

> 
>> +pub fn render_sdn_status_icon(zone: &SdnZoneResource) -> Container {
>> +    let extra = match zone.status {
>> +        SdnStatus::Available => NodeState::Online,
>> +        SdnStatus::Error => NodeState::Offline,
>> +        _ => NodeState::Unknown,
>> +    };
>> +    Container::new()
>> +        .class("pdm-type-icon")
>> +        .with_child(Fa::new("th").fixed_width())
>> +        .with_child(Fa::from(extra).fixed_width().class("status-icon"))
>> +}
>> +
>>   /// Renders the status icon for a PveStorage
>>   pub fn render_storage_status_icon(node: &PveStorageResource) ->
>> Container {
>>       let extra = match node.status.as_str() {
>> diff --git a/ui/src/renderer.rs b/ui/src/renderer.rs
>> index 5ebd9a3..e179cd5 100644
>> --- a/ui/src/renderer.rs
>> +++ b/ui/src/renderer.rs
>> @@ -1,3 +1,4 @@
>> +use pdm_api_types::resource::PveSdnResource;
>>   use pwt::{
>>       css,
>>       prelude::*,
>> @@ -17,7 +18,7 @@ pub fn render_resource_name(resource: &Resource,
>> vmid_first: bool) -> String {
>>           Resource::PveQemu(qemu) =>
>> pve::utils::render_qemu_name(qemu, vmid_first),
>>           Resource::PveLxc(lxc) => pve::utils::render_lxc_name(lxc,
>> vmid_first),
>>           Resource::PveNode(node) => node.node.clone(),
>> -        Resource::PveSdn(sdn) => sdn.sdn.clone(),
>> +        Resource::PveSdn(sdn) => sdn.name().to_string(),

while I'm at it will also amend this change into the earlier commit
since this line is touched twice in the patch series and a remnant of an
earlier version of the Resource types..

>>           Resource::PbsNode(node) => node.name.clone(),
>>           Resource::PbsDatastore(store) => store.name.clone(),
>>       }
>> @@ -43,6 +44,7 @@ pub fn render_status_icon(resource: &Resource) ->
>> Container {
>>           Resource::PveQemu(qemu) =>
>> pve::utils::render_qemu_status_icon(qemu),
>>           Resource::PveLxc(lxc) =>
>> pve::utils::render_lxc_status_icon(lxc),
>>           Resource::PveNode(node) =>
>> pve::utils::render_node_status_icon(node),
>> +        Resource::PveSdn(PveSdnResource::Zone(zone)) =>
>> pve::utils::render_sdn_status_icon(zone),
>>           // FIXME: implement remaining types
>>           _ =>
>> Container::new().with_child(render_resource_icon(resource)),
>>       }
> 



_______________________________________________
pdm-devel mailing list
pdm-devel@lists.proxmox.com
https://lists.proxmox.com/cgi-bin/mailman/listinfo/pdm-devel

^ permalink raw reply	[flat|nested] 18+ messages in thread

* Re: [pdm-devel] [PATCH proxmox-datacenter-manager 5/5] ui: sdn: add zone tree
  2025-09-09 10:08 ` [pdm-devel] [PATCH proxmox-datacenter-manager 5/5] ui: sdn: add zone tree Stefan Hanreich
@ 2025-09-09 13:41   ` Dominik Csapak
  2025-09-09 13:57     ` Stefan Hanreich
  0 siblings, 1 reply; 18+ messages in thread
From: Dominik Csapak @ 2025-09-09 13:41 UTC (permalink / raw)
  To: Proxmox Datacenter Manager development discussion, Stefan Hanreich

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 <s.hanreich@proxmox.com>
> ---
>   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<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 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<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
> +    }
> +
> +    #[allow(unused_variables)]

is this necessary? or is there some clippy weirdness going on?

> +    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()
> +    }
> +}



_______________________________________________
pdm-devel mailing list
pdm-devel@lists.proxmox.com
https://lists.proxmox.com/cgi-bin/mailman/listinfo/pdm-devel


^ permalink raw reply	[flat|nested] 18+ messages in thread

* Re: [pdm-devel] [PATCH manager/proxmox-datacenter-manager 0/6] Add SDN resources to dashboard + SDN zone overview tree
  2025-09-09 10:08 [pdm-devel] [PATCH manager/proxmox-datacenter-manager 0/6] Add SDN resources to dashboard + SDN zone overview tree Stefan Hanreich
                   ` (5 preceding siblings ...)
  2025-09-09 10:08 ` [pdm-devel] [PATCH proxmox-datacenter-manager 5/5] ui: sdn: add zone tree Stefan Hanreich
@ 2025-09-09 13:43 ` Dominik Csapak
  2025-09-09 14:06   ` Stefan Hanreich
  6 siblings, 1 reply; 18+ messages in thread
From: Dominik Csapak @ 2025-09-09 13:43 UTC (permalink / raw)
  To: Proxmox Datacenter Manager development discussion, Stefan Hanreich

looks mostly ok, see my comments on the individual patches

On 9/9/25 12:08 PM, Stefan Hanreich wrote:
> ## Introduction
> 
> This patch series adds the SDN cluster resources to the existing resource
> infrastructure in PDM. It also adds a small panel to the dashboard that gives an
> aggregated count of the status of SDN zones across all remotes. It also adds the
> SDN resources to the resource tree.
> 
> It adds a new menu entry: SDN that acts as the top-level for all SDN-related
> menu entries. The menu entry shows a tree of all SDN zones across all remotes,
> as well as their current status.
> 
> I've decided to model the SDN entities as an enum, since in the future we want
> to add additional SDN entities, and they all might have different properties.
> This avoids a type where all additional properties have Option<> as well as
> polluting the root Resource type with Sdn<Entity> variants.
> 
> ## Additional API endpoints:
> 
> * GET /resources/type/{resource_type}
> 
> ## Notes for reviewers:
> * is the structure for the SDN resources okay, or should we introduce a
>    dedicated resource for different SDN entities (i.e. PveSdnZone,
>    PveSdnFabric, .. instead of PveSdn(Zone::(_)))?

imho it's fine for me because we look at the sdn stuff differently, so
being able to easily filter them out/in is nicer.maybe someone else has 
a different opinion though


> * is the new API endpoint okay or should we just use the existing search
>    infrastructure for returning SDN resources instead of introducing a dedicated
>    API endpoint?

could we maybe add a 'type' filter on the list api call that does the
same you do? then we'd just have a single api endpoint but can still
filter efficiently for the type?

> 
> pve-manager:
> 
> Stefan Hanreich (1):
>    cluster: resources: add sdn property to cluster resources schema
> 
>   PVE/API2/Cluster.pm | 5 +++++
>   1 file changed, 5 insertions(+)
> 
> 
> proxmox-datacenter-manager:
> 
> Stefan Hanreich (5):
>    pdm-api-types: add sdn cluster resource
>    server: api: add resources_by_type api call
>    ui: add sdn status report to dashboard
>    ui: images: add sdn icon
>    ui: sdn: add zone tree
> 
>   cli/client/src/resources.rs                  |  14 +
>   lib/pdm-api-types/src/resource.rs            | 159 +++++++++-
>   lib/pdm-client/src/lib.rs                    |  14 +-
>   lib/proxmox-api-types                        |   2 +-
>   server/src/api/resources.rs                  | 131 +++++++-
>   server/src/metric_collection/top_entities.rs |   1 +
>   ui/Makefile                                  |   1 +
>   ui/css/pdm.scss                              |  27 +-
>   ui/images/icon-sdn.svg                       |  70 +++++
>   ui/src/dashboard/mod.rs                      |  17 +-
>   ui/src/dashboard/sdn_zone_panel.rs           | 155 ++++++++++
>   ui/src/lib.rs                                |  13 +-
>   ui/src/main_menu.rs                          |  15 +-
>   ui/src/pve/remote.rs                         |   1 +
>   ui/src/pve/tree.rs                           |   1 +
>   ui/src/pve/utils.rs                          |  16 +-
>   ui/src/renderer.rs                           |   4 +
>   ui/src/sdn/mod.rs                            |   3 +
>   ui/src/sdn/zone_tree.rs                      | 299 +++++++++++++++++++
>   19 files changed, 929 insertions(+), 14 deletions(-)
>   create mode 100644 ui/images/icon-sdn.svg
>   create mode 100644 ui/src/dashboard/sdn_zone_panel.rs
>   create mode 100644 ui/src/sdn/zone_tree.rs
> 
> 
> Summary over all repositories:
>    20 files changed, 934 insertions(+), 14 deletions(-)
> 



_______________________________________________
pdm-devel mailing list
pdm-devel@lists.proxmox.com
https://lists.proxmox.com/cgi-bin/mailman/listinfo/pdm-devel


^ permalink raw reply	[flat|nested] 18+ messages in thread

* Re: [pdm-devel] [PATCH proxmox-datacenter-manager 5/5] ui: sdn: add zone tree
  2025-09-09 13:41   ` Dominik Csapak
@ 2025-09-09 13:57     ` Stefan Hanreich
  0 siblings, 0 replies; 18+ messages in thread
From: Stefan Hanreich @ 2025-09-09 13:57 UTC (permalink / raw)
  To: Dominik Csapak, Proxmox Datacenter Manager development discussion

On 9/9/25 3:41 PM, Dominik Csapak wrote:
> 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?

I thought about it too, but generally we talked once that we usually
don't use titles and it seems weird to break that here - as does
breaking the position of the refresh button imo. But I agree that it
does look barren atm.

> 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 <s.hanreich@proxmox.com>
>> ---
>>   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<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 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?
> 

yeah, that seems a lot better.

>> +                })
>> +                .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
>> +    }
>> +
>> +    #[allow(unused_variables)]
> 
> is this necessary? or is there some clippy weirdness going on?

will double-check



_______________________________________________
pdm-devel mailing list
pdm-devel@lists.proxmox.com
https://lists.proxmox.com/cgi-bin/mailman/listinfo/pdm-devel

^ permalink raw reply	[flat|nested] 18+ messages in thread

* Re: [pdm-devel] [PATCH manager/proxmox-datacenter-manager 0/6] Add SDN resources to dashboard + SDN zone overview tree
  2025-09-09 13:43 ` [pdm-devel] [PATCH manager/proxmox-datacenter-manager 0/6] Add SDN resources to dashboard + SDN zone overview tree Dominik Csapak
@ 2025-09-09 14:06   ` Stefan Hanreich
  0 siblings, 0 replies; 18+ messages in thread
From: Stefan Hanreich @ 2025-09-09 14:06 UTC (permalink / raw)
  To: Dominik Csapak, Proxmox Datacenter Manager development discussion

On 9/9/25 3:42 PM, Dominik Csapak wrote:

[snip]

>> * is the new API endpoint okay or should we just use the existing search
>>    infrastructure for returning SDN resources instead of introducing a
>> dedicated
>>    API endpoint?
> 
> could we maybe add a 'type' filter on the list api call that does the
> same you do? then we'd just have a single api endpoint but can still
> filter efficiently for the type?

yeah, that's probably better and avoids lots of duplication - shouldn't
be hard to retrofit I'll look into it for a v2.

[snip]


_______________________________________________
pdm-devel mailing list
pdm-devel@lists.proxmox.com
https://lists.proxmox.com/cgi-bin/mailman/listinfo/pdm-devel

^ permalink raw reply	[flat|nested] 18+ messages in thread

end of thread, other threads:[~2025-09-09 14:06 UTC | newest]

Thread overview: 18+ messages (download: mbox.gz / follow: Atom feed)
-- links below jump to the message on this page --
2025-09-09 10:08 [pdm-devel] [PATCH manager/proxmox-datacenter-manager 0/6] Add SDN resources to dashboard + SDN zone overview tree Stefan Hanreich
2025-09-09 10:08 ` [pdm-devel] [PATCH pve-manager 1/1] cluster: resources: add sdn property to cluster resources schema Stefan Hanreich
2025-09-09 10:08 ` [pdm-devel] [PATCH proxmox-datacenter-manager 1/5] pdm-api-types: add sdn cluster resource Stefan Hanreich
2025-09-09 11:13   ` Stefan Hanreich
2025-09-09 11:24     ` Thomas Lamprecht
2025-09-09 11:26       ` Stefan Hanreich
2025-09-09 10:08 ` [pdm-devel] [PATCH proxmox-datacenter-manager 2/5] server: api: add resources_by_type api call Stefan Hanreich
2025-09-09 10:08 ` [pdm-devel] [PATCH proxmox-datacenter-manager 3/5] ui: add sdn status report to dashboard Stefan Hanreich
2025-09-09 13:10   ` Dominik Csapak
2025-09-09 13:22     ` Stefan Hanreich
2025-09-09 10:08 ` [pdm-devel] [PATCH proxmox-datacenter-manager 4/5] ui: images: add sdn icon Stefan Hanreich
2025-09-09 13:16   ` Dominik Csapak
2025-09-09 13:21     ` Stefan Hanreich
2025-09-09 10:08 ` [pdm-devel] [PATCH proxmox-datacenter-manager 5/5] ui: sdn: add zone tree Stefan Hanreich
2025-09-09 13:41   ` Dominik Csapak
2025-09-09 13:57     ` Stefan Hanreich
2025-09-09 13:43 ` [pdm-devel] [PATCH manager/proxmox-datacenter-manager 0/6] Add SDN resources to dashboard + SDN zone overview tree Dominik Csapak
2025-09-09 14:06   ` Stefan Hanreich

This is an external index of several public inboxes,
see mirroring instructions on how to clone and mirror
all data and code used by this external index.
Service provided by Proxmox Server Solutions GmbH | Privacy | Legal