public inbox for pdm-devel@lists.proxmox.com
 help / color / mirror / Atom feed
From: Dominik Csapak <d.csapak@proxmox.com>
To: pdm-devel@lists.proxmox.com
Subject: [RFC PATCH datacenter-manager 2/2] ui: pve: load and use tag style overrides for resource tree
Date: Fri, 17 Apr 2026 16:10:41 +0200	[thread overview]
Message-ID: <20260417141237.2004866-2-d.csapak@proxmox.com> (raw)
In-Reply-To: <20260417141237.2004866-1-d.csapak@proxmox.com>

This loads the tag style overrides (currently only the color), from
/cluster/options once per remote when the remotes panel is opened,
and uses the color-map to change the appearance of the tags.

Since the /cluster/options return value is just a `Value` currently,
introduce our own `TagStyle` and `DataCenterOptions` struct for parsing.
(This can be dropped when we update the return schema and types in
pve-manager and redo the return type in the pdm api)

These will be converted into a `TagStyleOverride` struct that contains
the necessary infos and interfaces and can be cloned cheaply (as it uses
an Rc for the internal HashMap) so that we can easily move this around
in the code, e.g. into closures and callbacks.

The only downside to this is, that the global tree (e.g. in the search
bar or a view) does not have these overrides and the tags can be
different.

Signed-off-by: Dominik Csapak <d.csapak@proxmox.com>
---
Not sure if we want to use the per remote color/style overrides in PDM
at all, so I only included the color for now and sent as RFC. If we want
to include the style, sorting, etc. it should be easy to include.

If we want this, I'd also go and fix the return types in pve-manager,
and update pve-api-types of course. Until then we could leave this in as
a stopgap.

 ui/src/pve/mod.rs              | 75 +++++++++++++++++++++++++++++++++-
 ui/src/pve/tree.rs             | 19 +++++++--
 ui/src/pve/utils.rs            | 22 +++++++---
 ui/src/widget/resource_tree.rs | 15 ++++---
 4 files changed, 116 insertions(+), 15 deletions(-)

diff --git a/ui/src/pve/mod.rs b/ui/src/pve/mod.rs
index 34109931..426c5ee1 100644
--- a/ui/src/pve/mod.rs
+++ b/ui/src/pve/mod.rs
@@ -1,7 +1,8 @@
-use std::{fmt::Display, rc::Rc};
+use std::{collections::HashMap, fmt::Display, rc::Rc};
 
 use gloo_utils::window;
 use serde::{Deserialize, Serialize};
+use serde_json::Value;
 use yew::{
     prelude::Html,
     virtual_dom::{VComp, VNode},
@@ -141,6 +142,7 @@ pub enum Msg {
         Result<Vec<PveResource>, Error>,
         Result<RemoteUpdateSummary, Error>,
     ),
+    OptionsLoadFinished(Result<Value, Error>),
 }
 
 pub struct PveRemoteComp {
@@ -149,10 +151,66 @@ pub struct PveRemoteComp {
     resources: Rc<Vec<PveResource>>,
     last_error: Option<String>,
     updates: LoadResult<RemoteUpdateSummary, Error>,
+    tag_style_override: TagStyleOverride,
 }
 
 pwt::impl_deref_mut_property!(PveRemoteComp, state, LoadableComponentState<()>);
 
+#[derive(Clone, PartialEq, Default)]
+/// Holds overrides for the guest tag display
+///
+/// This can be cloned cheaply, so it can be easily passed to e.g. renderer closures.
+pub struct TagStyleOverride {
+    color_map: Rc<HashMap<String, (String, String)>>,
+}
+
+impl TagStyleOverride {
+    /// Returns the background and foreground color of a tag as CSS hex color strings.
+    pub fn get(&self, name: &str) -> Option<&(String, String)> {
+        self.color_map.get(name)
+    }
+
+    /// Parses the tag style override from a [serde_json::Value]
+    pub fn from_value(value: Value) -> Self {
+        let mut map = HashMap::new();
+        match serde_json::from_value::<DataCenterOptions>(value) {
+            Ok(DataCenterOptions {
+                tag_style: Some(tag_style),
+            }) => {
+                for tag_override in tag_style.color_map.split(';') {
+                    let mut list = tag_override.splitn(3, ':');
+                    if let (Some(name), Some(bg), Some(fg)) =
+                        (list.next(), list.next(), list.next())
+                    {
+                        // store with css color syntax directly
+                        map.insert(name.to_string(), (format!("#{bg}"), format!("#{fg}")));
+                    }
+                }
+            }
+            Ok(_) => {} // no override options
+            Err(err) => log::warn!("error parsing tag style options: {err}"),
+        }
+
+        Self {
+            color_map: Rc::new(map),
+        }
+    }
+}
+
+// types for Deserialize
+#[derive(Deserialize)]
+#[serde(rename_all = "kebab-case")]
+struct TagStyle {
+    #[serde(default)]
+    color_map: String,
+}
+
+#[derive(Deserialize)]
+#[serde(rename_all = "kebab-case")]
+struct DataCenterOptions {
+    tag_style: Option<TagStyle>,
+}
+
 impl LoadableComponent for PveRemoteComp {
     type Message = Msg;
     type Properties = PveRemote;
@@ -160,12 +218,22 @@ impl LoadableComponent for PveRemoteComp {
 
     fn create(ctx: &LoadableComponentContext<PveRemoteComp>) -> Self {
         ctx.link().repeated_load(5000);
+
+        // load datacenter options only once
+        let remote = ctx.props().remote.clone();
+        ctx.link().send_future(async move {
+            let client = crate::pdm_client();
+            let options = client.pve_cluster_options(&remote).await;
+            Msg::OptionsLoadFinished(options)
+        });
+
         Self {
             state: LoadableComponentState::new(),
             view: PveTreeNode::Root,
             resources: Rc::new(Vec::new()),
             last_error: None,
             updates: LoadResult::new(),
+            tag_style_override: TagStyleOverride::default(),
         }
     }
 
@@ -188,6 +256,10 @@ impl LoadableComponent for PveRemoteComp {
                 };
                 self.updates.update(updates);
             }
+            Msg::OptionsLoadFinished(options) => match options {
+                Ok(options) => self.tag_style_override = TagStyleOverride::from_value(options),
+                Err(err) => log::warn!("error loading datacenter options: {err}"),
+            },
         }
         true
     }
@@ -281,6 +353,7 @@ impl LoadableComponent for PveRemoteComp {
                                 remote.to_string(),
                                 self.resources.clone(),
                                 self.loading(),
+                                self.tag_style_override.clone(),
                                 link.callback(Msg::SelectedView),
                                 {
                                     let link = link.clone();
diff --git a/ui/src/pve/tree.rs b/ui/src/pve/tree.rs
index c3e3e8b2..7e09fb6b 100644
--- a/ui/src/pve/tree.rs
+++ b/ui/src/pve/tree.rs
@@ -27,7 +27,9 @@ use pdm_api_types::{
     RemoteUpid,
 };
 
-use crate::{get_deep_url, renderer::render_tree_column, widget::MigrateWindow};
+use crate::{
+    get_deep_url, pve::TagStyleOverride, renderer::render_tree_column, widget::MigrateWindow,
+};
 
 use super::{
     utils::{self, render_guest_tags, render_lxc_name, render_qemu_name},
@@ -77,6 +79,8 @@ pub struct PveTree {
 
     loading: bool,
 
+    tag_style_override: TagStyleOverride,
+
     on_select: Callback<PveTreeNode>,
 
     on_reload_click: Callback<()>,
@@ -87,6 +91,7 @@ impl PveTree {
         remote: String,
         resources: Rc<Vec<PveResource>>,
         loading: bool,
+        tag_style_override: TagStyleOverride,
         on_select: impl Into<Callback<PveTreeNode>>,
         on_reload_click: impl Into<Callback<()>>,
     ) -> Self {
@@ -94,6 +99,7 @@ impl PveTree {
             remote,
             resources,
             loading,
+            tag_style_override,
             on_select: on_select.into(),
             on_reload_click: on_reload_click.into(),
         })
@@ -312,6 +318,7 @@ impl LoadableComponent for PveTreeComp {
                 store.clone(),
                 ctx.props().remote.clone(),
                 ctx.props().loading,
+                ctx.props().tag_style_override.clone(),
             ),
             loaded: false,
             store,
@@ -464,6 +471,7 @@ impl LoadableComponent for PveTreeComp {
             self.store.clone(),
             props.remote.clone(),
             props.loading,
+            props.tag_style_override.clone(),
         );
 
         true
@@ -589,6 +597,7 @@ fn columns(
     store: TreeStore<PveTreeNode>,
     remote: String,
     loading: bool,
+    tag_style_override: TagStyleOverride,
 ) -> Rc<Vec<DataTableHeader<PveTreeNode>>> {
     let loading = match store.read().root() {
         Some(root) => loading && root.children_count() == 0,
@@ -626,8 +635,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::Lxc(lxc) => {
+                    render_guest_tags(&lxc.tags[..], &tag_style_override).into()
+                }
+                PveTreeNode::Qemu(qemu) => {
+                    render_guest_tags(&qemu.tags[..], &tag_style_override).into()
+                }
                 _ => html! {},
             })
             .into(),
diff --git a/ui/src/pve/utils.rs b/ui/src/pve/utils.rs
index a52689ec..89a00cee 100644
--- a/ui/src/pve/utils.rs
+++ b/ui/src/pve/utils.rs
@@ -16,6 +16,8 @@ use pwt::{
     widget::{Container, Fa, Row},
 };
 
+use crate::pve::TagStyleOverride;
+
 /// Renders the display name for Virtual Machines, e.g. used for resource trees
 pub fn render_qemu_name(qemu: &PveQemuResource, vmid_first: bool) -> String {
     render_guest_name(&qemu.name, qemu.vmid, vmid_first)
@@ -121,21 +123,31 @@ pub fn render_storage_status_icon(node: &PveStorageResource) -> Container {
 }
 
 /// Returns a [`pwt::widget::Row`] with an element for each tag
-pub fn render_guest_tags(tags: &[String]) -> Row {
+pub fn render_guest_tags(tags: &[String], tag_style_override: &TagStyleOverride) -> Row {
     let mut row = Row::new().class("pve-tags").gap(2);
 
     for tag in tags {
         if tag.is_empty() {
             continue;
         }
-        let color = pdm_ui_shared::colors::text_to_rgb(tag).unwrap();
-        let foreground = pdm_ui_shared::colors::get_best_contrast_color(&color);
+
+        let (color, foreground) = match tag_style_override.get(tag) {
+            Some(tag_override) => tag_override.clone(),
+            None => {
+                let color = pdm_ui_shared::colors::text_to_rgb(tag).unwrap();
+                let foreground = pdm_ui_shared::colors::get_best_contrast_color(&color);
+                (
+                    color.as_css_rgb().to_string(),
+                    foreground.as_css_rgb().to_string(),
+                )
+            }
+        };
 
         row.add_child(
             Container::new()
                 .class("pve-tag")
-                .style("background-color", color.as_css_rgb().to_string())
-                .style("color", foreground.as_css_rgb().to_string())
+                .style("background-color", color)
+                .style("color", foreground)
                 .with_child(tag),
         );
     }
diff --git a/ui/src/widget/resource_tree.rs b/ui/src/widget/resource_tree.rs
index ba178b89..dc67fe94 100644
--- a/ui/src/widget/resource_tree.rs
+++ b/ui/src/widget/resource_tree.rs
@@ -33,7 +33,7 @@ use pdm_api_types::resource::{RemoteResources, Resource};
 use crate::{
     dashboard::view::ViewContext,
     get_deep_url, get_resource_node,
-    pve::utils::render_guest_tags,
+    pve::{utils::render_guest_tags, TagStyleOverride},
     renderer::{render_resource_name, render_status_icon},
     RemoteList,
 };
@@ -381,11 +381,14 @@ fn columns(
                         Row::new()
                             .gap(1)
                             .with_child(render_resource_name(resource, true))
-                            .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[..],
-                                _ => &[],
-                            }))
+                            .with_child(render_guest_tags(
+                                match resource {
+                                    Resource::PveQemu(qemu) => &qemu.tags[..],
+                                    Resource::PveLxc(lxc) => &lxc.tags[..],
+                                    _ => &[],
+                                },
+                                &TagStyleOverride::default(),
+                            ))
                             .into(),
                         None,
                     ),
-- 
2.47.3





      reply	other threads:[~2026-04-17 14:12 UTC|newest]

Thread overview: 2+ messages / expand[flat|nested]  mbox.gz  Atom feed  top
2026-04-17 14:10 [RFC PATCH datacenter-manager 1/2] lib: pdm-client: add method for /cluster/options Dominik Csapak
2026-04-17 14:10 ` Dominik Csapak [this message]

Reply instructions:

You may reply publicly to this message via plain-text email
using any one of the following methods:

* Save the following mbox file, import it into your mail client,
  and reply-to-all from there: mbox

  Avoid top-posting and favor interleaved quoting:
  https://en.wikipedia.org/wiki/Posting_style#Interleaved_style

* Reply using the --to, --cc, and --in-reply-to
  switches of git-send-email(1):

  git send-email \
    --in-reply-to=20260417141237.2004866-2-d.csapak@proxmox.com \
    --to=d.csapak@proxmox.com \
    --cc=pdm-devel@lists.proxmox.com \
    /path/to/YOUR_REPLY

  https://kernel.org/pub/software/scm/git/docs/git-send-email.html

* If your mail client supports setting the In-Reply-To header
  via mailto: links, try the mailto: link
Be sure your reply has a Subject: header at the top and a blank line before the message body.
This is a public inbox, see mirroring instructions
for how to clone and mirror all data and code used for this inbox
Service provided by Proxmox Server Solutions GmbH | Privacy | Legal