all lists on lists.proxmox.com
 help / color / mirror / Atom feed
* [pdm-devel] [PATCH manager/proxmox{-api-types, -datacenter-manager} v2 0/8] Add SDN resources to dashboard + SDN zone overview tree
@ 2025-09-09 15:54 Stefan Hanreich
  2025-09-09 15:54 ` [pdm-devel] [PATCH pve-manager v2 1/1] cluster: resources: add sdn property to cluster resources schema Stefan Hanreich
                   ` (7 more replies)
  0 siblings, 8 replies; 9+ messages in thread
From: Stefan Hanreich @ 2025-09-09 15:54 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.

## Changed API endpoints:

* GET /resources/list
  added resource-type parameter for filtering by type efficiently

## 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?

## Changelog

Changes from v1 (Thank you very much @Dominik!)
* integrated type filtering into list endpoint
* fixed color of VNet icon in dark mode
* improved navigate_to function by introducing prefix variable
* fixed documentation for render_sdn_status_icon
* improved rendering logic for names in zone_tree
* removed unnecessary clippy macro
* removed submodule update in commit
* included pve-api.json updates
  there are more when running make pve-api.json against the new API and then pdm
  doesn't compile for me, so I've included the proxmox-api-types commits to
  spare any committer some work.

(left the refresh button as is for now, will revisit later)

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-api-types:

Stefan Hanreich (2):
  cluster: resource: add sdn property
  regenerate

 pve-api-types/pve-api.json           | 5 +++++
 pve-api-types/src/generated/types.rs | 8 ++++++++
 2 files changed, 13 insertions(+)


proxmox-datacenter-manager:

Stefan Hanreich (5):
  pdm-api-types: add sdn cluster resource
  server: api: add resource-type parameter to list_resources
  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                    |  15 +-
 server/src/api/resources.rs                  |  55 +++-
 server/src/metric_collection/top_entities.rs |   1 +
 server/src/resource_cache.rs                 |   2 +-
 ui/Makefile                                  |   1 +
 ui/css/pdm.scss                              |  36 ++-
 ui/images/icon-sdn.svg                       |  70 +++++
 ui/src/dashboard/mod.rs                      |  17 +-
 ui/src/dashboard/sdn_zone_panel.rs           | 155 ++++++++++
 ui/src/lib.rs                                |  30 +-
 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                      | 300 +++++++++++++++++++
 19 files changed, 870 insertions(+), 25 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:
  22 files changed, 888 insertions(+), 25 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] 9+ messages in thread

* [pdm-devel] [PATCH pve-manager v2 1/1] cluster: resources: add sdn property to cluster resources schema
  2025-09-09 15:54 [pdm-devel] [PATCH manager/proxmox{-api-types, -datacenter-manager} v2 0/8] Add SDN resources to dashboard + SDN zone overview tree Stefan Hanreich
@ 2025-09-09 15:54 ` Stefan Hanreich
  2025-09-09 15:54 ` [pdm-devel] [PATCH proxmox-api-types v2 1/2] cluster: resource: add sdn property Stefan Hanreich
                   ` (6 subsequent siblings)
  7 siblings, 0 replies; 9+ messages in thread
From: Stefan Hanreich @ 2025-09-09 15:54 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] 9+ messages in thread

* [pdm-devel] [PATCH proxmox-api-types v2 1/2] cluster: resource: add sdn property
  2025-09-09 15:54 [pdm-devel] [PATCH manager/proxmox{-api-types, -datacenter-manager} v2 0/8] Add SDN resources to dashboard + SDN zone overview tree Stefan Hanreich
  2025-09-09 15:54 ` [pdm-devel] [PATCH pve-manager v2 1/1] cluster: resources: add sdn property to cluster resources schema Stefan Hanreich
@ 2025-09-09 15:54 ` Stefan Hanreich
  2025-09-09 15:54 ` [pdm-devel] [PATCH proxmox-api-types v2 2/2] regenerate Stefan Hanreich
                   ` (5 subsequent siblings)
  7 siblings, 0 replies; 9+ messages in thread
From: Stefan Hanreich @ 2025-09-09 15:54 UTC (permalink / raw)
  To: pdm-devel

SDN entities store their name in the sdn property. Add it to the
existing API schema where it was missing.

Signed-off-by: Stefan Hanreich <s.hanreich@proxmox.com>
---
 pve-api-types/pve-api.json | 5 +++++
 1 file changed, 5 insertions(+)

diff --git a/pve-api-types/pve-api.json b/pve-api-types/pve-api.json
index d9d1627..2d1ed9e 100644
--- a/pve-api-types/pve-api.json
+++ b/pve-api-types/pve-api.json
@@ -15734,6 +15734,11 @@
                                  "optional": 1,
                                  "type": "string"
                               },
+                              "sdn": {
+                                 "description": "The name of an SDN entity (for type 'sdn')",
+                                 "optional": 1,
+                                 "type": "string"
+                              },
                               "status": {
                                  "description": "Resource type dependent status.",
                                  "optional": 1,
-- 
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] 9+ messages in thread

* [pdm-devel] [PATCH proxmox-api-types v2 2/2] regenerate
  2025-09-09 15:54 [pdm-devel] [PATCH manager/proxmox{-api-types, -datacenter-manager} v2 0/8] Add SDN resources to dashboard + SDN zone overview tree Stefan Hanreich
  2025-09-09 15:54 ` [pdm-devel] [PATCH pve-manager v2 1/1] cluster: resources: add sdn property to cluster resources schema Stefan Hanreich
  2025-09-09 15:54 ` [pdm-devel] [PATCH proxmox-api-types v2 1/2] cluster: resource: add sdn property Stefan Hanreich
@ 2025-09-09 15:54 ` Stefan Hanreich
  2025-09-09 15:54 ` [pdm-devel] [PATCH proxmox-datacenter-manager v2 1/5] pdm-api-types: add sdn cluster resource Stefan Hanreich
                   ` (4 subsequent siblings)
  7 siblings, 0 replies; 9+ messages in thread
From: Stefan Hanreich @ 2025-09-09 15:54 UTC (permalink / raw)
  To: pdm-devel

Signed-off-by: Stefan Hanreich <s.hanreich@proxmox.com>
---
 pve-api-types/src/generated/types.rs | 8 ++++++++
 1 file changed, 8 insertions(+)

diff --git a/pve-api-types/src/generated/types.rs b/pve-api-types/src/generated/types.rs
index fe18d26..eaf81a8 100644
--- a/pve-api-types/src/generated/types.rs
+++ b/pve-api-types/src/generated/types.rs
@@ -726,6 +726,10 @@ CLUSTER_RESOURCE_STORAGE_RE = r##"^(?i:[a-z][a-z0-9\-_.]*[a-z0-9])$"##;
             optional: true,
             type: String,
         },
+        sdn: {
+            optional: true,
+            type: String,
+        },
         status: {
             optional: true,
             type: String,
@@ -868,6 +872,10 @@ pub struct ClusterResource {
     #[serde(default, skip_serializing_if = "Option::is_none")]
     pub pool: Option<String>,
 
+    /// The name of an SDN entity (for type 'sdn')
+    #[serde(default, skip_serializing_if = "Option::is_none")]
+    pub sdn: Option<String>,
+
     /// Resource type dependent status.
     #[serde(default, skip_serializing_if = "Option::is_none")]
     pub status: Option<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] 9+ messages in thread

* [pdm-devel] [PATCH proxmox-datacenter-manager v2 1/5] pdm-api-types: add sdn cluster resource
  2025-09-09 15:54 [pdm-devel] [PATCH manager/proxmox{-api-types, -datacenter-manager} v2 0/8] Add SDN resources to dashboard + SDN zone overview tree Stefan Hanreich
                   ` (2 preceding siblings ...)
  2025-09-09 15:54 ` [pdm-devel] [PATCH proxmox-api-types v2 2/2] regenerate Stefan Hanreich
@ 2025-09-09 15:54 ` Stefan Hanreich
  2025-09-09 15:54 ` [pdm-devel] [PATCH proxmox-datacenter-manager v2 2/5] server: api: add resource-type parameter to list_resources Stefan Hanreich
                   ` (3 subsequent siblings)
  7 siblings, 0 replies; 9+ messages in thread
From: Stefan Hanreich @ 2025-09-09 15:54 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 ++++++++++++++++++-
 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 +
 8 files changed, 199 insertions(+), 2 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/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..6370930 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.node()),
         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..f137b68 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.name().to_string(),
         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] 9+ messages in thread

* [pdm-devel] [PATCH proxmox-datacenter-manager v2 2/5] server: api: add resource-type parameter to list_resources
  2025-09-09 15:54 [pdm-devel] [PATCH manager/proxmox{-api-types, -datacenter-manager} v2 0/8] Add SDN resources to dashboard + SDN zone overview tree Stefan Hanreich
                   ` (3 preceding siblings ...)
  2025-09-09 15:54 ` [pdm-devel] [PATCH proxmox-datacenter-manager v2 1/5] pdm-api-types: add sdn cluster resource Stefan Hanreich
@ 2025-09-09 15:54 ` Stefan Hanreich
  2025-09-09 15:54 ` [pdm-devel] [PATCH proxmox-datacenter-manager v2 3/5] ui: add sdn status report to dashboard Stefan Hanreich
                   ` (2 subsequent siblings)
  7 siblings, 0 replies; 9+ messages in thread
From: Stefan Hanreich @ 2025-09-09 15:54 UTC (permalink / raw)
  To: pdm-devel

Add a new parameter to the list_resources call that filters entities
based on their type. While this should already be possible with the
search function as well, filtering directly by enum is more efficient
than building the search filters and then doing matching based on
strings. The type parameter has precedence, so any search filters will
be applied only on entities that have the type specified in
resource-type.

In pdm-client add a separate method to not pollute existing call sites
with unnecessary parameters. In the future it might make sense to add
another method that takes all possible combinations of parameters.

Signed-off-by: Stefan Hanreich <s.hanreich@proxmox.com>
---
 lib/pdm-api-types/src/resource.rs | 15 ++++++++++++++-
 lib/pdm-client/src/lib.rs         | 15 ++++++++++++++-
 server/src/api/resources.rs       | 18 +++++++++++++++---
 server/src/resource_cache.rs      |  2 +-
 4 files changed, 44 insertions(+), 6 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..f2ff4d4 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,19 @@ 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("/api2/extjs/resources/list")
+            .maybe_arg("max-age", &max_age)
+            .arg("resource-type", resource_type)
+            .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..dcdf0ea 100644
--- a/server/src/api/resources.rs
+++ b/server/src/api/resources.rs
@@ -150,6 +150,10 @@ fn remote_matches_search_term(remote_name: &str, online: Option<bool>, term: &Se
                 description: "Search term to filter for, uses special syntax e.g. <TODO>",
                 optional: true,
             },
+            "resource-type": {
+                type: ResourceType,
+                optional: true,
+            },
         }
     },
     returns: {
@@ -163,10 +167,11 @@ fn remote_matches_search_term(remote_name: &str, online: Option<bool>, term: &Se
 /// List all resources from remote nodes.
 pub async fn get_resources(
     max_age: u64,
+    resource_type: Option<ResourceType>,
     search: Option<String>,
     rpcenv: &mut dyn RpcEnvironment,
 ) -> Result<Vec<RemoteResources>, Error> {
-    get_resources_impl(max_age, search, Some(rpcenv)).await
+    get_resources_impl(max_age, search, resource_type, Some(rpcenv)).await
 }
 
 // helper to determine if the combination of search terms requires the results
@@ -208,6 +213,7 @@ fn is_remotes_only(filters: &Search) -> bool {
 pub(crate) async fn get_resources_impl(
     max_age: u64,
     search: Option<String>,
+    resource_type: Option<ResourceType>,
     rpcenv: Option<&mut dyn RpcEnvironment>,
 ) -> Result<Vec<RemoteResources>, Error> {
     let user_info = CachedUserInfo::new()?;
@@ -252,8 +258,14 @@ pub(crate) async fn get_resources_impl(
 
             if remotes_only {
                 resources.clear();
-            } else if !filter.is_empty() {
+            } else if resource_type.is_some() || !filter.is_empty() {
                 resources.retain(|resource| {
+                    if let Some(resource_type) = resource_type {
+                        if resource.resource_type() != resource_type {
+                            return false;
+                        }
+                    }
+
                     filter.matches(|filter| {
                         // if we get can't decide if it matches, don't filter it out
                         resource_matches_search_term(resource, filter).unwrap_or(true)
@@ -318,7 +330,7 @@ pub async fn get_status(
     max_age: u64,
     rpcenv: &mut dyn RpcEnvironment,
 ) -> Result<ResourcesStatus, Error> {
-    let remotes = get_resources(max_age, None, rpcenv).await?;
+    let remotes = get_resources(max_age, None, None, rpcenv).await?;
     let mut counts = ResourcesStatus::default();
     for remote in remotes {
         if remote.error.is_some() {
diff --git a/server/src/resource_cache.rs b/server/src/resource_cache.rs
index 0ae86ee..aa20c54 100644
--- a/server/src/resource_cache.rs
+++ b/server/src/resource_cache.rs
@@ -21,7 +21,7 @@ pub fn start_task() {
 async fn resource_caching_task() -> Result<(), Error> {
     loop {
         if let Err(err) =
-            crate::api::resources::get_resources_impl(METRIC_POLL_INTERVALL, None, None).await
+            crate::api::resources::get_resources_impl(METRIC_POLL_INTERVALL, None, None, None).await
         {
             log::error!("could not update resource cache: {err}");
         }
-- 
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] 9+ messages in thread

* [pdm-devel] [PATCH proxmox-datacenter-manager v2 3/5] ui: add sdn status report to dashboard
  2025-09-09 15:54 [pdm-devel] [PATCH manager/proxmox{-api-types, -datacenter-manager} v2 0/8] Add SDN resources to dashboard + SDN zone overview tree Stefan Hanreich
                   ` (4 preceding siblings ...)
  2025-09-09 15:54 ` [pdm-devel] [PATCH proxmox-datacenter-manager v2 2/5] server: api: add resource-type parameter to list_resources Stefan Hanreich
@ 2025-09-09 15:54 ` Stefan Hanreich
  2025-09-09 15:54 ` [pdm-devel] [PATCH proxmox-datacenter-manager v2 4/5] ui: images: add sdn icon Stefan Hanreich
  2025-09-09 15:54 ` [pdm-devel] [PATCH proxmox-datacenter-manager v2 5/5] ui: sdn: add zone tree Stefan Hanreich
  7 siblings, 0 replies; 9+ messages in thread
From: Stefan Hanreich @ 2025-09-09 15:54 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                      |  29 ++++--
 ui/src/pve/utils.rs                |  16 ++-
 ui/src/renderer.rs                 |   2 +
 5 files changed, 208 insertions(+), 11 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 6370930..3d9b3c1 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};
 
@@ -140,24 +140,37 @@ pub(crate) fn navigate_to<C: yew::Component>(
     resource: Option<&pdm_client::types::Resource>,
 ) {
     if let Some(nav) = link.navigator() {
-        let id = resource
+        let (prefix, id) = resource
             .and_then(|resource| {
                 Some(match resource {
                     pdm_client::types::Resource::PveQemu(PveQemuResource { vmid, .. })
                     | pdm_client::types::Resource::PveLxc(PveLxcResource { vmid, .. }) => {
-                        format!("guest+{vmid}")
+                        (Some(remote), format!("guest+{vmid}"))
                     }
-                    pdm_client::types::Resource::PveNode(node) => format!("node+{}", node.node),
-                    pdm_client::types::Resource::PveStorage(storage) => {
-                        format!("storage+{}+{}", storage.node, storage.storage)
+                    pdm_client::types::Resource::PveNode(node) => {
+                        (Some(remote), format!("node+{}", node.node))
+                    }
+                    pdm_client::types::Resource::PveStorage(storage) => (
+                        Some(remote),
+                        format!("storage+{}+{}", storage.node, storage.storage),
+                    ),
+                    pdm_client::types::Resource::PveSdn(PveSdnResource::Zone(_)) => {
+                        (None, "sdn/zones".to_string())
+                    }
+                    pdm_client::types::Resource::PbsDatastore(store) => {
+                        (Some(remote), store.name.clone())
                     }
-                    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}")));
+
+        let prefix = prefix
+            .map(|prefix| format!("{prefix}/"))
+            .unwrap_or_default();
+
+        nav.push(&yew_router::AnyRoute::new(format!("{prefix}{id}")));
     }
 }
 
diff --git a/ui/src/pve/utils.rs b/ui/src/pve/utils.rs
index 7663734..5923855 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 PveSdnZone
+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 f137b68..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::*,
@@ -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] 9+ messages in thread

* [pdm-devel] [PATCH proxmox-datacenter-manager v2 4/5] ui: images: add sdn icon
  2025-09-09 15:54 [pdm-devel] [PATCH manager/proxmox{-api-types, -datacenter-manager} v2 0/8] Add SDN resources to dashboard + SDN zone overview tree Stefan Hanreich
                   ` (5 preceding siblings ...)
  2025-09-09 15:54 ` [pdm-devel] [PATCH proxmox-datacenter-manager v2 3/5] ui: add sdn status report to dashboard Stefan Hanreich
@ 2025-09-09 15:54 ` Stefan Hanreich
  2025-09-09 15:54 ` [pdm-devel] [PATCH proxmox-datacenter-manager v2 5/5] ui: sdn: add zone tree Stefan Hanreich
  7 siblings, 0 replies; 9+ messages in thread
From: Stefan Hanreich @ 2025-09-09 15:54 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        | 36 +++++++++++++++++++---
 ui/images/icon-sdn.svg | 70 ++++++++++++++++++++++++++++++++++++++++++
 3 files changed, 102 insertions(+), 5 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..92182a4 100644
--- a/ui/css/pdm.scss
+++ b/ui/css/pdm.scss
@@ -59,19 +59,45 @@
 
 .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,
+    .fa-sdn-vnet:before {
+        background-color: var(--pwt-accent-color);
+    }
+}
+
+.pwt-panel-header-text{
+    .fa-sdn:before,
+    .fa-sdn-vnet:before {
+      background-color: var(--pwt-accent-color-background);
+    }
+}
+
 :root.pwt-dark-mode {
     .fa-memory,
-    .fa-cpu,
-    .fa-sdn-vnet {
+    .fa-cpu {
         filter: invert(90%);
     }
 }
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] 9+ messages in thread

* [pdm-devel] [PATCH proxmox-datacenter-manager v2 5/5] ui: sdn: add zone tree
  2025-09-09 15:54 [pdm-devel] [PATCH manager/proxmox{-api-types, -datacenter-manager} v2 0/8] Add SDN resources to dashboard + SDN zone overview tree Stefan Hanreich
                   ` (6 preceding siblings ...)
  2025-09-09 15:54 ` [pdm-devel] [PATCH proxmox-datacenter-manager v2 4/5] ui: images: add sdn icon Stefan Hanreich
@ 2025-09-09 15:54 ` Stefan Hanreich
  7 siblings, 0 replies; 9+ messages in thread
From: Stefan Hanreich @ 2025-09-09 15:54 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 | 300 ++++++++++++++++++++++++++++++++++++++++
 3 files changed, 317 insertions(+), 1 deletion(-)
 create mode 100644 ui/src/sdn/zone_tree.rs

diff --git a/ui/src/main_menu.rs b/ui/src/main_menu.rs
index 7eac775..f440be8 100644
--- a/ui/src/main_menu.rs
+++ b/ui/src/main_menu.rs
@@ -18,6 +18,7 @@ use pdm_api_types::remotes::RemoteType;
 
 use crate::remotes::RemotesPanel;
 use crate::sdn::evpn::EvpnPanel;
+use crate::sdn::ZoneTree;
 use crate::{
     AccessControl, CertificatesPanel, Dashboard, RemoteList, ServerAdministration,
     SystemConfiguration,
@@ -248,8 +249,10 @@ impl Component for PdmMainMenu {
             admin_submenu,
         );
 
+        let mut sdn_submenu = Menu::new();
+
         register_view(
-            &mut menu,
+            &mut sdn_submenu,
             &mut content,
             tr!("EVPN"),
             "evpn",
@@ -257,6 +260,16 @@ impl Component for PdmMainMenu {
             |_| EvpnPanel::new().into(),
         );
 
+        register_submenu(
+            &mut menu,
+            &mut content,
+            tr!("SDN"),
+            "sdn",
+            Some("fa fa-sdn"),
+            |_| ZoneTree::new().into(),
+            sdn_submenu,
+        );
+
         let mut remote_submenu = Menu::new();
 
         for remote in self.remote_list_cache.iter() {
diff --git a/ui/src/sdn/mod.rs b/ui/src/sdn/mod.rs
index ef2eab9..b6ab8ad 100644
--- a/ui/src/sdn/mod.rs
+++ b/ui/src/sdn/mod.rs
@@ -1 +1,4 @@
 pub mod evpn;
+
+mod zone_tree;
+pub use zone_tree::ZoneTree;
diff --git a/ui/src/sdn/zone_tree.rs b/ui/src/sdn/zone_tree.rs
new file mode 100644
index 0000000..0cda1a4
--- /dev/null
+++ b/ui/src/sdn/zone_tree.rs
@@ -0,0 +1,300 @@
+use futures::Future;
+use std::cmp::Ordering;
+use std::pin::Pin;
+use std::rc::Rc;
+
+use yew::virtual_dom::{Key, VComp, VNode};
+use yew::{Html, Properties};
+
+use pdm_api_types::resource::{
+    PveSdnResource, RemoteResources, ResourceType, SdnStatus, SdnZoneResource,
+};
+use pdm_client::types::Resource;
+use proxmox_yew_comp::{LoadableComponent, LoadableComponentContext, LoadableComponentMaster};
+use pwt::props::EventSubscriber;
+use pwt::widget::{Button, Toolbar};
+use pwt::{
+    css,
+    css::FontColor,
+    props::{ContainerBuilder, ExtractPrimaryKey, WidgetBuilder},
+    state::{Selection, SlabTree, TreeStore},
+    tr,
+    widget::{
+        data_table::{DataTable, DataTableColumn, DataTableHeader},
+        error_message, Column, Fa, Row,
+    },
+};
+
+use crate::pdm_client;
+
+#[derive(PartialEq, Properties)]
+pub struct ZoneTree {}
+
+impl ZoneTree {
+    pub fn new() -> Self {
+        yew::props!(Self {})
+    }
+}
+
+impl From<ZoneTree> for VNode {
+    fn from(value: ZoneTree) -> Self {
+        let comp = VComp::new::<LoadableComponentMaster<ZoneTreeComponent>>(Rc::new(value), None);
+        VNode::from(comp)
+    }
+}
+
+#[derive(Clone, PartialEq, Debug)]
+struct ZoneData {
+    remote: String,
+    node: String,
+    name: String,
+    status: SdnStatus,
+}
+
+#[derive(Clone, PartialEq, Debug)]
+enum ZoneTreeEntry {
+    Root,
+    Remote(String),
+    Node(String, String),
+    Zone(ZoneData),
+}
+
+impl ZoneTreeEntry {
+    fn from_zone_resource(remote: String, value: SdnZoneResource) -> Self {
+        Self::Zone(ZoneData {
+            remote,
+            node: value.node.clone(),
+            name: value.name.clone(),
+            status: value.status,
+        })
+    }
+
+    fn name(&self) -> &str {
+        match &self {
+            Self::Root => "",
+            Self::Remote(name) => name,
+            Self::Node(_, name) => name,
+            Self::Zone(zone) => &zone.name,
+        }
+    }
+}
+
+impl ExtractPrimaryKey for ZoneTreeEntry {
+    fn extract_key(&self) -> yew::virtual_dom::Key {
+        Key::from(match self {
+            ZoneTreeEntry::Root => "/".to_string(),
+            ZoneTreeEntry::Remote(name) => format!("/{name}"),
+            ZoneTreeEntry::Node(remote_name, name) => format!("/{remote_name}/{name}"),
+            ZoneTreeEntry::Zone(zone) => format!("/{}/{}/{}", zone.remote, zone.node, zone.name),
+        })
+    }
+}
+
+pub enum ZoneTreeMsg {
+    LoadFinished(Vec<RemoteResources>),
+    Reload,
+}
+
+pub struct ZoneTreeComponent {
+    store: TreeStore<ZoneTreeEntry>,
+    selection: Selection,
+    remote_errors: Vec<String>,
+    columns: Rc<Vec<DataTableHeader<ZoneTreeEntry>>>,
+}
+
+fn default_sorter(a: &ZoneTreeEntry, b: &ZoneTreeEntry) -> Ordering {
+    a.name().cmp(b.name())
+}
+
+impl ZoneTreeComponent {
+    fn columns(store: TreeStore<ZoneTreeEntry>) -> Rc<Vec<DataTableHeader<ZoneTreeEntry>>> {
+        Rc::new(vec![
+            DataTableColumn::new(tr!("Name"))
+                .tree_column(store)
+                .render(|entry: &ZoneTreeEntry| {
+                    let icon = match entry {
+                        ZoneTreeEntry::Remote(_) => Some("server"),
+                        ZoneTreeEntry::Node(_, _) => Some("building"),
+                        ZoneTreeEntry::Zone(_) => Some("th"),
+                        _ => None,
+                    };
+
+                    Row::new()
+                        .class(css::AlignItems::Baseline)
+                        .gap(2)
+                        .with_optional_child(icon.map(|icon| Fa::new(icon)))
+                        .with_child(entry.name())
+                        .into()
+                })
+                .sorter(default_sorter)
+                .into(),
+            DataTableColumn::new(tr!("Status"))
+                .render(|entry: &ZoneTreeEntry| {
+                    let mut row = Row::new().class(css::AlignItems::Baseline).gap(2);
+
+                    if let ZoneTreeEntry::Zone(zone) = entry {
+                        row = match zone.status {
+                            SdnStatus::Available => {
+                                row.with_child(Fa::new("check").class(FontColor::Success))
+                            }
+                            SdnStatus::Error => {
+                                row.with_child(Fa::new("times-circle").class(FontColor::Error))
+                            }
+                            _ => row,
+                        };
+
+                        row = row.with_child(zone.status);
+                    } else {
+                        row = row.with_child("");
+                    }
+
+                    row.into()
+                })
+                .into(),
+        ])
+    }
+}
+
+fn build_store_from_response(
+    remote_resources: Vec<RemoteResources>,
+) -> (SlabTree<ZoneTreeEntry>, Vec<String>) {
+    let mut tree = SlabTree::new();
+
+    let mut root = tree.set_root(ZoneTreeEntry::Root);
+    root.set_expanded(true);
+
+    let mut remote_errors = Vec::new();
+
+    for resources in remote_resources {
+        if let Some(error) = resources.error {
+            remote_errors.push(format!(
+                "could not fetch resources from remote {}: {error}",
+                resources.remote,
+            ));
+            continue;
+        }
+
+        let mut remote = root.append(ZoneTreeEntry::Remote(resources.remote.clone()));
+        remote.set_expanded(true);
+
+        for resource in resources.resources {
+            if let Resource::PveSdn(PveSdnResource::Zone(zone_resource)) = resource {
+                let node_entry = remote.children_mut().find(|entry| {
+                    if let ZoneTreeEntry::Node(_, name) = entry.record() {
+                        if name == &zone_resource.node {
+                            return true;
+                        }
+                    }
+
+                    false
+                });
+
+                let node_name = zone_resource.node.clone();
+
+                let entry =
+                    ZoneTreeEntry::from_zone_resource(resources.remote.clone(), zone_resource);
+
+                match node_entry {
+                    Some(mut node_entry) => {
+                        node_entry.append(entry);
+                    }
+                    None => {
+                        let mut node_entry =
+                            remote.append(ZoneTreeEntry::Node(resources.remote.clone(), node_name));
+
+                        node_entry.set_expanded(true);
+
+                        node_entry.append(entry);
+                    }
+                };
+            }
+        }
+    }
+
+    (tree, remote_errors)
+}
+
+impl LoadableComponent for ZoneTreeComponent {
+    type Properties = ZoneTree;
+    type Message = ZoneTreeMsg;
+    type ViewState = ();
+
+    fn create(_ctx: &LoadableComponentContext<Self>) -> Self {
+        let store = TreeStore::new().view_root(false);
+        store.set_sorter(default_sorter);
+
+        let selection = Selection::new();
+
+        Self {
+            store: store.clone(),
+            selection,
+            remote_errors: Vec::new(),
+            columns: Self::columns(store),
+        }
+    }
+
+    fn load(
+        &self,
+        ctx: &LoadableComponentContext<Self>,
+    ) -> Pin<Box<dyn Future<Output = Result<(), anyhow::Error>>>> {
+        let link = ctx.link().clone();
+
+        Box::pin(async move {
+            let client = pdm_client();
+            let remote_resources = client
+                .resources_by_type(None, ResourceType::PveSdnZone)
+                .await?;
+            link.send_message(Self::Message::LoadFinished(remote_resources));
+
+            Ok(())
+        })
+    }
+
+    fn update(&mut self, ctx: &LoadableComponentContext<Self>, msg: Self::Message) -> bool {
+        match msg {
+            Self::Message::LoadFinished(remote_resources) => {
+                let (data, remote_errors) = build_store_from_response(remote_resources);
+                self.store.write().update_root_tree(data);
+                self.store.set_sorter(default_sorter);
+
+                self.remote_errors = remote_errors;
+
+                return true;
+            }
+            Self::Message::Reload => {
+                ctx.link().send_reload();
+            }
+        }
+
+        false
+    }
+
+    fn toolbar(&self, ctx: &LoadableComponentContext<Self>) -> Option<Html> {
+        let on_refresh = ctx.link().callback(|_| ZoneTreeMsg::Reload);
+
+        Some(
+            Toolbar::new()
+                .class("pwt-w-100")
+                .class("pwt-overflow-hidden")
+                .class("pwt-border-bottom")
+                .with_flex_spacer()
+                .with_child(Button::refresh(ctx.loading()).onclick(on_refresh))
+                .into(),
+        )
+    }
+
+    fn main_view(&self, _ctx: &LoadableComponentContext<Self>) -> yew::Html {
+        let table = DataTable::new(self.columns.clone(), self.store.clone())
+            .selection(self.selection.clone())
+            .striped(false)
+            .class(css::FlexFit);
+
+        let mut column = Column::new().class(pwt::css::FlexFit).with_child(table);
+
+        for remote_error in &self.remote_errors {
+            column.add_child(error_message(remote_error));
+        }
+
+        column.into()
+    }
+}
-- 
2.47.3


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


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

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

Thread overview: 9+ messages (download: mbox.gz / follow: Atom feed)
-- links below jump to the message on this page --
2025-09-09 15:54 [pdm-devel] [PATCH manager/proxmox{-api-types, -datacenter-manager} v2 0/8] Add SDN resources to dashboard + SDN zone overview tree Stefan Hanreich
2025-09-09 15:54 ` [pdm-devel] [PATCH pve-manager v2 1/1] cluster: resources: add sdn property to cluster resources schema Stefan Hanreich
2025-09-09 15:54 ` [pdm-devel] [PATCH proxmox-api-types v2 1/2] cluster: resource: add sdn property Stefan Hanreich
2025-09-09 15:54 ` [pdm-devel] [PATCH proxmox-api-types v2 2/2] regenerate Stefan Hanreich
2025-09-09 15:54 ` [pdm-devel] [PATCH proxmox-datacenter-manager v2 1/5] pdm-api-types: add sdn cluster resource Stefan Hanreich
2025-09-09 15:54 ` [pdm-devel] [PATCH proxmox-datacenter-manager v2 2/5] server: api: add resource-type parameter to list_resources Stefan Hanreich
2025-09-09 15:54 ` [pdm-devel] [PATCH proxmox-datacenter-manager v2 3/5] ui: add sdn status report to dashboard Stefan Hanreich
2025-09-09 15:54 ` [pdm-devel] [PATCH proxmox-datacenter-manager v2 4/5] ui: images: add sdn icon Stefan Hanreich
2025-09-09 15:54 ` [pdm-devel] [PATCH proxmox-datacenter-manager v2 5/5] ui: sdn: add zone tree 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