all lists on lists.proxmox.com
 help / color / mirror / Atom feed
* [PATCH datacenter-manager v2 0/9] refactor and partially fix #7371
@ 2026-08-18 13:25 Dominik Csapak
  2026-08-18 13:25 ` [PATCH datacenter-manager v2 1/9] lib: api types: add new ResourceView type and move accessors there Dominik Csapak
                   ` (10 more replies)
  0 siblings, 11 replies; 16+ messages in thread
From: Dominik Csapak @ 2026-08-18 13:25 UTC (permalink / raw)
  To: pdm-devel

the first 6 patches are pure refactors and should not change anything
user visible, could be applied independently.

patch 7-9 partially fix #7371 by showing the HA maintenance status
of PVE nodes in various places.

changes from v1:
* fixed some display problems: NaN% when maxmem was set to 0.0,
  0.0% cpu when the node was offline, fixed by not showing values for
  offline nodes
* add a new patch for showing tha badge too in the node card's title
* various typo fixes
* added #[serde(default)] to the new hastate field to allow
  deserialization from older versions
* some smaller fixes  (left over comments, naming, etc.)

Dominik Csapak (9):
  lib: api types: add new ResourceView type and move accessors there
  lib: api types: resource: add 'node' helper to ResourceView
  lib: api types: add guest specific getter to ResourceView
  ui: pve: factor out the pve-manager version extraction
  ui: renderer: use ResourceView for rendering
  ui: pve: tree: reuse `PveResource` for `PveTreeNode`
  ui: pve: show ha maintenance mode for nodes
  ui: pve: node selector: show maintenance badge with node name
  ui: pve: node: show HA maintenance badge

 lib/pdm-api-types/src/resource.rs  | 301 ++++++++++++++++++++--------
 server/src/api/resources.rs        |   1 +
 ui/pwt-assets                      |   2 +-
 ui/src/dashboard/top_entities.rs   |   5 +-
 ui/src/guests.rs                   |   6 +-
 ui/src/lib.rs                      |  23 +--
 ui/src/pve/mod.rs                  |  73 ++++---
 ui/src/pve/node/mod.rs             |  15 +-
 ui/src/pve/tree.rs                 | 302 ++++++++++++++---------------
 ui/src/renderer.rs                 |  66 ++++---
 ui/src/widget/pve_node_selector.rs |  91 +++++----
 ui/src/widget/resource_tree.rs     |  11 +-
 12 files changed, 533 insertions(+), 363 deletions(-)

-- 
2.47.3





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

* [PATCH datacenter-manager v2 1/9] lib: api types: add new ResourceView type and move accessors there
  2026-08-18 13:25 [PATCH datacenter-manager v2 0/9] refactor and partially fix #7371 Dominik Csapak
@ 2026-08-18 13:25 ` Dominik Csapak
  2026-08-20 11:07   ` Lukas Wagner
  2026-08-18 13:25 ` [PATCH datacenter-manager v2 2/9] lib: api types: resource: add 'node' helper to ResourceView Dominik Csapak
                   ` (9 subsequent siblings)
  10 siblings, 1 reply; 16+ messages in thread
From: Dominik Csapak @ 2026-08-18 13:25 UTC (permalink / raw)
  To: pdm-devel

This is intended as a view type for Resource and PveResource, which will
always share some enum type. This way, we can reuse all accessors
cheaply for PveResource values without cloning or consuming.

This makes it easier to share code for both of them, for instance in the
UI where we want to render properties consistently for resources.

Introduces also a AsResourceView trait to make converting more
ergonomic.

Signed-off-by: Dominik Csapak <d.csapak@proxmox.com>
---
 lib/pdm-api-types/src/resource.rs | 261 +++++++++++++++++++++---------
 1 file changed, 184 insertions(+), 77 deletions(-)

diff --git a/lib/pdm-api-types/src/resource.rs b/lib/pdm-api-types/src/resource.rs
index 303ac3cb..4447aedb 100644
--- a/lib/pdm-api-types/src/resource.rs
+++ b/lib/pdm-api-types/src/resource.rs
@@ -45,102 +45,31 @@ impl Resource {
     /// Returns the local ID, not a globally unique one, e.g.
     /// `qemu/<vmid>`
     pub fn id(&self) -> String {
-        match self {
-            Resource::PveStorage(r) => format!("storage/{}/{}", r.node, r.storage),
-            Resource::PveQemu(r) => format!("qemu/{}", r.vmid),
-            Resource::PveLxc(r) => format!("lxc/{}", r.vmid),
-            Resource::PveNode(r) => format!("node/{}", r.node),
-            Resource::PveNetwork(r) => {
-                if let PveNetworkResource::Zone(z) = r {
-                    if z.legacy {
-                        return format!("sdn/{}/{}", r.node(), r.name());
-                    }
-                }
-
-                format!("network/{}/{}/{}", r.node(), r.network_type(), r.name())
-            }
-            Resource::PbsNode(r) => format!("node/{}", r.name),
-            Resource::PbsDatastore(r) => r.name.clone(),
-        }
+        self.as_resource_view().id()
     }
 
     /// Returns the PDM global ID for the resource, e.g.
     /// `remote/<remote-id>/guest/<vmid>`
     pub fn global_id(&self) -> &str {
-        match self {
-            Resource::PveStorage(r) => r.id.as_str(),
-            Resource::PveQemu(r) => r.id.as_str(),
-            Resource::PveLxc(r) => r.id.as_str(),
-            Resource::PveNode(r) => r.id.as_str(),
-            Resource::PveNetwork(r) => r.id(),
-            Resource::PbsNode(r) => r.id.as_str(),
-            Resource::PbsDatastore(r) => r.id.as_str(),
-        }
+        self.as_resource_view().global_id()
     }
 
     /// Returns the "name" of the resource, e.g. the guest name for VMs/Containers or
     /// the hostname for nodes
     pub fn name(&self) -> &str {
-        match self {
-            Resource::PveStorage(r) => r.storage.as_str(),
-            Resource::PveQemu(r) => r.name.as_str(),
-            Resource::PveLxc(r) => r.name.as_str(),
-            Resource::PveNode(r) => r.node.as_str(),
-            Resource::PveNetwork(r) => r.name(),
-            Resource::PbsNode(r) => r.name.as_str(),
-            Resource::PbsDatastore(r) => r.name.as_str(),
-        }
+        self.as_resource_view().name()
     }
 
     pub fn resource_type(&self) -> ResourceType {
-        match self {
-            Resource::PveStorage(_) => ResourceType::PveStorage,
-            Resource::PveQemu(_) => ResourceType::PveQemu,
-            Resource::PveLxc(_) => ResourceType::PveLxc,
-            Resource::PveNetwork(_) => ResourceType::PveNetwork,
-            Resource::PveNode(_) | Resource::PbsNode(_) => ResourceType::Node,
-            Resource::PbsDatastore(_) => ResourceType::PbsDatastore,
-        }
+        self.as_resource_view().resource_type()
     }
 
     pub fn status(&self) -> &str {
-        match self {
-            Resource::PveStorage(r) => r.status.as_str(),
-            Resource::PveQemu(r) => r.status.as_str(),
-            Resource::PveLxc(r) => r.status.as_str(),
-            Resource::PveNode(r) => r.status.as_str(),
-            Resource::PveNetwork(r) => r.status(),
-            Resource::PbsNode(r) => {
-                if r.uptime > 0 {
-                    "online"
-                } else {
-                    "offline"
-                }
-            }
-            Resource::PbsDatastore(r) => {
-                if r.maintenance.is_none() {
-                    "online"
-                } else {
-                    "under-maintenance"
-                }
-            }
-        }
+        self.as_resource_view().status()
     }
 
     pub fn properties(&self) -> String {
-        let mut properties = Vec::new();
-        if let Resource::PbsDatastore(r) = self {
-            if let Some(backend_type) = &r.backend_type {
-                properties.push(backend_type.to_string());
-            }
-            if r.backing_device.is_some() {
-                properties.push("removable".to_string());
-            }
-            if r.usage > PBS_DATASTORE_HIGH_USAGE_THRESHOLD {
-                properties.push("high-usage".to_string());
-            }
-        }
-        properties.join(",")
+        self.as_resource_view().properties()
     }
 }
 
@@ -229,6 +158,184 @@ pub enum PveResource {
     Network(PveNetworkResource),
 }
 
+/// A borrowed view of a resource that can come from a [Resource] or a [PveResource]
+/// This is useful for having a single implementation of field access regardless which
+/// of the types is used.
+#[derive(Clone, Debug, PartialEq)]
+pub enum ResourceView<'a> {
+    /// Storage resource in PVE.
+    PveStorage(&'a PveStorageResource),
+    /// QEMU guest resource in PVE.
+    PveQemu(&'a PveQemuResource),
+    /// LXC guest resource in PVE.
+    PveLxc(&'a PveLxcResource),
+    /// A PVE node.
+    PveNode(&'a PveNodeResource),
+    /// Network in PVE.
+    PveNetwork(&'a PveNetworkResource),
+    /// A PBS node.
+    PbsNode(&'a PbsNodeResource),
+    /// Datastore on a PBS node.
+    PbsDatastore(&'a PbsDatastoreResource),
+}
+
+impl<'a> ResourceView<'a> {
+    /// Returns the local ID, not a globally unique one, e.g.
+    /// `qemu/<vmid>`
+    pub fn id(&self) -> String {
+        match self {
+            ResourceView::PveStorage(r) => format!("storage/{}/{}", r.node, r.storage),
+            ResourceView::PveQemu(r) => format!("qemu/{}", r.vmid),
+            ResourceView::PveLxc(r) => format!("lxc/{}", r.vmid),
+            ResourceView::PveNode(r) => format!("node/{}", r.node),
+            ResourceView::PveNetwork(r) => {
+                if let PveNetworkResource::Zone(z) = r {
+                    if z.legacy {
+                        return format!("sdn/{}/{}", r.node(), r.name());
+                    }
+                }
+
+                format!("network/{}/{}/{}", r.node(), r.network_type(), r.name())
+            }
+            ResourceView::PbsNode(r) => format!("node/{}", r.name),
+            ResourceView::PbsDatastore(r) => r.name.clone(),
+        }
+    }
+
+    /// Returns the PDM global ID for the resource, e.g.
+    /// `remote/<remote-id>/guest/<vmid>`
+    pub fn global_id(&self) -> &'a str {
+        match self {
+            ResourceView::PveStorage(r) => r.id.as_str(),
+            ResourceView::PveQemu(r) => r.id.as_str(),
+            ResourceView::PveLxc(r) => r.id.as_str(),
+            ResourceView::PveNode(r) => r.id.as_str(),
+            ResourceView::PveNetwork(r) => r.id(),
+            ResourceView::PbsNode(r) => r.id.as_str(),
+            ResourceView::PbsDatastore(r) => r.id.as_str(),
+        }
+    }
+
+    /// Returns the "name" of the resource, e.g. the guest name for VMs/Containers or
+    /// the hostname for nodes
+    pub fn name(&self) -> &'a str {
+        match self {
+            ResourceView::PveStorage(r) => r.storage.as_str(),
+            ResourceView::PveQemu(r) => r.name.as_str(),
+            ResourceView::PveLxc(r) => r.name.as_str(),
+            ResourceView::PveNode(r) => r.node.as_str(),
+            ResourceView::PveNetwork(r) => r.name(),
+            ResourceView::PbsNode(r) => r.name.as_str(),
+            ResourceView::PbsDatastore(r) => r.name.as_str(),
+        }
+    }
+
+    pub fn resource_type(&self) -> ResourceType {
+        match self {
+            ResourceView::PveStorage(_) => ResourceType::PveStorage,
+            ResourceView::PveQemu(_) => ResourceType::PveQemu,
+            ResourceView::PveLxc(_) => ResourceType::PveLxc,
+            ResourceView::PveNetwork(_) => ResourceType::PveNetwork,
+            ResourceView::PveNode(_) | ResourceView::PbsNode(_) => ResourceType::Node,
+            ResourceView::PbsDatastore(_) => ResourceType::PbsDatastore,
+        }
+    }
+
+    pub fn status(&self) -> &'a str {
+        match self {
+            ResourceView::PveStorage(r) => r.status.as_str(),
+            ResourceView::PveQemu(r) => r.status.as_str(),
+            ResourceView::PveLxc(r) => r.status.as_str(),
+            ResourceView::PveNode(r) => r.status.as_str(),
+            ResourceView::PveNetwork(r) => r.status(),
+            ResourceView::PbsNode(r) => {
+                if r.uptime > 0 {
+                    "online"
+                } else {
+                    "offline"
+                }
+            }
+            ResourceView::PbsDatastore(r) => {
+                if r.maintenance.is_none() {
+                    "online"
+                } else {
+                    "under-maintenance"
+                }
+            }
+        }
+    }
+
+    pub fn properties(&self) -> String {
+        let mut properties = Vec::new();
+        if let ResourceView::PbsDatastore(r) = self {
+            if let Some(backend_type) = &r.backend_type {
+                properties.push(backend_type.to_string());
+            }
+            if r.backing_device.is_some() {
+                properties.push("removable".to_string());
+            }
+            if r.usage > PBS_DATASTORE_HIGH_USAGE_THRESHOLD {
+                properties.push("high-usage".to_string());
+            }
+        }
+        properties.join(",")
+    }
+}
+
+impl<'a> From<&'a Resource> for ResourceView<'a> {
+    fn from(value: &'a Resource) -> Self {
+        match value {
+            Resource::PveStorage(r) => ResourceView::PveStorage(r),
+            Resource::PveQemu(r) => ResourceView::PveQemu(r),
+            Resource::PveLxc(r) => ResourceView::PveLxc(r),
+            Resource::PveNode(r) => ResourceView::PveNode(r),
+            Resource::PveNetwork(r) => ResourceView::PveNetwork(r),
+            Resource::PbsNode(r) => ResourceView::PbsNode(r),
+            Resource::PbsDatastore(r) => ResourceView::PbsDatastore(r),
+        }
+    }
+}
+
+impl<'a> From<&'a PveResource> for ResourceView<'a> {
+    fn from(value: &'a PveResource) -> Self {
+        match value {
+            PveResource::Storage(r) => ResourceView::PveStorage(r),
+            PveResource::Qemu(r) => ResourceView::PveQemu(r),
+            PveResource::Lxc(r) => ResourceView::PveLxc(r),
+            PveResource::Node(r) => ResourceView::PveNode(r),
+            PveResource::Network(r) => ResourceView::PveNetwork(r),
+        }
+    }
+}
+
+pub trait AsResourceView {
+    fn as_resource_view(&self) -> ResourceView<'_>;
+}
+
+impl AsResourceView for Resource {
+    fn as_resource_view<'a>(&'a self) -> ResourceView<'a> {
+        ResourceView::from(self)
+    }
+}
+
+impl AsResourceView for &Resource {
+    fn as_resource_view<'a>(&'a self) -> ResourceView<'a> {
+        ResourceView::from(*self)
+    }
+}
+
+impl AsResourceView for PveResource {
+    fn as_resource_view<'a>(&'a self) -> ResourceView<'a> {
+        ResourceView::from(self)
+    }
+}
+
+impl AsResourceView for &PveResource {
+    fn as_resource_view<'a>(&'a self) -> ResourceView<'a> {
+        ResourceView::from(*self)
+    }
+}
+
 #[api(
     properties: {
         tags: {
-- 
2.47.3





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

* [PATCH datacenter-manager v2 2/9] lib: api types: resource: add 'node' helper to ResourceView
  2026-08-18 13:25 [PATCH datacenter-manager v2 0/9] refactor and partially fix #7371 Dominik Csapak
  2026-08-18 13:25 ` [PATCH datacenter-manager v2 1/9] lib: api types: add new ResourceView type and move accessors there Dominik Csapak
@ 2026-08-18 13:25 ` Dominik Csapak
  2026-08-18 13:25 ` [PATCH datacenter-manager v2 3/9] lib: api types: add guest specific getter " Dominik Csapak
                   ` (8 subsequent siblings)
  10 siblings, 0 replies; 16+ messages in thread
From: Dominik Csapak @ 2026-08-18 13:25 UTC (permalink / raw)
  To: pdm-devel

this is often needed independent of the underlying variant

Signed-off-by: Dominik Csapak <d.csapak@proxmox.com>
---
 lib/pdm-api-types/src/resource.rs | 13 +++++++++++++
 ui/src/dashboard/top_entities.rs  |  5 +++--
 ui/src/guests.rs                  |  6 +++---
 ui/src/lib.rs                     | 13 -------------
 ui/src/widget/resource_tree.rs    |  8 ++++----
 5 files changed, 23 insertions(+), 22 deletions(-)

diff --git a/lib/pdm-api-types/src/resource.rs b/lib/pdm-api-types/src/resource.rs
index 4447aedb..e4ce5b62 100644
--- a/lib/pdm-api-types/src/resource.rs
+++ b/lib/pdm-api-types/src/resource.rs
@@ -230,6 +230,19 @@ impl<'a> ResourceView<'a> {
         }
     }
 
+    /// Returns the cluster node the resource is located on. Returns `None` if it's a PBS resource
+    pub fn node(&self) -> Option<&'a str> {
+        let node = match self {
+            ResourceView::PveStorage(r) => &r.node,
+            ResourceView::PveQemu(r) => &r.node,
+            ResourceView::PveLxc(r) => &r.node,
+            ResourceView::PveNode(r) => &r.node,
+            ResourceView::PveNetwork(r) => r.node(),
+            ResourceView::PbsNode(_) | ResourceView::PbsDatastore(_) => return None,
+        };
+        Some(node)
+    }
+
     pub fn resource_type(&self) -> ResourceType {
         match self {
             ResourceView::PveStorage(_) => ResourceType::PveStorage,
diff --git a/ui/src/dashboard/top_entities.rs b/ui/src/dashboard/top_entities.rs
index 634be1d6..cdc3b465 100644
--- a/ui/src/dashboard/top_entities.rs
+++ b/ui/src/dashboard/top_entities.rs
@@ -18,13 +18,14 @@ use pwt::{
     widget::{ActionIcon, Column, Container, Panel, Row, error_message},
 };
 
+use pdm_api_types::resource::AsResourceView;
 use pdm_api_types::views::LeaderboardType;
 use pdm_client::types::{Resource, TopEntity};
 
 use crate::LoadResult;
 use crate::{
     dashboard::{create_title_with_icon, loading_column},
-    get_deep_url, get_resource_node, navigate_to,
+    get_deep_url, navigate_to,
     renderer::{render_resource_icon, render_resource_name},
 };
 
@@ -138,7 +139,7 @@ impl Component for TopEntitiesComp {
             let rrd = &entity.rrd_data;
             let remote = &entity.remote;
 
-            let node = get_resource_node(resource).map(|n| n.to_string());
+            let node = resource.as_resource_view().node().map(|n| n.to_string());
 
             let tooltip_anchor = if let Some(info) = self.tooltip_info.as_ref() {
                 if info.id == resource.global_id() {
diff --git a/ui/src/guests.rs b/ui/src/guests.rs
index ef670a00..b40b1866 100644
--- a/ui/src/guests.rs
+++ b/ui/src/guests.rs
@@ -39,14 +39,14 @@ use pwt::widget::{
 };
 
 use pdm_api_types::RemoteUpid;
-use pdm_api_types::resource::{RemoteResources, Resource};
+use pdm_api_types::resource::{AsResourceView, RemoteResources, Resource};
 use pdm_search::SearchTerm;
 
 use crate::pve::utils::{guest_is_live, guest_status_label, render_guest_tags};
 use crate::pve::{GuestInfo, GuestType};
 use crate::renderer::{empty_state, render_resource_name, render_status_icon, render_tree_column};
 use crate::{
-    get_deep_url, get_resource_node,
+    get_deep_url,
     widget::{MigrateWindow, SnapshotWindow},
 };
 
@@ -161,7 +161,7 @@ impl GuestEntry {
     }
 
     fn node(&self) -> &str {
-        get_resource_node(&self.resource).unwrap_or("")
+        self.resource.as_resource_view().node().unwrap_or("")
     }
 
     fn guest_info(&self) -> GuestInfo {
diff --git a/ui/src/lib.rs b/ui/src/lib.rs
index fedbfee5..6e89f044 100644
--- a/ui/src/lib.rs
+++ b/ui/src/lib.rs
@@ -5,7 +5,6 @@ use pdm_api_types::remote_updates::RemoteUpdateSummary;
 use pdm_api_types::remotes::RemoteType;
 use pdm_api_types::resource::{PveLxcResource, PveQemuResource};
 use pdm_api_types::subscription::PdmSubscriptionInfo;
-use pdm_client::types::Resource;
 use proxmox_deb_version::Version;
 use pwt::props::ContainerBuilder;
 use pwt::tr;
@@ -221,18 +220,6 @@ pub(crate) fn navigate_to<C: yew::Component>(
     }
 }
 
-pub(crate) fn get_resource_node(resource: &Resource) -> Option<&str> {
-    match resource {
-        Resource::PveStorage(storage) => Some(&storage.node),
-        Resource::PveQemu(qemu) => Some(&qemu.node),
-        Resource::PveLxc(lxc) => Some(&lxc.node),
-        Resource::PveNode(node) => Some(&node.node),
-        Resource::PveNetwork(network) => Some(network.node()),
-        Resource::PbsNode(_) => None,
-        Resource::PbsDatastore(_) => None,
-    }
-}
-
 /// Wrapper to 'locale compare' to strings
 ///
 /// Note: The first parameter must be a [`String`], since it needs to be converted to a [`js_sys::JsString`].
diff --git a/ui/src/widget/resource_tree.rs b/ui/src/widget/resource_tree.rs
index 3ba10a7b..337d8e23 100644
--- a/ui/src/widget/resource_tree.rs
+++ b/ui/src/widget/resource_tree.rs
@@ -28,12 +28,12 @@ use pwt_macros::{builder, widget};
 
 use proxmox_yew_comp::{Status, http_get};
 
-use pdm_api_types::resource::{RemoteResources, Resource};
+use pdm_api_types::resource::{AsResourceView, RemoteResources, Resource};
 
 use crate::{
     RemoteList,
     dashboard::view::ViewContext,
-    get_deep_url, get_resource_node,
+    get_deep_url,
     pve::utils::render_guest_tags,
     renderer::{render_resource_name, render_status_icon},
 };
@@ -420,7 +420,7 @@ fn columns(
                 match item {
                     PdmTreeEntry::Root => "",
                     PdmTreeEntry::Resource(_, resource) => {
-                        get_resource_node(resource).unwrap_or("")
+                        resource.as_resource_view().node().unwrap_or("")
                     }
                     PdmTreeEntry::Remote(_, _) => "",
                 }
@@ -436,7 +436,7 @@ fn columns(
                     let (remote, id, node) = match item {
                         PdmTreeEntry::Root => return html! {},
                         PdmTreeEntry::Resource(remote_id, resource) => {
-                            (remote_id, resource.id(), get_resource_node(resource))
+                            (remote_id, resource.id(), resource.as_resource_view().node())
                         }
                         PdmTreeEntry::Remote(remote_id, _) => (remote_id, String::new(), None),
                     };
-- 
2.47.3





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

* [PATCH datacenter-manager v2 3/9] lib: api types: add guest specific getter to ResourceView
  2026-08-18 13:25 [PATCH datacenter-manager v2 0/9] refactor and partially fix #7371 Dominik Csapak
  2026-08-18 13:25 ` [PATCH datacenter-manager v2 1/9] lib: api types: add new ResourceView type and move accessors there Dominik Csapak
  2026-08-18 13:25 ` [PATCH datacenter-manager v2 2/9] lib: api types: resource: add 'node' helper to ResourceView Dominik Csapak
@ 2026-08-18 13:25 ` Dominik Csapak
  2026-08-18 13:25 ` [PATCH datacenter-manager v2 4/9] ui: pve: factor out the pve-manager version extraction Dominik Csapak
                   ` (7 subsequent siblings)
  10 siblings, 0 replies; 16+ messages in thread
From: Dominik Csapak @ 2026-08-18 13:25 UTC (permalink / raw)
  To: pdm-devel

This is useful for the UI to get the VMID and guest type independent of
the exact resource type.

Signed-off-by: Dominik Csapak <d.csapak@proxmox.com>
---
 lib/pdm-api-types/src/resource.rs | 18 ++++++++++++++++++
 ui/src/lib.rs                     | 10 +++++-----
 2 files changed, 23 insertions(+), 5 deletions(-)

diff --git a/lib/pdm-api-types/src/resource.rs b/lib/pdm-api-types/src/resource.rs
index e4ce5b62..c433a284 100644
--- a/lib/pdm-api-types/src/resource.rs
+++ b/lib/pdm-api-types/src/resource.rs
@@ -243,6 +243,24 @@ impl<'a> ResourceView<'a> {
         Some(node)
     }
 
+    /// Returns the VMID if the resource has one. Returns `None` for non guest resources.
+    pub fn vmid(&self) -> Option<u32> {
+        match self {
+            ResourceView::PveQemu(qemu) => Some(qemu.vmid),
+            ResourceView::PveLxc(lxc) => Some(lxc.vmid),
+            _ => None,
+        }
+    }
+
+    /// Returns the guest type if the resource has one. Returns `None` for non guest resources.
+    pub fn guest_type(&self) -> Option<GuestType> {
+        match self {
+            ResourceView::PveQemu(_) => Some(GuestType::Qemu),
+            ResourceView::PveLxc(_) => Some(GuestType::Lxc),
+            _ => None,
+        }
+    }
+
     pub fn resource_type(&self) -> ResourceType {
         match self {
             ResourceView::PveStorage(_) => ResourceType::PveStorage,
diff --git a/ui/src/lib.rs b/ui/src/lib.rs
index 6e89f044..d42ef42f 100644
--- a/ui/src/lib.rs
+++ b/ui/src/lib.rs
@@ -3,7 +3,7 @@ use gloo_utils::window;
 use js_sys::{Array, JsString, Object};
 use pdm_api_types::remote_updates::RemoteUpdateSummary;
 use pdm_api_types::remotes::RemoteType;
-use pdm_api_types::resource::{PveLxcResource, PveQemuResource};
+use pdm_api_types::resource::AsResourceView;
 use pdm_api_types::subscription::PdmSubscriptionInfo;
 use proxmox_deb_version::Version;
 use pwt::props::ContainerBuilder;
@@ -190,11 +190,11 @@ pub(crate) fn navigate_to<C: yew::Component>(
     if let Some(nav) = link.navigator() {
         let (prefix, id) = resource
             .and_then(|resource| {
+                if let Some(vmid) = resource.as_resource_view().vmid() {
+                    return Some((true, format!("guest+{vmid}")));
+                }
+
                 Some(match resource {
-                    pdm_client::types::Resource::PveQemu(PveQemuResource { vmid, .. })
-                    | pdm_client::types::Resource::PveLxc(PveLxcResource { vmid, .. }) => {
-                        (true, format!("guest+{vmid}"))
-                    }
                     pdm_client::types::Resource::PveNode(node) => {
                         (true, format!("node+{}", node.node))
                     }
-- 
2.47.3





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

* [PATCH datacenter-manager v2 4/9] ui: pve: factor out the pve-manager version extraction
  2026-08-18 13:25 [PATCH datacenter-manager v2 0/9] refactor and partially fix #7371 Dominik Csapak
                   ` (2 preceding siblings ...)
  2026-08-18 13:25 ` [PATCH datacenter-manager v2 3/9] lib: api types: add guest specific getter " Dominik Csapak
@ 2026-08-18 13:25 ` Dominik Csapak
  2026-08-18 13:25 ` [PATCH datacenter-manager v2 5/9] ui: renderer: use ResourceView for rendering Dominik Csapak
                   ` (6 subsequent siblings)
  10 siblings, 0 replies; 16+ messages in thread
From: Dominik Csapak @ 2026-08-18 13:25 UTC (permalink / raw)
  To: pdm-devel

This was done three times in the same way. Simply refactor it into
its own method.

Signed-off-by: Dominik Csapak <d.csapak@proxmox.com>
---
 ui/src/pve/mod.rs | 32 +++++++++++++-------------------
 1 file changed, 13 insertions(+), 19 deletions(-)

diff --git a/ui/src/pve/mod.rs b/ui/src/pve/mod.rs
index f069ec75..1c32d26f 100644
--- a/ui/src/pve/mod.rs
+++ b/ui/src/pve/mod.rs
@@ -25,6 +25,7 @@ use pdm_api_types::{
     remote_updates::RemoteUpdateSummary,
     resource::{PveResource, ResourceType},
 };
+use proxmox_deb_version::Version;
 
 use crate::remotes::RemoteCertCheck;
 use crate::{LoadResult, extract_package_version, get_deep_url, get_remote};
@@ -158,6 +159,13 @@ pub struct PveRemoteComp {
     updates: LoadResult<RemoteUpdateSummary, Error>,
 }
 
+impl PveRemoteComp {
+    fn get_pve_manager_version(&self, node: &str) -> Option<Version> {
+        let updates = self.updates.data.as_ref()?;
+        extract_package_version(updates, node, "pve-manager")
+    }
+}
+
 pwt::impl_deref_mut_property!(PveRemoteComp, state, LoadableComponentState<ViewState>);
 
 impl LoadableComponent for PveRemoteComp {
@@ -206,31 +214,17 @@ impl LoadableComponent for PveRemoteComp {
 
         let content: Html = match &self.view {
             PveTreeNode::Root => remote::PveRemotePanel::new(remote.clone()).into(),
-            PveTreeNode::Node(node) => {
-                let pve_manager = match &self.updates.data {
-                    Some(updates) => extract_package_version(updates, &node.node, "pve-manager"),
-                    None => None,
-                };
-                node::PveNodePanel::new(remote.clone(), node.node.clone())
-                    .pve_manager_version(pve_manager)
-                    .into()
-            }
+            PveTreeNode::Node(node) => node::PveNodePanel::new(remote.clone(), node.node.clone())
+                .pve_manager_version(self.get_pve_manager_version(&node.node))
+                .into(),
             PveTreeNode::Qemu(qemu) => {
-                let pve_manager = match &self.updates.data {
-                    Some(updates) => extract_package_version(updates, &qemu.node, "pve-manager"),
-                    None => None,
-                };
                 qemu::QemuPanel::new(remote.clone(), qemu.node.clone(), qemu.clone())
-                    .pve_manager_version(pve_manager)
+                    .pve_manager_version(self.get_pve_manager_version(&qemu.node))
                     .into()
             }
             PveTreeNode::Lxc(lxc) => {
-                let pve_manager = match &self.updates.data {
-                    Some(updates) => extract_package_version(updates, &lxc.node, "pve-manager"),
-                    None => None,
-                };
                 lxc::LxcPanel::new(remote.clone(), lxc.node.clone(), lxc.clone())
-                    .pve_manager_version(pve_manager)
+                    .pve_manager_version(self.get_pve_manager_version(&lxc.node))
                     .into()
             }
             PveTreeNode::Storage(storage) => {
-- 
2.47.3





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

* [PATCH datacenter-manager v2 5/9] ui: renderer: use ResourceView for rendering
  2026-08-18 13:25 [PATCH datacenter-manager v2 0/9] refactor and partially fix #7371 Dominik Csapak
                   ` (3 preceding siblings ...)
  2026-08-18 13:25 ` [PATCH datacenter-manager v2 4/9] ui: pve: factor out the pve-manager version extraction Dominik Csapak
@ 2026-08-18 13:25 ` Dominik Csapak
  2026-08-18 13:25 ` [PATCH datacenter-manager v2 6/9] ui: pve: tree: reuse `PveResource` for `PveTreeNode` Dominik Csapak
                   ` (5 subsequent siblings)
  10 siblings, 0 replies; 16+ messages in thread
From: Dominik Csapak @ 2026-08-18 13:25 UTC (permalink / raw)
  To: pdm-devel

A `Resource` can be converted to the `ResourceView` and the data
returned is identical, so there is no need to change any current caller,
but it's now possible to call it with a `PveResource` too.

Signed-off-by: Dominik Csapak <d.csapak@proxmox.com>
---
 ui/src/renderer.rs | 52 +++++++++++++++++++++++-----------------------
 1 file changed, 26 insertions(+), 26 deletions(-)

diff --git a/ui/src/renderer.rs b/ui/src/renderer.rs
index be3a94b4..0eecc889 100644
--- a/ui/src/renderer.rs
+++ b/ui/src/renderer.rs
@@ -6,43 +6,43 @@ use pwt::widget::{Column, Container, Fa, Row};
 
 use proxmox_human_byte::HumanByte;
 
-use pdm_client::types::Resource;
+use pdm_api_types::resource::{AsResourceView, ResourceView};
 
 use crate::pve;
 
-pub fn render_resource_name(resource: &Resource, vmid_first: bool) -> String {
-    match resource {
-        Resource::PveStorage(storage) => storage.storage.clone(),
-        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::PveNetwork(network) => network.name().to_string(),
-        Resource::PbsNode(node) => node.name.clone(),
-        Resource::PbsDatastore(store) => store.name.clone(),
+pub fn render_resource_name(resource: impl AsResourceView, vmid_first: bool) -> String {
+    match resource.as_resource_view() {
+        ResourceView::PveStorage(storage) => storage.storage.clone(),
+        ResourceView::PveQemu(qemu) => pve::utils::render_qemu_name(qemu, vmid_first),
+        ResourceView::PveLxc(lxc) => pve::utils::render_lxc_name(lxc, vmid_first),
+        ResourceView::PveNode(node) => node.node.clone(),
+        ResourceView::PveNetwork(network) => network.name().to_string(),
+        ResourceView::PbsNode(node) => node.name.clone(),
+        ResourceView::PbsDatastore(store) => store.name.clone(),
     }
 }
 
-pub fn render_resource_icon(resource: &Resource) -> Fa {
-    let class = match resource {
-        Resource::PveStorage(_) => "database",
-        Resource::PveQemu(_) => "desktop",
-        Resource::PveLxc(_) => "cube",
-        Resource::PveNode(_) => "building",
-        Resource::PveNetwork(_) => "fa-sdn",
-        Resource::PbsNode(_) => "building-o",
-        Resource::PbsDatastore(_) => "floppy-o",
+pub fn render_resource_icon(resource: impl AsResourceView) -> Fa {
+    let class = match resource.as_resource_view() {
+        ResourceView::PveStorage(_) => "database",
+        ResourceView::PveQemu(_) => "desktop",
+        ResourceView::PveLxc(_) => "cube",
+        ResourceView::PveNode(_) => "building",
+        ResourceView::PveNetwork(_) => "fa-sdn",
+        ResourceView::PbsNode(_) => "building-o",
+        ResourceView::PbsDatastore(_) => "floppy-o",
     };
 
     Fa::new(class)
 }
 
-pub fn render_status_icon(resource: &Resource) -> Container {
-    match resource {
-        Resource::PveStorage(store) => pve::utils::render_storage_status_icon(store),
-        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::PveNetwork(network) => pve::utils::render_sdn_status_icon(network),
+pub fn render_status_icon(resource: impl AsResourceView) -> Container {
+    match resource.as_resource_view() {
+        ResourceView::PveStorage(store) => pve::utils::render_storage_status_icon(store),
+        ResourceView::PveQemu(qemu) => pve::utils::render_qemu_status_icon(qemu),
+        ResourceView::PveLxc(lxc) => pve::utils::render_lxc_status_icon(lxc),
+        ResourceView::PveNode(node) => pve::utils::render_node_status_icon(node),
+        ResourceView::PveNetwork(network) => pve::utils::render_sdn_status_icon(network),
         // FIXME: implement remaining types
         _ => Container::new().with_child(render_resource_icon(resource)),
     }
-- 
2.47.3





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

* [PATCH datacenter-manager v2 6/9] ui: pve: tree: reuse `PveResource` for `PveTreeNode`
  2026-08-18 13:25 [PATCH datacenter-manager v2 0/9] refactor and partially fix #7371 Dominik Csapak
                   ` (4 preceding siblings ...)
  2026-08-18 13:25 ` [PATCH datacenter-manager v2 5/9] ui: renderer: use ResourceView for rendering Dominik Csapak
@ 2026-08-18 13:25 ` Dominik Csapak
  2026-08-18 13:25 ` [PATCH datacenter-manager v2 7/9] ui: pve: show ha maintenance mode for nodes Dominik Csapak
                   ` (4 subsequent siblings)
  10 siblings, 0 replies; 16+ messages in thread
From: Dominik Csapak @ 2026-08-18 13:25 UTC (permalink / raw)
  To: pdm-devel

This has some advantages: We can't forget types that are represented in
the backend (as we already did with Network for example). And with the
new `ResourceView` type, we can reuse methods that were made for
`Resource`. The inner types are the same anyway, so this does not change
what data we can show.

While changing the PveTreeNode, refactor some code that can profit from
the newly abstracted ResourceView to make it more consistent with other
parts of PDM (like the ResourceTree) and to save some lines.

Signed-off-by: Dominik Csapak <d.csapak@proxmox.com>
---
 ui/src/pve/mod.rs  |  40 ++++---
 ui/src/pve/tree.rs | 293 +++++++++++++++++++++------------------------
 2 files changed, 158 insertions(+), 175 deletions(-)

diff --git a/ui/src/pve/mod.rs b/ui/src/pve/mod.rs
index 1c32d26f..340fc731 100644
--- a/ui/src/pve/mod.rs
+++ b/ui/src/pve/mod.rs
@@ -214,23 +214,31 @@ impl LoadableComponent for PveRemoteComp {
 
         let content: Html = match &self.view {
             PveTreeNode::Root => remote::PveRemotePanel::new(remote.clone()).into(),
-            PveTreeNode::Node(node) => node::PveNodePanel::new(remote.clone(), node.node.clone())
-                .pve_manager_version(self.get_pve_manager_version(&node.node))
+            PveTreeNode::Resource(r) => match r {
+                PveResource::Node(node) => {
+                    node::PveNodePanel::new(remote.clone(), node.node.clone())
+                        .pve_manager_version(self.get_pve_manager_version(&node.node))
+                        .into()
+                }
+                PveResource::Qemu(qemu) => {
+                    qemu::QemuPanel::new(remote.clone(), qemu.node.clone(), qemu.clone())
+                        .pve_manager_version(self.get_pve_manager_version(&qemu.node))
+                        .into()
+                }
+                PveResource::Lxc(lxc) => {
+                    lxc::LxcPanel::new(remote.clone(), lxc.node.clone(), lxc.clone())
+                        .pve_manager_version(self.get_pve_manager_version(&lxc.node))
+                        .into()
+                }
+                PveResource::Storage(storage) => storage::StoragePanel::new(
+                    remote.clone(),
+                    storage.node.clone(),
+                    storage.clone(),
+                )
                 .into(),
-            PveTreeNode::Qemu(qemu) => {
-                qemu::QemuPanel::new(remote.clone(), qemu.node.clone(), qemu.clone())
-                    .pve_manager_version(self.get_pve_manager_version(&qemu.node))
-                    .into()
-            }
-            PveTreeNode::Lxc(lxc) => {
-                lxc::LxcPanel::new(remote.clone(), lxc.node.clone(), lxc.clone())
-                    .pve_manager_version(self.get_pve_manager_version(&lxc.node))
-                    .into()
-            }
-            PveTreeNode::Storage(storage) => {
-                storage::StoragePanel::new(remote.clone(), storage.node.clone(), storage.clone())
-                    .into()
-            }
+                // the tree does not show the networks currently
+                PveResource::Network(_) => Container::new().into(),
+            },
         };
         let content = NavigationContainer::new().with_child(content);
 
diff --git a/ui/src/pve/tree.rs b/ui/src/pve/tree.rs
index 0042331c..4ea5716a 100644
--- a/ui/src/pve/tree.rs
+++ b/ui/src/pve/tree.rs
@@ -24,33 +24,31 @@ use pwt::{prelude::*, widget::Button};
 
 use pdm_api_types::{
     RemoteUpid,
-    resource::{PveLxcResource, PveNodeResource, PveQemuResource, PveResource, PveStorageResource},
+    resource::{AsResourceView, PveNodeResource, PveResource},
 };
 
-use crate::{get_deep_url, renderer::render_tree_column, widget::MigrateWindow};
+use crate::{
+    get_deep_url,
+    renderer::{render_resource_name, render_status_icon, render_tree_column},
+    widget::MigrateWindow,
+};
 
 use super::{
     GuestInfo, GuestType,
-    utils::{self, render_guest_tags, render_lxc_name, render_qemu_name},
+    utils::{self, render_guest_tags},
 };
 
 #[derive(Clone, PartialEq)]
 pub enum PveTreeNode {
     Root,
-    Node(PveNodeResource),
-    Lxc(PveLxcResource),
-    Qemu(PveQemuResource),
-    Storage(PveStorageResource),
+    Resource(PveResource),
 }
 
 impl ExtractPrimaryKey for PveTreeNode {
     fn extract_key(&self) -> Key {
         Key::from(match self {
             PveTreeNode::Root => "__root__",
-            PveTreeNode::Node(node) => node.id.as_str(),
-            PveTreeNode::Lxc(lxc) => lxc.id.as_str(),
-            PveTreeNode::Qemu(qemu) => qemu.id.as_str(),
-            PveTreeNode::Storage(storage) => storage.id.as_str(),
+            PveTreeNode::Resource(resource) => resource.as_resource_view().global_id(),
         })
     }
 }
@@ -59,12 +57,15 @@ impl PveTreeNode {
     fn get_path(&self) -> String {
         match self {
             PveTreeNode::Root => "datacenter".to_string(),
-            PveTreeNode::Node(node) => format!("node+{}", node.node),
-            PveTreeNode::Lxc(lxc) => format!("guest+{}", lxc.vmid),
-            PveTreeNode::Qemu(qemu) => format!("guest+{}", qemu.vmid),
-            PveTreeNode::Storage(storage) => {
-                format!("storage+{}+{}", storage.node, storage.storage)
-            }
+            PveTreeNode::Resource(resource) => match resource {
+                PveResource::Node(node) => format!("node+{}", node.node),
+                PveResource::Lxc(lxc) => format!("guest+{}", lxc.vmid),
+                PveResource::Qemu(qemu) => format!("guest+{}", qemu.vmid),
+                PveResource::Storage(storage) => {
+                    format!("storage+{}+{}", storage.node, storage.storage)
+                }
+                PveResource::Network(network) => format!("network+{}", network.name()),
+            },
         }
     }
 }
@@ -163,39 +164,16 @@ impl PveTreeComp {
                     let key = Key::from(node_info.id.as_str());
 
                     if let Some(mut node) = root.find_node_by_key_mut(&key) {
-                        *node.record_mut() = PveTreeNode::Node(node_info.clone());
+                        *node.record_mut() = PveTreeNode::Resource(entry.clone());
                     } else {
-                        root.append(PveTreeNode::Node(node_info.clone()));
-                    }
-                }
-                PveResource::Qemu(qemu_info) => {
-                    let node_id = format!("remote/{}/node/{}", remote, qemu_info.node);
-                    let key = Key::from(node_id.as_str());
-                    let mut node = match root.find_node_by_key_mut(&key) {
-                        Some(node) => node,
-                        None => root.append(create_empty_node(node_id)),
-                    };
-
-                    if !self.loaded {
-                        node.set_expanded(true);
+                        root.append(PveTreeNode::Resource(entry.clone()));
                     }
-                    node.append(PveTreeNode::Qemu(qemu_info.clone()));
                 }
-                PveResource::Lxc(lxc_info) => {
-                    let node_id = format!("remote/{}/node/{}", remote, lxc_info.node);
-                    let key = Key::from(node_id.as_str());
-                    let mut node = match root.find_node_by_key_mut(&key) {
-                        Some(node) => node,
-                        None => root.append(create_empty_node(node_id)),
-                    };
+                PveResource::Qemu(_) | PveResource::Lxc(_) | PveResource::Storage(_) => {
+                    // PVE resources always have a node
+                    let node = entry.as_resource_view().node().unwrap();
+                    let node_id = format!("remote/{remote}/node/{node}");
 
-                    if !self.loaded {
-                        node.set_expanded(true);
-                    }
-                    node.append(PveTreeNode::Lxc(lxc_info.clone()));
-                }
-                PveResource::Storage(storage) => {
-                    let node_id = format!("remote/{}/node/{}", remote, storage.node);
                     let key = Key::from(node_id.as_str());
                     let mut node = match root.find_node_by_key_mut(&key) {
                         Some(node) => node,
@@ -205,7 +183,7 @@ impl PveTreeComp {
                     if !self.loaded {
                         node.set_expanded(true);
                     }
-                    node.append(PveTreeNode::Storage(storage.clone()));
+                    node.append(PveTreeNode::Resource(entry.clone()));
                 }
                 PveResource::Network(_) => {}
             }
@@ -227,24 +205,29 @@ impl PveTreeComp {
             (PveTreeNode::Root, PveTreeNode::Root) => std::cmp::Ordering::Equal,
             (PveTreeNode::Root, _) => std::cmp::Ordering::Less,
             (_, PveTreeNode::Root) => std::cmp::Ordering::Greater,
-            (PveTreeNode::Node(a), PveTreeNode::Node(b)) => a.node.cmp(&b.node),
-            (PveTreeNode::Node(_), _) => std::cmp::Ordering::Less,
-            (_, PveTreeNode::Node(_)) => std::cmp::Ordering::Greater,
-            (PveTreeNode::Lxc(a), PveTreeNode::Lxc(b)) => {
-                cmp_guests(a.template, b.template, a.vmid, b.vmid)
-            }
-            (PveTreeNode::Lxc(_), PveTreeNode::Qemu(_)) => std::cmp::Ordering::Less,
-            (PveTreeNode::Qemu(_), PveTreeNode::Lxc(_)) => std::cmp::Ordering::Greater,
-            (PveTreeNode::Qemu(a), PveTreeNode::Qemu(b)) => {
-                cmp_guests(a.template, b.template, a.vmid, b.vmid)
-            }
-            (PveTreeNode::Lxc(_) | PveTreeNode::Qemu(_), PveTreeNode::Storage(_)) => {
-                std::cmp::Ordering::Less
-            }
-            (PveTreeNode::Storage(_), PveTreeNode::Lxc(_) | PveTreeNode::Qemu(_)) => {
-                std::cmp::Ordering::Greater
-            }
-            (PveTreeNode::Storage(a), PveTreeNode::Storage(b)) => a.id.cmp(&b.id),
+            (PveTreeNode::Resource(a), PveTreeNode::Resource(b)) => match (a, b) {
+                (PveResource::Node(a), PveResource::Node(b)) => a.node.cmp(&b.node),
+                (PveResource::Node(_), _) => std::cmp::Ordering::Less,
+                (_, PveResource::Node(_)) => std::cmp::Ordering::Greater,
+                (PveResource::Lxc(a), PveResource::Lxc(b)) => {
+                    cmp_guests(a.template, b.template, a.vmid, b.vmid)
+                }
+                (PveResource::Lxc(_), PveResource::Qemu(_)) => std::cmp::Ordering::Less,
+                (PveResource::Qemu(_), PveResource::Lxc(_)) => std::cmp::Ordering::Greater,
+                (PveResource::Qemu(a), PveResource::Qemu(b)) => {
+                    cmp_guests(a.template, b.template, a.vmid, b.vmid)
+                }
+                (PveResource::Lxc(_) | PveResource::Qemu(_), PveResource::Storage(_)) => {
+                    std::cmp::Ordering::Less
+                }
+                (PveResource::Storage(_), PveResource::Lxc(_) | PveResource::Qemu(_)) => {
+                    std::cmp::Ordering::Greater
+                }
+                (PveResource::Storage(a), PveResource::Storage(b)) => a.id.cmp(&b.id),
+                (PveResource::Network(a), PveResource::Network(b)) => a.id().cmp(b.id()),
+                (PveResource::Network(_), _) => std::cmp::Ordering::Greater,
+                (_, PveResource::Network(_)) => std::cmp::Ordering::Less,
+            },
         });
         let first_id = root
             .children()
@@ -344,54 +327,49 @@ impl LoadableComponent for PveTreeComp {
                 let record = node.record().clone();
                 let link = ctx.link().clone();
 
-                match record {
-                    PveTreeNode::Lxc(r) => ctx.link().spawn(async move {
-                        let res = match action {
-                            Action::Start => {
-                                crate::pdm_client()
-                                    .pve_lxc_start(&remote, Some(&r.node), r.vmid)
-                                    .await
-                            }
-                            Action::Shutdown => {
-                                crate::pdm_client()
-                                    .pve_lxc_shutdown(&remote, Some(&r.node), r.vmid)
-                                    .await
-                            }
-                            // LXC resume is not exposed yet; the UI never offers it.
-                            Action::Resume => return,
-                        };
+                let PveTreeNode::Resource(resource) = record else {
+                    return false;
+                };
+                let resource = resource.as_resource_view();
 
-                        match res {
-                            Ok(upid) => link.show_task_progress(upid.to_string()),
-                            Err(err) => link.show_error(tr!("Error"), err.to_string(), true),
-                        }
-                    }),
-                    PveTreeNode::Qemu(r) => ctx.link().spawn(async move {
-                        let res = match action {
-                            Action::Start => {
-                                crate::pdm_client()
-                                    .pve_qemu_start(&remote, Some(&r.node), r.vmid)
-                                    .await
-                            }
-                            Action::Shutdown => {
-                                crate::pdm_client()
-                                    .pve_qemu_shutdown(&remote, Some(&r.node), r.vmid)
-                                    .await
-                            }
-                            Action::Resume => {
-                                crate::pdm_client()
-                                    .pve_qemu_resume(&remote, Some(&r.node), r.vmid)
-                                    .await
-                            }
-                        };
+                let Some(guest_node) = resource.node().map(|n| n.to_string()) else {
+                    return false;
+                };
+                let Some(vmid) = resource.vmid() else {
+                    return false;
+                };
+                let Some(guest_type) = resource.guest_type() else {
+                    return false;
+                };
 
-                        match res {
-                            Ok(upid) => link.show_task_progress(upid.to_string()),
-                            Err(err) => link.show_error(tr!("Error"), err.to_string(), true),
+                ctx.link().spawn(async move {
+                    let client = crate::pdm_client();
+                    let node = Some(guest_node.as_str());
+                    let res = match (guest_type.into(), action) {
+                        (GuestType::Qemu, Action::Start) => {
+                            client.pve_qemu_start(&remote, node, vmid).await
                         }
-                    }),
-                    _ => {}
-                }
+                        (GuestType::Qemu, Action::Shutdown) => {
+                            client.pve_qemu_shutdown(&remote, node, vmid).await
+                        }
+                        (GuestType::Qemu, Action::Resume) => {
+                            client.pve_qemu_resume(&remote, node, vmid).await
+                        }
+                        (GuestType::Lxc, Action::Start) => {
+                            client.pve_lxc_start(&remote, node, vmid).await
+                        }
+                        (GuestType::Lxc, Action::Shutdown) => {
+                            client.pve_lxc_shutdown(&remote, node, vmid).await
+                        }
+                        // LXC resume is not exposed yet, the UI never offers it.
+                        (GuestType::Lxc, Action::Resume) => return,
+                    };
+
+                    match res {
+                        Ok(upid) => link.show_task_progress(upid.to_string()),
+                        Err(err) => link.show_error(tr!("Error"), err.to_string(), true),
+                    }
+                });
             }
             Msg::KeySelected(key) => {
                 let key = key.unwrap_or_else(|| Key::from("__root__"));
@@ -430,26 +408,28 @@ impl LoadableComponent for PveTreeComp {
                 } else {
                     let text = self.filter.to_lowercase();
                     self.store.set_filter(move |node: &PveTreeNode| match node {
-                        PveTreeNode::Lxc(r) => {
+                        PveTreeNode::Resource(PveResource::Lxc(r)) => {
                             r.vmid.to_string().to_lowercase().contains(&text)
                                 || r.name.to_lowercase().contains(&text)
                                 || "lxc".contains(&text)
                                 || r.tags.iter().any(|tag| tag.contains(&text))
                         }
-                        PveTreeNode::Qemu(r) => {
+                        PveTreeNode::Resource(PveResource::Qemu(r)) => {
                             r.vmid.to_string().to_lowercase().contains(&text)
                                 || r.name.to_lowercase().contains(&text)
                                 || "qemu".contains(&text)
                                 || r.tags.iter().any(|tag| tag.contains(&text))
                         }
-                        PveTreeNode::Storage(r) => {
+                        PveTreeNode::Resource(PveResource::Storage(r)) => {
                             r.storage.to_string().to_lowercase().contains(&text)
                                 || "storage".contains(&text)
                         }
                         // always show tree root node to ensure tree does not look odd.
                         // For now also always show all nodes (should we filter those without any
                         // matches for the node or for it's sub elements?
-                        PveTreeNode::Root | PveTreeNode::Node(_) => true,
+                        PveTreeNode::Root | PveTreeNode::Resource(PveResource::Node(_)) => true,
+                        // don't show the networks
+                        PveTreeNode::Resource(PveResource::Network(_)) => false,
                     });
                 }
             }
@@ -582,7 +562,7 @@ impl LoadableComponent for PveTreeComp {
 }
 
 fn create_empty_node(node_id: String) -> PveTreeNode {
-    PveTreeNode::Node(PveNodeResource {
+    PveTreeNode::Resource(PveResource::Node(PveNodeResource {
         cgroup_mode: Default::default(),
         cpu: Default::default(),
         maxcpu: Default::default(),
@@ -593,7 +573,7 @@ fn create_empty_node(node_id: String) -> PveTreeNode {
         uptime: Default::default(),
         status: Default::default(),
         level: Default::default(),
-    })
+    }))
 }
 
 fn columns(
@@ -620,15 +600,8 @@ fn columns(
                         Container::new().with_child(Fa::new("server")),
                         tr!("Datacenter"),
                     ),
-                    PveTreeNode::Node(r) => (utils::render_node_status_icon(r), r.node.to_string()),
-                    PveTreeNode::Qemu(r) => {
-                        (utils::render_qemu_status_icon(r), render_qemu_name(r, true))
-                    }
-                    PveTreeNode::Lxc(r) => {
-                        (utils::render_lxc_status_icon(r), render_lxc_name(r, true))
-                    }
-                    PveTreeNode::Storage(r) => {
-                        (utils::render_storage_status_icon(r), r.storage.clone())
+                    PveTreeNode::Resource(r) => {
+                        (render_status_icon(r), render_resource_name(r, true))
                     }
                 };
 
@@ -638,8 +611,12 @@ fn columns(
         DataTableColumn::new(tr!("Tags"))
             .flex(1)
             .render(move |entry: &PveTreeNode| match entry {
-                PveTreeNode::Lxc(lxc) => render_guest_tags(&lxc.tags[..]).into(),
-                PveTreeNode::Qemu(qemu) => render_guest_tags(&qemu.tags[..]).into(),
+                PveTreeNode::Resource(PveResource::Lxc(lxc)) => {
+                    render_guest_tags(&lxc.tags[..]).into()
+                }
+                PveTreeNode::Resource(PveResource::Qemu(qemu)) => {
+                    render_guest_tags(&qemu.tags[..]).into()
+                }
                 _ => html! {},
             })
             .into(),
@@ -647,39 +624,37 @@ fn columns(
             .width("180px")
             .render(move |entry: &PveTreeNode| {
                 let (id, local_id, guest_info, node) = match entry {
-                    PveTreeNode::Lxc(r) => {
-                        let guest_info = GuestInfo::new(GuestType::Lxc, r.vmid);
-                        let local_id = guest_info.local_id();
-                        (
-                            r.id.as_str(),
-                            local_id,
-                            Some((guest_info, r.status.as_str(), r.template)),
-                            Some(r.node.clone()),
-                        )
-                    }
-                    PveTreeNode::Qemu(r) => {
-                        let guest_info = GuestInfo::new(GuestType::Qemu, r.vmid);
-                        let local_id = guest_info.local_id();
-                        (
-                            r.id.as_str(),
-                            local_id,
-                            Some((guest_info, r.status.as_str(), r.template)),
-                            Some(r.node.clone()),
-                        )
-                    }
                     PveTreeNode::Root => ("root", "root".to_string(), None, None),
-                    PveTreeNode::Node(r) => (
-                        r.id.as_str(),
-                        format!("node/{}", r.node),
-                        None,
-                        Some(r.node.clone()),
-                    ),
-                    PveTreeNode::Storage(r) => (
-                        r.id.as_str(),
-                        format!("storage/{}/{}", r.node, r.storage),
-                        None,
-                        Some(r.node.clone()),
-                    ),
+                    PveTreeNode::Resource(r) => match r {
+                        PveResource::Lxc(r) => {
+                            let guest_info = GuestInfo::new(GuestType::Lxc, r.vmid);
+                            let local_id = guest_info.local_id();
+                            (
+                                r.id.as_str(),
+                                local_id,
+                                Some((guest_info, r.status.as_str(), r.template)),
+                                Some(r.node.clone()),
+                            )
+                        }
+                        PveResource::Qemu(r) => {
+                            let guest_info = GuestInfo::new(GuestType::Qemu, r.vmid);
+                            let local_id = guest_info.local_id();
+                            (
+                                r.id.as_str(),
+                                local_id,
+                                Some((guest_info, r.status.as_str(), r.template)),
+                                Some(r.node.clone()),
+                            )
+                        }
+                        PveResource::Node(_)
+                        | PveResource::Storage(_)
+                        | PveResource::Network(_) => (
+                            r.as_resource_view().global_id(),
+                            r.as_resource_view().id(),
+                            None,
+                            r.as_resource_view().node().map(|n| n.to_string()),
+                        ),
+                    },
                 };
 
                 Row::new()
-- 
2.47.3





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

* [PATCH datacenter-manager v2 7/9] ui: pve: show ha maintenance mode for nodes
  2026-08-18 13:25 [PATCH datacenter-manager v2 0/9] refactor and partially fix #7371 Dominik Csapak
                   ` (5 preceding siblings ...)
  2026-08-18 13:25 ` [PATCH datacenter-manager v2 6/9] ui: pve: tree: reuse `PveResource` for `PveTreeNode` Dominik Csapak
@ 2026-08-18 13:25 ` Dominik Csapak
  2026-08-20 11:07   ` Lukas Wagner
  2026-08-18 13:25 ` [PATCH datacenter-manager v2 8/9] ui: pve: node selector: show maintenance badge with node name Dominik Csapak
                   ` (3 subsequent siblings)
  10 siblings, 1 reply; 16+ messages in thread
From: Dominik Csapak @ 2026-08-18 13:25 UTC (permalink / raw)
  To: pdm-devel

By rendering a small `Badge` after the nodename in the pve tree and the
resource tree, when the node is in ha maintenance mode.

I opted for showing the ha state here differently than in PVE, because
seeing the online/offline state separately from the hastate can make
sense (so there is no ambiguity if the node is online or offline).

To do this we have to add the hastate to the `PveNodeResource` and wire
that through from the /cluster/resources call.

We also have to update the pwt-assets submodule to get the necessary CSS
classes for the badge.

This partially fixes #7371.

Signed-off-by: Dominik Csapak <d.csapak@proxmox.com>
---
 lib/pdm-api-types/src/resource.rs |  3 +++
 server/src/api/resources.rs       |  1 +
 ui/pwt-assets                     |  2 +-
 ui/src/pve/tree.rs                | 11 +++++++++--
 ui/src/renderer.rs                | 14 +++++++++++++-
 ui/src/widget/resource_tree.rs    |  3 ++-
 6 files changed, 29 insertions(+), 5 deletions(-)

diff --git a/lib/pdm-api-types/src/resource.rs b/lib/pdm-api-types/src/resource.rs
index c433a284..288d8543 100644
--- a/lib/pdm-api-types/src/resource.rs
+++ b/lib/pdm-api-types/src/resource.rs
@@ -490,6 +490,9 @@ pub struct PveNodeResource {
     pub status: String,
     /// Subscription level
     pub level: String,
+    /// HA State
+    #[serde(default)]
+    pub hastate: String,
 }
 
 #[api]
diff --git a/server/src/api/resources.rs b/server/src/api/resources.rs
index 09d2b88d..2e4b3aba 100644
--- a/server/src/api/resources.rs
+++ b/server/src/api/resources.rs
@@ -1189,6 +1189,7 @@ pub(super) fn map_pve_node(remote: &str, resource: ClusterResource) -> Option<Pv
             uptime: resource.uptime.unwrap_or_default() as u64,
             status: resource.status.unwrap_or_default(),
             level: resource.level.unwrap_or_default(),
+            hastate: resource.hastate.unwrap_or_default(),
         }),
         _ => None,
     }
diff --git a/ui/pwt-assets b/ui/pwt-assets
index cd2819ed..5b3a866f 160000
--- a/ui/pwt-assets
+++ b/ui/pwt-assets
@@ -1 +1 @@
-Subproject commit cd2819edb53c4b01a9df7f0a2f1f43736ff677fa
+Subproject commit 5b3a866ff9e2a0945bf5d84176d0d23ae83ef782
diff --git a/ui/src/pve/tree.rs b/ui/src/pve/tree.rs
index 4ea5716a..1a7c9df0 100644
--- a/ui/src/pve/tree.rs
+++ b/ui/src/pve/tree.rs
@@ -29,7 +29,9 @@ use pdm_api_types::{
 
 use crate::{
     get_deep_url,
-    renderer::{render_resource_name, render_status_icon, render_tree_column},
+    renderer::{
+        render_resource_extra_info, render_resource_name, render_status_icon, render_tree_column,
+    },
     widget::MigrateWindow,
 };
 
@@ -573,6 +575,7 @@ fn create_empty_node(node_id: String) -> PveTreeNode {
         uptime: Default::default(),
         status: Default::default(),
         level: Default::default(),
+        hastate: Default::default(),
     }))
 }
 
@@ -591,6 +594,7 @@ fn columns(
             .flex(1)
             .tree_column(store)
             .render(move |entry: &PveTreeNode| {
+                let mut extra = None;
                 let (icon, text) = match entry {
                     PveTreeNode::Root if loading => (
                         Container::from_tag("i").class("pwt-loading-icon"),
@@ -601,11 +605,14 @@ fn columns(
                         tr!("Datacenter"),
                     ),
                     PveTreeNode::Resource(r) => {
+                        extra = render_resource_extra_info(r);
                         (render_status_icon(r), render_resource_name(r, true))
                     }
                 };
 
-                render_tree_column(icon.into(), text).into()
+                render_tree_column(icon.into(), text)
+                    .with_optional_child(extra)
+                    .into()
             })
             .into(),
         DataTableColumn::new(tr!("Tags"))
diff --git a/ui/src/renderer.rs b/ui/src/renderer.rs
index 0eecc889..fd1283fa 100644
--- a/ui/src/renderer.rs
+++ b/ui/src/renderer.rs
@@ -2,7 +2,7 @@ use proxmox_yew_comp::MeterLabel;
 use pwt::css::{AlignItems, FlexFit, FontStyle, JustifyContent};
 use pwt::prelude::*;
 use pwt::props::ContainerBuilder;
-use pwt::widget::{Column, Container, Fa, Row};
+use pwt::widget::{Badge, Column, Container, Fa, Row};
 
 use proxmox_human_byte::HumanByte;
 
@@ -22,6 +22,18 @@ pub fn render_resource_name(resource: impl AsResourceView, vmid_first: bool) ->
     }
 }
 
+pub fn render_resource_extra_info(resource: impl AsResourceView) -> Option<Html> {
+    match resource.as_resource_view() {
+        ResourceView::PveNode(node) => (node.hastate == "maintenance").then_some(
+            Badge::new(tr!("HA Maintenance"))
+                .color_scheme(pwt::css::ColorScheme::PrimaryContainer)
+                .icon("wrench")
+                .into(),
+        ),
+        _ => None,
+    }
+}
+
 pub fn render_resource_icon(resource: impl AsResourceView) -> Fa {
     let class = match resource.as_resource_view() {
         ResourceView::PveStorage(_) => "database",
diff --git a/ui/src/widget/resource_tree.rs b/ui/src/widget/resource_tree.rs
index 337d8e23..0681c24d 100644
--- a/ui/src/widget/resource_tree.rs
+++ b/ui/src/widget/resource_tree.rs
@@ -35,7 +35,7 @@ use crate::{
     dashboard::view::ViewContext,
     get_deep_url,
     pve::utils::render_guest_tags,
-    renderer::{render_resource_name, render_status_icon},
+    renderer::{render_resource_extra_info, render_resource_name, render_status_icon},
 };
 
 const REFRESH_TIME_S: u32 = 60;
@@ -381,6 +381,7 @@ fn columns(
                         Row::new()
                             .gap(1)
                             .with_child(render_resource_name(resource, true))
+                            .with_optional_child(render_resource_extra_info(resource))
                             .with_child(render_guest_tags(match resource {
                                 Resource::PveQemu(pve_qemu_resource) => &pve_qemu_resource.tags[..],
                                 Resource::PveLxc(pve_lxc_resource) => &pve_lxc_resource.tags[..],
-- 
2.47.3





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

* [PATCH datacenter-manager v2 8/9] ui: pve: node selector: show maintenance badge with node name
  2026-08-18 13:25 [PATCH datacenter-manager v2 0/9] refactor and partially fix #7371 Dominik Csapak
                   ` (6 preceding siblings ...)
  2026-08-18 13:25 ` [PATCH datacenter-manager v2 7/9] ui: pve: show ha maintenance mode for nodes Dominik Csapak
@ 2026-08-18 13:25 ` Dominik Csapak
  2026-08-18 13:25 ` [PATCH datacenter-manager v2 9/9] ui: pve: node: show HA maintenance badge Dominik Csapak
                   ` (2 subsequent siblings)
  10 siblings, 0 replies; 16+ messages in thread
From: Dominik Csapak @ 2026-08-18 13:25 UTC (permalink / raw)
  To: pdm-devel

So when e.g. selecting a target for a migration, it's obvious that the
node is in ha maintenance mode.

To do this we have to change the node selector from the /nodes api call
to the /cluster/resources one (since only that includes the hastate),
and we have to implement `AsResourceView` for the ResourceView itself.
We do this by cloning (should be cheap, ResourceView is just an enum
with references, so no real data is contained only pointers.)

This partially fixes #7371.

Signed-off-by: Dominik Csapak <d.csapak@proxmox.com>
---
 lib/pdm-api-types/src/resource.rs  |  6 ++
 ui/src/widget/pve_node_selector.rs | 91 ++++++++++++++++++------------
 2 files changed, 62 insertions(+), 35 deletions(-)

diff --git a/lib/pdm-api-types/src/resource.rs b/lib/pdm-api-types/src/resource.rs
index 288d8543..63a7caaa 100644
--- a/lib/pdm-api-types/src/resource.rs
+++ b/lib/pdm-api-types/src/resource.rs
@@ -343,6 +343,12 @@ pub trait AsResourceView {
     fn as_resource_view(&self) -> ResourceView<'_>;
 }
 
+impl<'a> AsResourceView for ResourceView<'a> {
+    fn as_resource_view<'b>(&'b self) -> ResourceView<'a> {
+        self.clone()
+    }
+}
+
 impl AsResourceView for Resource {
     fn as_resource_view<'a>(&'a self) -> ResourceView<'a> {
         ResourceView::from(self)
diff --git a/ui/src/widget/pve_node_selector.rs b/ui/src/widget/pve_node_selector.rs
index e0ca7b61..d471780e 100644
--- a/ui/src/widget/pve_node_selector.rs
+++ b/ui/src/widget/pve_node_selector.rs
@@ -16,13 +16,17 @@ use pwt::{
     tr,
     widget::{
         Fa, GridPicker, Row,
+        container::div,
         data_table::{DataTable, DataTableColumn, DataTableHeader},
         form::{Selector, SelectorRenderArgs},
     },
 };
 use pwt_macros::{builder, widget};
 
-use pdm_client::types::ClusterNodeIndexResponse;
+use pdm_api_types::resource::{PveNodeResource, ResourceView};
+use pdm_client::types::ClusterResourceKind;
+
+use crate::renderer::render_resource_extra_info;
 
 #[widget(comp=PveNodeSelectorComp, @input)]
 #[derive(Clone, Properties, PartialEq)]
@@ -76,27 +80,35 @@ impl PveNodeSelector {
 }
 
 pub enum Msg {
-    UpdateNodeList(Result<Vec<ClusterNodeIndexResponse>, Error>),
+    UpdateNodeList(Result<Vec<PveNodeResource>, Error>),
 }
 
 pub struct PveNodeSelectorComp {
     _async_pool: AsyncPool,
-    store: Store<ClusterNodeIndexResponse>,
+    store: Store<PveNodeResource>,
     /// Unfiltered node list as fetched from the remote, kept so a prop change to `excluded_nodes`
     /// can re-filter without round-tripping the remote again.
-    raw_nodes: Vec<ClusterNodeIndexResponse>,
+    raw_nodes: Vec<PveNodeResource>,
     last_err: Option<AttrValue>,
 }
 
 impl PveNodeSelectorComp {
-    async fn get_node_list(remote: AttrValue) -> Result<Vec<ClusterNodeIndexResponse>, Error> {
-        let mut nodes = crate::pdm_client().pve_list_nodes(&remote).await?;
+    async fn get_node_list(remote: AttrValue) -> Result<Vec<PveNodeResource>, Error> {
+        let mut nodes: Vec<PveNodeResource> = crate::pdm_client()
+            .pve_cluster_resources(&remote, Some(ClusterResourceKind::Node))
+            .await?
+            .into_iter()
+            .filter_map(|r| match r {
+                pdm_api_types::resource::PveResource::Node(node) => Some(node),
+                _ => None,
+            })
+            .collect();
         nodes.sort_by(|a, b| a.node.cmp(&b.node));
         Ok(nodes)
     }
 
     fn apply_filter(&mut self, excluded: &[String], source_node: Option<&str>) {
-        let filtered: Vec<ClusterNodeIndexResponse> = self
+        let filtered: Vec<PveNodeResource> = self
             .raw_nodes
             .iter()
             .filter(|n| {
@@ -122,9 +134,7 @@ impl Component for PveNodeSelectorComp {
             _async_pool,
             last_err: None,
             raw_nodes: Vec::new(),
-            store: Store::with_extract_key(|node: &ClusterNodeIndexResponse| {
-                Key::from(node.node.as_str())
-            }),
+            store: Store::with_extract_key(|node: &PveNodeResource| Key::from(node.node.as_str())),
         }
     }
 
@@ -175,7 +185,7 @@ impl Component for PveNodeSelectorComp {
             }
         };
         Selector::new(self.store.clone(), {
-            move |args: &SelectorRenderArgs<Store<ClusterNodeIndexResponse>>| {
+            move |args: &SelectorRenderArgs<Store<PveNodeResource>>| {
                 if let Some(err) = &err {
                     return Row::new()
                         .with_child(Fa::from(Status::Error))
@@ -203,44 +213,55 @@ impl Component for PveNodeSelectorComp {
     }
 }
 
-fn columns(show_memory: bool) -> Rc<Vec<DataTableHeader<ClusterNodeIndexResponse>>> {
+fn columns(show_memory: bool) -> Rc<Vec<DataTableHeader<PveNodeResource>>> {
     let mut columns = vec![
         DataTableColumn::new(tr!("Node"))
-            .get_property(|entry: &ClusterNodeIndexResponse| &entry.node)
+            .width("250px")
+            .render(|entry: &PveNodeResource| {
+                Row::new()
+                    .gap(1)
+                    .with_child(div(&entry.node))
+                    .with_optional_child(render_resource_extra_info(ResourceView::PveNode(entry)))
+                    .into()
+            })
+            .sorter(|a: &PveNodeResource, b: &PveNodeResource| a.node.cmp(&b.node))
             .sort_order(true)
             .into(),
     ];
     if show_memory {
         columns.push(
             DataTableColumn::new(tr!("CPU Usage"))
-                .render(|entry: &ClusterNodeIndexResponse| match entry.cpu {
-                    Some(cpu) => html! { rrd_value_renderer::render_cpu_usage(&cpu) },
-                    None => html! {},
+                .render(|entry: &PveNodeResource| {
+                    if entry.status != "online" {
+                        return html! {};
+                    }
+                    rrd_value_renderer::render_cpu_usage(&entry.cpu).into()
                 })
-                .sorter(
-                    |a: &ClusterNodeIndexResponse, b: &ClusterNodeIndexResponse| {
-                        // total_cmp tolerates NaN; preserve the "no data sorts low" intuition by
-                        // mapping None to negative infinity so unprobed nodes stay at the bottom.
+                .sorter(|a: &PveNodeResource, b: &PveNodeResource| {
+                    let a = if a.status == "online" {
                         a.cpu
-                            .unwrap_or(f64::NEG_INFINITY)
-                            .total_cmp(&b.cpu.unwrap_or(f64::NEG_INFINITY))
-                    },
-                )
+                    } else {
+                        f64::NEG_INFINITY
+                    };
+                    let b = if b.status == "online" {
+                        b.cpu
+                    } else {
+                        f64::NEG_INFINITY
+                    };
+                    a.total_cmp(&b)
+                })
                 .into(),
         );
         columns.push(
             DataTableColumn::new(tr!("Memory Usage"))
-                .render(
-                    |entry: &ClusterNodeIndexResponse| match (entry.mem, entry.maxmem) {
-                        (Some(mem), Some(maxmem)) => {
-                            html! {format!("{:.2}%", 100.0 * mem as f64 / maxmem as f64)}
-                        }
-                        _ => html! {},
-                    },
-                )
-                .sorter(
-                    |a: &ClusterNodeIndexResponse, b: &ClusterNodeIndexResponse| a.mem.cmp(&b.mem),
-                )
+                .render(|entry: &PveNodeResource| {
+                    // don't show values when the node isn't online or when it's non sensible
+                    if entry.maxmem == 0 || entry.status != "online" {
+                        return html! {};
+                    }
+                    html! {format!("{:.2}%", 100.0 * entry.mem as f64 / entry.maxmem as f64)}
+                })
+                .sorter(|a: &PveNodeResource, b: &PveNodeResource| a.mem.cmp(&b.mem))
                 .into(),
         );
     }
-- 
2.47.3





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

* [PATCH datacenter-manager v2 9/9] ui: pve: node: show HA maintenance badge
  2026-08-18 13:25 [PATCH datacenter-manager v2 0/9] refactor and partially fix #7371 Dominik Csapak
                   ` (7 preceding siblings ...)
  2026-08-18 13:25 ` [PATCH datacenter-manager v2 8/9] ui: pve: node selector: show maintenance badge with node name Dominik Csapak
@ 2026-08-18 13:25 ` Dominik Csapak
  2026-08-20 11:08 ` [PATCH datacenter-manager v2 0/9] refactor and partially fix #7371 Lukas Wagner
  2026-08-21 12:51 ` superseded: " Dominik Csapak
  10 siblings, 0 replies; 16+ messages in thread
From: Dominik Csapak @ 2026-08-18 13:25 UTC (permalink / raw)
  To: pdm-devel

in the title of the node panel, so it's visible on all tabs, including
the shell, where admins might do the maintenance.

Signed-off-by: Dominik Csapak <d.csapak@proxmox.com>
---
 ui/src/pve/mod.rs      | 11 ++++++++++-
 ui/src/pve/node/mod.rs | 15 +++++++++++----
 2 files changed, 21 insertions(+), 5 deletions(-)

diff --git a/ui/src/pve/mod.rs b/ui/src/pve/mod.rs
index 340fc731..3513a814 100644
--- a/ui/src/pve/mod.rs
+++ b/ui/src/pve/mod.rs
@@ -216,7 +216,16 @@ impl LoadableComponent for PveRemoteComp {
             PveTreeNode::Root => remote::PveRemotePanel::new(remote.clone()).into(),
             PveTreeNode::Resource(r) => match r {
                 PveResource::Node(node) => {
-                    node::PveNodePanel::new(remote.clone(), node.node.clone())
+                    let info = self
+                        .resources
+                        .iter()
+                        .find_map(|r: &PveResource| match r {
+                            PveResource::Node(info) if node.id == info.id => Some(info),
+                            _ => None,
+                        })
+                        .unwrap_or(node)
+                        .clone();
+                    node::PveNodePanel::new(remote.clone(), node.node.clone(), info)
                         .pve_manager_version(self.get_pve_manager_version(&node.node))
                         .into()
                 }
diff --git a/ui/src/pve/node/mod.rs b/ui/src/pve/node/mod.rs
index 056f15cd..55e28a81 100644
--- a/ui/src/pve/node/mod.rs
+++ b/ui/src/pve/node/mod.rs
@@ -13,13 +13,15 @@ use pwt::{
 };
 use pwt_macros::builder;
 
+use pdm_api_types::resource::{PveNodeResource, ResourceView};
+
 mod overview;
 
 use overview::PveNodeOverviewPanel;
 
-use crate::get_deep_url;
+use crate::{get_deep_url, renderer::render_resource_extra_info};
 
-#[derive(Clone, Debug, Eq, PartialEq, Properties)]
+#[derive(Clone, Debug, PartialEq, Properties)]
 #[builder]
 pub struct PveNodePanel {
     /// The remote to show
@@ -28,6 +30,8 @@ pub struct PveNodePanel {
     /// The node to show
     pub node: String,
 
+    info: PveNodeResource,
+
     #[prop_or_default]
     #[builder]
     /// The nodes pve-manager version, used to feature gate some entries.
@@ -35,8 +39,8 @@ pub struct PveNodePanel {
 }
 
 impl PveNodePanel {
-    pub fn new(remote: String, node: String) -> Self {
-        yew::props!(Self { remote, node })
+    pub fn new(remote: String, node: String, info: PveNodeResource) -> Self {
+        yew::props!(Self { remote, node, info })
     }
 }
 
@@ -64,6 +68,9 @@ impl yew::Component for PveNodePanelComp {
             .class(AlignItems::Baseline)
             .with_child(Fa::new("building"))
             .with_child(tr! {"Node '{0}'", props.node})
+            .with_optional_child(render_resource_extra_info(ResourceView::PveNode(
+                &props.info,
+            )))
             .into();
 
         TabPanel::new()
-- 
2.47.3





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

* Re: [PATCH datacenter-manager v2 1/9] lib: api types: add new ResourceView type and move accessors there
  2026-08-18 13:25 ` [PATCH datacenter-manager v2 1/9] lib: api types: add new ResourceView type and move accessors there Dominik Csapak
@ 2026-08-20 11:07   ` Lukas Wagner
  2026-08-20 11:20     ` Dominik Csapak
  0 siblings, 1 reply; 16+ messages in thread
From: Lukas Wagner @ 2026-08-20 11:07 UTC (permalink / raw)
  To: Dominik Csapak, pdm-devel

Hi Dominik,

looking good - some of the code is missing doc-strings that could be
added, but that's just a minor nit-pick.

On Tue Aug 18, 2026 at 3:25 PM CEST, Dominik Csapak wrote:
> This is intended as a view type for Resource and PveResource, which will
> always share some enum type. This way, we can reuse all accessors
> cheaply for PveResource values without cloning or consuming.
>
> This makes it easier to share code for both of them, for instance in the
> UI where we want to render properties consistently for resources.
>
> Introduces also a AsResourceView trait to make converting more
> ergonomic.
>
> Signed-off-by: Dominik Csapak <d.csapak@proxmox.com>
> ---
>  lib/pdm-api-types/src/resource.rs | 261 +++++++++++++++++++++---------
>  1 file changed, 184 insertions(+), 77 deletions(-)
>
> diff --git a/lib/pdm-api-types/src/resource.rs b/lib/pdm-api-types/src/resource.rs
> index 303ac3cb..4447aedb 100644
> --- a/lib/pdm-api-types/src/resource.rs
> +++ b/lib/pdm-api-types/src/resource.rs
> @@ -45,102 +45,31 @@ impl Resource {
>      /// Returns the local ID, not a globally unique one, e.g.
>      /// `qemu/<vmid>`
>      pub fn id(&self) -> String {
> -        match self {
> -            Resource::PveStorage(r) => format!("storage/{}/{}", r.node, r.storage),
> -            Resource::PveQemu(r) => format!("qemu/{}", r.vmid),
> -            Resource::PveLxc(r) => format!("lxc/{}", r.vmid),
> -            Resource::PveNode(r) => format!("node/{}", r.node),
> -            Resource::PveNetwork(r) => {
> -                if let PveNetworkResource::Zone(z) = r {
> -                    if z.legacy {
> -                        return format!("sdn/{}/{}", r.node(), r.name());
> -                    }
> -                }
> -
> -                format!("network/{}/{}/{}", r.node(), r.network_type(), r.name())
> -            }
> -            Resource::PbsNode(r) => format!("node/{}", r.name),
> -            Resource::PbsDatastore(r) => r.name.clone(),
> -        }
> +        self.as_resource_view().id()
>      }
>  
>      /// Returns the PDM global ID for the resource, e.g.
>      /// `remote/<remote-id>/guest/<vmid>`
>      pub fn global_id(&self) -> &str {
> -        match self {
> -            Resource::PveStorage(r) => r.id.as_str(),
> -            Resource::PveQemu(r) => r.id.as_str(),
> -            Resource::PveLxc(r) => r.id.as_str(),
> -            Resource::PveNode(r) => r.id.as_str(),
> -            Resource::PveNetwork(r) => r.id(),
> -            Resource::PbsNode(r) => r.id.as_str(),
> -            Resource::PbsDatastore(r) => r.id.as_str(),
> -        }
> +        self.as_resource_view().global_id()
>      }
>  
>      /// Returns the "name" of the resource, e.g. the guest name for VMs/Containers or
>      /// the hostname for nodes
>      pub fn name(&self) -> &str {
> -        match self {
> -            Resource::PveStorage(r) => r.storage.as_str(),
> -            Resource::PveQemu(r) => r.name.as_str(),
> -            Resource::PveLxc(r) => r.name.as_str(),
> -            Resource::PveNode(r) => r.node.as_str(),
> -            Resource::PveNetwork(r) => r.name(),
> -            Resource::PbsNode(r) => r.name.as_str(),
> -            Resource::PbsDatastore(r) => r.name.as_str(),
> -        }
> +        self.as_resource_view().name()
>      }
>  

While at it, you could add some doc strings here.

>      pub fn resource_type(&self) -> ResourceType {
> -        match self {
> -            Resource::PveStorage(_) => ResourceType::PveStorage,
> -            Resource::PveQemu(_) => ResourceType::PveQemu,
> -            Resource::PveLxc(_) => ResourceType::PveLxc,
> -            Resource::PveNetwork(_) => ResourceType::PveNetwork,
> -            Resource::PveNode(_) | Resource::PbsNode(_) => ResourceType::Node,
> -            Resource::PbsDatastore(_) => ResourceType::PbsDatastore,
> -        }
> +        self.as_resource_view().resource_type()
>      }
>  
While at it, you could add some doc strings here.

>      pub fn status(&self) -> &str {
> -        match self {
> -            Resource::PveStorage(r) => r.status.as_str(),
> -            Resource::PveQemu(r) => r.status.as_str(),
> -            Resource::PveLxc(r) => r.status.as_str(),
> -            Resource::PveNode(r) => r.status.as_str(),
> -            Resource::PveNetwork(r) => r.status(),
> -            Resource::PbsNode(r) => {
> -                if r.uptime > 0 {
> -                    "online"
> -                } else {
> -                    "offline"
> -                }
> -            }
> -            Resource::PbsDatastore(r) => {
> -                if r.maintenance.is_none() {
> -                    "online"
> -                } else {
> -                    "under-maintenance"
> -                }
> -            }
> -        }
> +        self.as_resource_view().status()
>      }
>  
While at it, you could add some doc strings here.

>      pub fn properties(&self) -> String {
> -        let mut properties = Vec::new();
> -        if let Resource::PbsDatastore(r) = self {
> -            if let Some(backend_type) = &r.backend_type {
> -                properties.push(backend_type.to_string());
> -            }
> -            if r.backing_device.is_some() {
> -                properties.push("removable".to_string());
> -            }
> -            if r.usage > PBS_DATASTORE_HIGH_USAGE_THRESHOLD {
> -                properties.push("high-usage".to_string());
> -            }
> -        }
> -        properties.join(",")
> +        self.as_resource_view().properties()
>      }
>  }
>  
> @@ -229,6 +158,184 @@ pub enum PveResource {
>      Network(PveNetworkResource),
>  }
>  
> +/// A borrowed view of a resource that can come from a [Resource] or a [PveResource]
> +/// This is useful for having a single implementation of field access regardless which
> +/// of the types is used.
> +#[derive(Clone, Debug, PartialEq)]
> +pub enum ResourceView<'a> {
> +    /// Storage resource in PVE.
> +    PveStorage(&'a PveStorageResource),
> +    /// QEMU guest resource in PVE.
> +    PveQemu(&'a PveQemuResource),
> +    /// LXC guest resource in PVE.
> +    PveLxc(&'a PveLxcResource),
> +    /// A PVE node.
> +    PveNode(&'a PveNodeResource),
> +    /// Network in PVE.
> +    PveNetwork(&'a PveNetworkResource),
> +    /// A PBS node.
> +    PbsNode(&'a PbsNodeResource),
> +    /// Datastore on a PBS node.
> +    PbsDatastore(&'a PbsDatastoreResource),
> +}
> +
> +impl<'a> ResourceView<'a> {
> +    /// Returns the local ID, not a globally unique one, e.g.
> +    /// `qemu/<vmid>`
> +    pub fn id(&self) -> String {
> +        match self {
> +            ResourceView::PveStorage(r) => format!("storage/{}/{}", r.node, r.storage),
> +            ResourceView::PveQemu(r) => format!("qemu/{}", r.vmid),
> +            ResourceView::PveLxc(r) => format!("lxc/{}", r.vmid),
> +            ResourceView::PveNode(r) => format!("node/{}", r.node),
> +            ResourceView::PveNetwork(r) => {
> +                if let PveNetworkResource::Zone(z) = r {
> +                    if z.legacy {
> +                        return format!("sdn/{}/{}", r.node(), r.name());
> +                    }
> +                }
> +
> +                format!("network/{}/{}/{}", r.node(), r.network_type(), r.name())
> +            }
> +            ResourceView::PbsNode(r) => format!("node/{}", r.name),
> +            ResourceView::PbsDatastore(r) => r.name.clone(),
> +        }
> +    }
> +
> +    /// Returns the PDM global ID for the resource, e.g.
> +    /// `remote/<remote-id>/guest/<vmid>`
> +    pub fn global_id(&self) -> &'a str {
> +        match self {
> +            ResourceView::PveStorage(r) => r.id.as_str(),
> +            ResourceView::PveQemu(r) => r.id.as_str(),
> +            ResourceView::PveLxc(r) => r.id.as_str(),
> +            ResourceView::PveNode(r) => r.id.as_str(),
> +            ResourceView::PveNetwork(r) => r.id(),
> +            ResourceView::PbsNode(r) => r.id.as_str(),
> +            ResourceView::PbsDatastore(r) => r.id.as_str(),
> +        }
> +    }
> +
> +    /// Returns the "name" of the resource, e.g. the guest name for VMs/Containers or
> +    /// the hostname for nodes
> +    pub fn name(&self) -> &'a str {
> +        match self {
> +            ResourceView::PveStorage(r) => r.storage.as_str(),
> +            ResourceView::PveQemu(r) => r.name.as_str(),
> +            ResourceView::PveLxc(r) => r.name.as_str(),
> +            ResourceView::PveNode(r) => r.node.as_str(),
> +            ResourceView::PveNetwork(r) => r.name(),
> +            ResourceView::PbsNode(r) => r.name.as_str(),
> +            ResourceView::PbsDatastore(r) => r.name.as_str(),
> +        }
> +    }
> +

Missing doc strings here.
> +    pub fn resource_type(&self) -> ResourceType {
> +        match self {
> +            ResourceView::PveStorage(_) => ResourceType::PveStorage,
> +            ResourceView::PveQemu(_) => ResourceType::PveQemu,
> +            ResourceView::PveLxc(_) => ResourceType::PveLxc,
> +            ResourceView::PveNetwork(_) => ResourceType::PveNetwork,
> +            ResourceView::PveNode(_) | ResourceView::PbsNode(_) => ResourceType::Node,
> +            ResourceView::PbsDatastore(_) => ResourceType::PbsDatastore,
> +        }
> +    }
> +

here as well
> +    pub fn status(&self) -> &'a str {
> +        match self {
> +            ResourceView::PveStorage(r) => r.status.as_str(),
> +            ResourceView::PveQemu(r) => r.status.as_str(),
> +            ResourceView::PveLxc(r) => r.status.as_str(),
> +            ResourceView::PveNode(r) => r.status.as_str(),
> +            ResourceView::PveNetwork(r) => r.status(),
> +            ResourceView::PbsNode(r) => {
> +                if r.uptime > 0 {
> +                    "online"
> +                } else {
> +                    "offline"
> +                }
> +            }
> +            ResourceView::PbsDatastore(r) => {
> +                if r.maintenance.is_none() {
> +                    "online"
> +                } else {
> +                    "under-maintenance"
> +                }
> +            }
> +        }
> +    }
> +

here as well
> +    pub fn properties(&self) -> String {
> +        let mut properties = Vec::new();
> +        if let ResourceView::PbsDatastore(r) = self {
> +            if let Some(backend_type) = &r.backend_type {
> +                properties.push(backend_type.to_string());
> +            }
> +            if r.backing_device.is_some() {
> +                properties.push("removable".to_string());
> +            }
> +            if r.usage > PBS_DATASTORE_HIGH_USAGE_THRESHOLD {
> +                properties.push("high-usage".to_string());
> +            }
> +        }
> +        properties.join(",")
> +    }
> +}
> +
> +impl<'a> From<&'a Resource> for ResourceView<'a> {
> +    fn from(value: &'a Resource) -> Self {
> +        match value {
> +            Resource::PveStorage(r) => ResourceView::PveStorage(r),
> +            Resource::PveQemu(r) => ResourceView::PveQemu(r),
> +            Resource::PveLxc(r) => ResourceView::PveLxc(r),
> +            Resource::PveNode(r) => ResourceView::PveNode(r),
> +            Resource::PveNetwork(r) => ResourceView::PveNetwork(r),
> +            Resource::PbsNode(r) => ResourceView::PbsNode(r),
> +            Resource::PbsDatastore(r) => ResourceView::PbsDatastore(r),
> +        }
> +    }
> +}
> +
> +impl<'a> From<&'a PveResource> for ResourceView<'a> {
> +    fn from(value: &'a PveResource) -> Self {
> +        match value {
> +            PveResource::Storage(r) => ResourceView::PveStorage(r),
> +            PveResource::Qemu(r) => ResourceView::PveQemu(r),
> +            PveResource::Lxc(r) => ResourceView::PveLxc(r),
> +            PveResource::Node(r) => ResourceView::PveNode(r),
> +            PveResource::Network(r) => ResourceView::PveNetwork(r),
> +        }
> +    }
> +}
> +

missing doc string here

> +pub trait AsResourceView {
> +    fn as_resource_view(&self) -> ResourceView<'_>;
> +}
> +
> +impl AsResourceView for Resource {
> +    fn as_resource_view<'a>(&'a self) -> ResourceView<'a> {
> +        ResourceView::from(self)
> +    }
> +}
> +
> +impl AsResourceView for &Resource {
> +    fn as_resource_view<'a>(&'a self) -> ResourceView<'a> {
> +        ResourceView::from(*self)
> +    }
> +}
> +
> +impl AsResourceView for PveResource {
> +    fn as_resource_view<'a>(&'a self) -> ResourceView<'a> {
> +        ResourceView::from(self)
> +    }
> +}
> +
> +impl AsResourceView for &PveResource {
> +    fn as_resource_view<'a>(&'a self) -> ResourceView<'a> {
> +        ResourceView::from(*self)
> +    }
> +}
> +
>  #[api(
>      properties: {
>          tags: {





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

* Re: [PATCH datacenter-manager v2 7/9] ui: pve: show ha maintenance mode for nodes
  2026-08-18 13:25 ` [PATCH datacenter-manager v2 7/9] ui: pve: show ha maintenance mode for nodes Dominik Csapak
@ 2026-08-20 11:07   ` Lukas Wagner
  2026-08-20 11:20     ` Dominik Csapak
  0 siblings, 1 reply; 16+ messages in thread
From: Lukas Wagner @ 2026-08-20 11:07 UTC (permalink / raw)
  To: Dominik Csapak, pdm-devel

On Tue Aug 18, 2026 at 3:25 PM CEST, Dominik Csapak wrote:
> By rendering a small `Badge` after the nodename in the pve tree and the
> resource tree, when the node is in ha maintenance mode.
>
> I opted for showing the ha state here differently than in PVE, because
> seeing the online/offline state separately from the hastate can make
> sense (so there is no ambiguity if the node is online or offline).
>
> To do this we have to add the hastate to the `PveNodeResource` and wire
> that through from the /cluster/resources call.
>
> We also have to update the pwt-assets submodule to get the necessary CSS
> classes for the badge.
>
> This partially fixes #7371.
>
> Signed-off-by: Dominik Csapak <d.csapak@proxmox.com>
> ---
>  lib/pdm-api-types/src/resource.rs |  3 +++
>  server/src/api/resources.rs       |  1 +
>  ui/pwt-assets                     |  2 +-
>  ui/src/pve/tree.rs                | 11 +++++++++--
>  ui/src/renderer.rs                | 14 +++++++++++++-
>  ui/src/widget/resource_tree.rs    |  3 ++-
>  6 files changed, 29 insertions(+), 5 deletions(-)
>
> diff --git a/lib/pdm-api-types/src/resource.rs b/lib/pdm-api-types/src/resource.rs
> index c433a284..288d8543 100644
> --- a/lib/pdm-api-types/src/resource.rs
> +++ b/lib/pdm-api-types/src/resource.rs
> @@ -490,6 +490,9 @@ pub struct PveNodeResource {
>      pub status: String,
>      /// Subscription level
>      pub level: String,
> +    /// HA State
> +    #[serde(default)]
> +    pub hastate: String,

I wonder, could we use a proper type here? E.g. something like

enum HaState {
    Maintenance,
    ...,
    Unknown
}

I'd also avoid carrying over these 'word amalgamations' and rather call
it at least 'ha_state' (serialized to 'ha-state'), but not a biggie for
me.

>  }





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

* Re: [PATCH datacenter-manager v2 0/9] refactor and partially fix #7371
  2026-08-18 13:25 [PATCH datacenter-manager v2 0/9] refactor and partially fix #7371 Dominik Csapak
                   ` (8 preceding siblings ...)
  2026-08-18 13:25 ` [PATCH datacenter-manager v2 9/9] ui: pve: node: show HA maintenance badge Dominik Csapak
@ 2026-08-20 11:08 ` Lukas Wagner
  2026-08-21 12:51 ` superseded: " Dominik Csapak
  10 siblings, 0 replies; 16+ messages in thread
From: Lukas Wagner @ 2026-08-20 11:08 UTC (permalink / raw)
  To: Dominik Csapak, pdm-devel

On Tue Aug 18, 2026 at 3:25 PM CEST, Dominik Csapak wrote:
> the first 6 patches are pure refactors and should not change anything
> user visible, could be applied independently.
>
> patch 7-9 partially fix #7371 by showing the HA maintenance status
> of PVE nodes in various places.
>
> changes from v1:
> * fixed some display problems: NaN% when maxmem was set to 0.0,
>   0.0% cpu when the node was offline, fixed by not showing values for
>   offline nodes
> * add a new patch for showing tha badge too in the node card's title
> * various typo fixes
> * added #[serde(default)] to the new hastate field to allow
>   deserialization from older versions
> * some smaller fixes  (left over comments, naming, etc.)
>
> Dominik Csapak (9):
>   lib: api types: add new ResourceView type and move accessors there
>   lib: api types: resource: add 'node' helper to ResourceView
>   lib: api types: add guest specific getter to ResourceView
>   ui: pve: factor out the pve-manager version extraction
>   ui: renderer: use ResourceView for rendering
>   ui: pve: tree: reuse `PveResource` for `PveTreeNode`
>   ui: pve: show ha maintenance mode for nodes
>   ui: pve: node selector: show maintenance badge with node name
>   ui: pve: node: show HA maintenance badge
>
>  lib/pdm-api-types/src/resource.rs  | 301 ++++++++++++++++++++--------
>  server/src/api/resources.rs        |   1 +
>  ui/pwt-assets                      |   2 +-
>  ui/src/dashboard/top_entities.rs   |   5 +-
>  ui/src/guests.rs                   |   6 +-
>  ui/src/lib.rs                      |  23 +--
>  ui/src/pve/mod.rs                  |  73 ++++---
>  ui/src/pve/node/mod.rs             |  15 +-
>  ui/src/pve/tree.rs                 | 302 ++++++++++++++---------------
>  ui/src/renderer.rs                 |  66 ++++---
>  ui/src/widget/pve_node_selector.rs |  91 +++++----
>  ui/src/widget/resource_tree.rs     |  11 +-
>  12 files changed, 533 insertions(+), 363 deletions(-)


Looks good to me. Two minor things, see patch replies for details.

Tested-by: Lukas Wagner <l.wagner@proxmox.com>
Reviewed-by: Lukas Wagner <l.wagner@proxmox.com>




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

* Re: [PATCH datacenter-manager v2 7/9] ui: pve: show ha maintenance mode for nodes
  2026-08-20 11:07   ` Lukas Wagner
@ 2026-08-20 11:20     ` Dominik Csapak
  0 siblings, 0 replies; 16+ messages in thread
From: Dominik Csapak @ 2026-08-20 11:20 UTC (permalink / raw)
  To: Lukas Wagner, pdm-devel



On 8/20/26 1:07 PM, Lukas Wagner wrote:
> On Tue Aug 18, 2026 at 3:25 PM CEST, Dominik Csapak wrote:
>> By rendering a small `Badge` after the nodename in the pve tree and the
>> resource tree, when the node is in ha maintenance mode.
>>
>> I opted for showing the ha state here differently than in PVE, because
>> seeing the online/offline state separately from the hastate can make
>> sense (so there is no ambiguity if the node is online or offline).
>>
>> To do this we have to add the hastate to the `PveNodeResource` and wire
>> that through from the /cluster/resources call.
>>
>> We also have to update the pwt-assets submodule to get the necessary CSS
>> classes for the badge.
>>
>> This partially fixes #7371.
>>
>> Signed-off-by: Dominik Csapak <d.csapak@proxmox.com>
>> ---
>>   lib/pdm-api-types/src/resource.rs |  3 +++
>>   server/src/api/resources.rs       |  1 +
>>   ui/pwt-assets                     |  2 +-
>>   ui/src/pve/tree.rs                | 11 +++++++++--
>>   ui/src/renderer.rs                | 14 +++++++++++++-
>>   ui/src/widget/resource_tree.rs    |  3 ++-
>>   6 files changed, 29 insertions(+), 5 deletions(-)
>>
>> diff --git a/lib/pdm-api-types/src/resource.rs b/lib/pdm-api-types/src/resource.rs
>> index c433a284..288d8543 100644
>> --- a/lib/pdm-api-types/src/resource.rs
>> +++ b/lib/pdm-api-types/src/resource.rs
>> @@ -490,6 +490,9 @@ pub struct PveNodeResource {
>>       pub status: String,
>>       /// Subscription level
>>       pub level: String,
>> +    /// HA State
>> +    #[serde(default)]
>> +    pub hastate: String,
> 
> I wonder, could we use a proper type here? E.g. something like
> 
> enum HaState {
>      Maintenance,
>      ...,
>      Unknown
> }

sure, I'd like that too. I just adapted to the code around this^^
(e.g. the level and status field are similar strings)

> 
> I'd also avoid carrying over these 'word amalgamations' and rather call
> it at least 'ha_state' (serialized to 'ha-state'), but not a biggie for
> me.
> 
>>   }
> 

sure, i guess i was too fixated on keeping the fields the same
as the pve ones




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

* Re: [PATCH datacenter-manager v2 1/9] lib: api types: add new ResourceView type and move accessors there
  2026-08-20 11:07   ` Lukas Wagner
@ 2026-08-20 11:20     ` Dominik Csapak
  0 siblings, 0 replies; 16+ messages in thread
From: Dominik Csapak @ 2026-08-20 11:20 UTC (permalink / raw)
  To: Lukas Wagner, pdm-devel



On 8/20/26 1:06 PM, Lukas Wagner wrote:
> Hi Dominik,
> 
> looking good - some of the code is missing doc-strings that could be
> added, but that's just a minor nit-pick.

yes of course, i'll send a v3

> 
> On Tue Aug 18, 2026 at 3:25 PM CEST, Dominik Csapak wrote:
>> This is intended as a view type for Resource and PveResource, which will
>> always share some enum type. This way, we can reuse all accessors
>> cheaply for PveResource values without cloning or consuming.
>>
>> This makes it easier to share code for both of them, for instance in the
>> UI where we want to render properties consistently for resources.
>>
>> Introduces also a AsResourceView trait to make converting more
>> ergonomic.
>>
>> Signed-off-by: Dominik Csapak <d.csapak@proxmox.com>
>> ---
>>   lib/pdm-api-types/src/resource.rs | 261 +++++++++++++++++++++---------
>>   1 file changed, 184 insertions(+), 77 deletions(-)
>>
>> diff --git a/lib/pdm-api-types/src/resource.rs b/lib/pdm-api-types/src/resource.rs
>> index 303ac3cb..4447aedb 100644
>> --- a/lib/pdm-api-types/src/resource.rs
>> +++ b/lib/pdm-api-types/src/resource.rs
>> @@ -45,102 +45,31 @@ impl Resource {
>>       /// Returns the local ID, not a globally unique one, e.g.
>>       /// `qemu/<vmid>`
>>       pub fn id(&self) -> String {
>> -        match self {
>> -            Resource::PveStorage(r) => format!("storage/{}/{}", r.node, r.storage),
>> -            Resource::PveQemu(r) => format!("qemu/{}", r.vmid),
>> -            Resource::PveLxc(r) => format!("lxc/{}", r.vmid),
>> -            Resource::PveNode(r) => format!("node/{}", r.node),
>> -            Resource::PveNetwork(r) => {
>> -                if let PveNetworkResource::Zone(z) = r {
>> -                    if z.legacy {
>> -                        return format!("sdn/{}/{}", r.node(), r.name());
>> -                    }
>> -                }
>> -
>> -                format!("network/{}/{}/{}", r.node(), r.network_type(), r.name())
>> -            }
>> -            Resource::PbsNode(r) => format!("node/{}", r.name),
>> -            Resource::PbsDatastore(r) => r.name.clone(),
>> -        }
>> +        self.as_resource_view().id()
>>       }
>>   
>>       /// Returns the PDM global ID for the resource, e.g.
>>       /// `remote/<remote-id>/guest/<vmid>`
>>       pub fn global_id(&self) -> &str {
>> -        match self {
>> -            Resource::PveStorage(r) => r.id.as_str(),
>> -            Resource::PveQemu(r) => r.id.as_str(),
>> -            Resource::PveLxc(r) => r.id.as_str(),
>> -            Resource::PveNode(r) => r.id.as_str(),
>> -            Resource::PveNetwork(r) => r.id(),
>> -            Resource::PbsNode(r) => r.id.as_str(),
>> -            Resource::PbsDatastore(r) => r.id.as_str(),
>> -        }
>> +        self.as_resource_view().global_id()
>>       }
>>   
>>       /// Returns the "name" of the resource, e.g. the guest name for VMs/Containers or
>>       /// the hostname for nodes
>>       pub fn name(&self) -> &str {
>> -        match self {
>> -            Resource::PveStorage(r) => r.storage.as_str(),
>> -            Resource::PveQemu(r) => r.name.as_str(),
>> -            Resource::PveLxc(r) => r.name.as_str(),
>> -            Resource::PveNode(r) => r.node.as_str(),
>> -            Resource::PveNetwork(r) => r.name(),
>> -            Resource::PbsNode(r) => r.name.as_str(),
>> -            Resource::PbsDatastore(r) => r.name.as_str(),
>> -        }
>> +        self.as_resource_view().name()
>>       }
>>   
> 
> While at it, you could add some doc strings here.
> 
>>       pub fn resource_type(&self) -> ResourceType {
>> -        match self {
>> -            Resource::PveStorage(_) => ResourceType::PveStorage,
>> -            Resource::PveQemu(_) => ResourceType::PveQemu,
>> -            Resource::PveLxc(_) => ResourceType::PveLxc,
>> -            Resource::PveNetwork(_) => ResourceType::PveNetwork,
>> -            Resource::PveNode(_) | Resource::PbsNode(_) => ResourceType::Node,
>> -            Resource::PbsDatastore(_) => ResourceType::PbsDatastore,
>> -        }
>> +        self.as_resource_view().resource_type()
>>       }
>>   
> While at it, you could add some doc strings here.
> 
>>       pub fn status(&self) -> &str {
>> -        match self {
>> -            Resource::PveStorage(r) => r.status.as_str(),
>> -            Resource::PveQemu(r) => r.status.as_str(),
>> -            Resource::PveLxc(r) => r.status.as_str(),
>> -            Resource::PveNode(r) => r.status.as_str(),
>> -            Resource::PveNetwork(r) => r.status(),
>> -            Resource::PbsNode(r) => {
>> -                if r.uptime > 0 {
>> -                    "online"
>> -                } else {
>> -                    "offline"
>> -                }
>> -            }
>> -            Resource::PbsDatastore(r) => {
>> -                if r.maintenance.is_none() {
>> -                    "online"
>> -                } else {
>> -                    "under-maintenance"
>> -                }
>> -            }
>> -        }
>> +        self.as_resource_view().status()
>>       }
>>   
> While at it, you could add some doc strings here.
> 
>>       pub fn properties(&self) -> String {
>> -        let mut properties = Vec::new();
>> -        if let Resource::PbsDatastore(r) = self {
>> -            if let Some(backend_type) = &r.backend_type {
>> -                properties.push(backend_type.to_string());
>> -            }
>> -            if r.backing_device.is_some() {
>> -                properties.push("removable".to_string());
>> -            }
>> -            if r.usage > PBS_DATASTORE_HIGH_USAGE_THRESHOLD {
>> -                properties.push("high-usage".to_string());
>> -            }
>> -        }
>> -        properties.join(",")
>> +        self.as_resource_view().properties()
>>       }
>>   }
>>   
>> @@ -229,6 +158,184 @@ pub enum PveResource {
>>       Network(PveNetworkResource),
>>   }
>>   
>> +/// A borrowed view of a resource that can come from a [Resource] or a [PveResource]
>> +/// This is useful for having a single implementation of field access regardless which
>> +/// of the types is used.
>> +#[derive(Clone, Debug, PartialEq)]
>> +pub enum ResourceView<'a> {
>> +    /// Storage resource in PVE.
>> +    PveStorage(&'a PveStorageResource),
>> +    /// QEMU guest resource in PVE.
>> +    PveQemu(&'a PveQemuResource),
>> +    /// LXC guest resource in PVE.
>> +    PveLxc(&'a PveLxcResource),
>> +    /// A PVE node.
>> +    PveNode(&'a PveNodeResource),
>> +    /// Network in PVE.
>> +    PveNetwork(&'a PveNetworkResource),
>> +    /// A PBS node.
>> +    PbsNode(&'a PbsNodeResource),
>> +    /// Datastore on a PBS node.
>> +    PbsDatastore(&'a PbsDatastoreResource),
>> +}
>> +
>> +impl<'a> ResourceView<'a> {
>> +    /// Returns the local ID, not a globally unique one, e.g.
>> +    /// `qemu/<vmid>`
>> +    pub fn id(&self) -> String {
>> +        match self {
>> +            ResourceView::PveStorage(r) => format!("storage/{}/{}", r.node, r.storage),
>> +            ResourceView::PveQemu(r) => format!("qemu/{}", r.vmid),
>> +            ResourceView::PveLxc(r) => format!("lxc/{}", r.vmid),
>> +            ResourceView::PveNode(r) => format!("node/{}", r.node),
>> +            ResourceView::PveNetwork(r) => {
>> +                if let PveNetworkResource::Zone(z) = r {
>> +                    if z.legacy {
>> +                        return format!("sdn/{}/{}", r.node(), r.name());
>> +                    }
>> +                }
>> +
>> +                format!("network/{}/{}/{}", r.node(), r.network_type(), r.name())
>> +            }
>> +            ResourceView::PbsNode(r) => format!("node/{}", r.name),
>> +            ResourceView::PbsDatastore(r) => r.name.clone(),
>> +        }
>> +    }
>> +
>> +    /// Returns the PDM global ID for the resource, e.g.
>> +    /// `remote/<remote-id>/guest/<vmid>`
>> +    pub fn global_id(&self) -> &'a str {
>> +        match self {
>> +            ResourceView::PveStorage(r) => r.id.as_str(),
>> +            ResourceView::PveQemu(r) => r.id.as_str(),
>> +            ResourceView::PveLxc(r) => r.id.as_str(),
>> +            ResourceView::PveNode(r) => r.id.as_str(),
>> +            ResourceView::PveNetwork(r) => r.id(),
>> +            ResourceView::PbsNode(r) => r.id.as_str(),
>> +            ResourceView::PbsDatastore(r) => r.id.as_str(),
>> +        }
>> +    }
>> +
>> +    /// Returns the "name" of the resource, e.g. the guest name for VMs/Containers or
>> +    /// the hostname for nodes
>> +    pub fn name(&self) -> &'a str {
>> +        match self {
>> +            ResourceView::PveStorage(r) => r.storage.as_str(),
>> +            ResourceView::PveQemu(r) => r.name.as_str(),
>> +            ResourceView::PveLxc(r) => r.name.as_str(),
>> +            ResourceView::PveNode(r) => r.node.as_str(),
>> +            ResourceView::PveNetwork(r) => r.name(),
>> +            ResourceView::PbsNode(r) => r.name.as_str(),
>> +            ResourceView::PbsDatastore(r) => r.name.as_str(),
>> +        }
>> +    }
>> +
> 
> Missing doc strings here.
>> +    pub fn resource_type(&self) -> ResourceType {
>> +        match self {
>> +            ResourceView::PveStorage(_) => ResourceType::PveStorage,
>> +            ResourceView::PveQemu(_) => ResourceType::PveQemu,
>> +            ResourceView::PveLxc(_) => ResourceType::PveLxc,
>> +            ResourceView::PveNetwork(_) => ResourceType::PveNetwork,
>> +            ResourceView::PveNode(_) | ResourceView::PbsNode(_) => ResourceType::Node,
>> +            ResourceView::PbsDatastore(_) => ResourceType::PbsDatastore,
>> +        }
>> +    }
>> +
> 
> here as well
>> +    pub fn status(&self) -> &'a str {
>> +        match self {
>> +            ResourceView::PveStorage(r) => r.status.as_str(),
>> +            ResourceView::PveQemu(r) => r.status.as_str(),
>> +            ResourceView::PveLxc(r) => r.status.as_str(),
>> +            ResourceView::PveNode(r) => r.status.as_str(),
>> +            ResourceView::PveNetwork(r) => r.status(),
>> +            ResourceView::PbsNode(r) => {
>> +                if r.uptime > 0 {
>> +                    "online"
>> +                } else {
>> +                    "offline"
>> +                }
>> +            }
>> +            ResourceView::PbsDatastore(r) => {
>> +                if r.maintenance.is_none() {
>> +                    "online"
>> +                } else {
>> +                    "under-maintenance"
>> +                }
>> +            }
>> +        }
>> +    }
>> +
> 
> here as well
>> +    pub fn properties(&self) -> String {
>> +        let mut properties = Vec::new();
>> +        if let ResourceView::PbsDatastore(r) = self {
>> +            if let Some(backend_type) = &r.backend_type {
>> +                properties.push(backend_type.to_string());
>> +            }
>> +            if r.backing_device.is_some() {
>> +                properties.push("removable".to_string());
>> +            }
>> +            if r.usage > PBS_DATASTORE_HIGH_USAGE_THRESHOLD {
>> +                properties.push("high-usage".to_string());
>> +            }
>> +        }
>> +        properties.join(",")
>> +    }
>> +}
>> +
>> +impl<'a> From<&'a Resource> for ResourceView<'a> {
>> +    fn from(value: &'a Resource) -> Self {
>> +        match value {
>> +            Resource::PveStorage(r) => ResourceView::PveStorage(r),
>> +            Resource::PveQemu(r) => ResourceView::PveQemu(r),
>> +            Resource::PveLxc(r) => ResourceView::PveLxc(r),
>> +            Resource::PveNode(r) => ResourceView::PveNode(r),
>> +            Resource::PveNetwork(r) => ResourceView::PveNetwork(r),
>> +            Resource::PbsNode(r) => ResourceView::PbsNode(r),
>> +            Resource::PbsDatastore(r) => ResourceView::PbsDatastore(r),
>> +        }
>> +    }
>> +}
>> +
>> +impl<'a> From<&'a PveResource> for ResourceView<'a> {
>> +    fn from(value: &'a PveResource) -> Self {
>> +        match value {
>> +            PveResource::Storage(r) => ResourceView::PveStorage(r),
>> +            PveResource::Qemu(r) => ResourceView::PveQemu(r),
>> +            PveResource::Lxc(r) => ResourceView::PveLxc(r),
>> +            PveResource::Node(r) => ResourceView::PveNode(r),
>> +            PveResource::Network(r) => ResourceView::PveNetwork(r),
>> +        }
>> +    }
>> +}
>> +
> 
> missing doc string here
> 
>> +pub trait AsResourceView {
>> +    fn as_resource_view(&self) -> ResourceView<'_>;
>> +}
>> +
>> +impl AsResourceView for Resource {
>> +    fn as_resource_view<'a>(&'a self) -> ResourceView<'a> {
>> +        ResourceView::from(self)
>> +    }
>> +}
>> +
>> +impl AsResourceView for &Resource {
>> +    fn as_resource_view<'a>(&'a self) -> ResourceView<'a> {
>> +        ResourceView::from(*self)
>> +    }
>> +}
>> +
>> +impl AsResourceView for PveResource {
>> +    fn as_resource_view<'a>(&'a self) -> ResourceView<'a> {
>> +        ResourceView::from(self)
>> +    }
>> +}
>> +
>> +impl AsResourceView for &PveResource {
>> +    fn as_resource_view<'a>(&'a self) -> ResourceView<'a> {
>> +        ResourceView::from(*self)
>> +    }
>> +}
>> +
>>   #[api(
>>       properties: {
>>           tags: {
> 





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

* superseded: [PATCH datacenter-manager v2 0/9] refactor and partially fix #7371
  2026-08-18 13:25 [PATCH datacenter-manager v2 0/9] refactor and partially fix #7371 Dominik Csapak
                   ` (9 preceding siblings ...)
  2026-08-20 11:08 ` [PATCH datacenter-manager v2 0/9] refactor and partially fix #7371 Lukas Wagner
@ 2026-08-21 12:51 ` Dominik Csapak
  10 siblings, 0 replies; 16+ messages in thread
From: Dominik Csapak @ 2026-08-21 12:51 UTC (permalink / raw)
  To: pdm-devel

superseded by v3:
https://lore.proxmox.com/pdm-devel/20260821123201.3035643-1-d.csapak@proxmox.com/T/#t




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

end of thread, other threads:[~2026-08-21 12:51 UTC | newest]

Thread overview: 16+ messages (download: mbox.gz follow: Atom feed
-- links below jump to the message on this page --
2026-08-18 13:25 [PATCH datacenter-manager v2 0/9] refactor and partially fix #7371 Dominik Csapak
2026-08-18 13:25 ` [PATCH datacenter-manager v2 1/9] lib: api types: add new ResourceView type and move accessors there Dominik Csapak
2026-08-20 11:07   ` Lukas Wagner
2026-08-20 11:20     ` Dominik Csapak
2026-08-18 13:25 ` [PATCH datacenter-manager v2 2/9] lib: api types: resource: add 'node' helper to ResourceView Dominik Csapak
2026-08-18 13:25 ` [PATCH datacenter-manager v2 3/9] lib: api types: add guest specific getter " Dominik Csapak
2026-08-18 13:25 ` [PATCH datacenter-manager v2 4/9] ui: pve: factor out the pve-manager version extraction Dominik Csapak
2026-08-18 13:25 ` [PATCH datacenter-manager v2 5/9] ui: renderer: use ResourceView for rendering Dominik Csapak
2026-08-18 13:25 ` [PATCH datacenter-manager v2 6/9] ui: pve: tree: reuse `PveResource` for `PveTreeNode` Dominik Csapak
2026-08-18 13:25 ` [PATCH datacenter-manager v2 7/9] ui: pve: show ha maintenance mode for nodes Dominik Csapak
2026-08-20 11:07   ` Lukas Wagner
2026-08-20 11:20     ` Dominik Csapak
2026-08-18 13:25 ` [PATCH datacenter-manager v2 8/9] ui: pve: node selector: show maintenance badge with node name Dominik Csapak
2026-08-18 13:25 ` [PATCH datacenter-manager v2 9/9] ui: pve: node: show HA maintenance badge Dominik Csapak
2026-08-20 11:08 ` [PATCH datacenter-manager v2 0/9] refactor and partially fix #7371 Lukas Wagner
2026-08-21 12:51 ` superseded: " Dominik Csapak

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