From mboxrd@z Thu Jan 1 00:00:00 1970 Return-Path: Received: from firstgate.proxmox.com (firstgate.proxmox.com [IPv6:2a01:7e0:0:424::9]) by lore.proxmox.com (Postfix) with ESMTPS id EE8E81FF13E for ; Fri, 17 Apr 2026 16:12:42 +0200 (CEST) Received: from firstgate.proxmox.com (localhost [127.0.0.1]) by firstgate.proxmox.com (Proxmox) with ESMTP id 23DE23F50; Fri, 17 Apr 2026 16:12:42 +0200 (CEST) From: Dominik Csapak 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 Message-ID: <20260417141237.2004866-2-d.csapak@proxmox.com> X-Mailer: git-send-email 2.47.3 In-Reply-To: <20260417141237.2004866-1-d.csapak@proxmox.com> References: <20260417141237.2004866-1-d.csapak@proxmox.com> MIME-Version: 1.0 Content-Transfer-Encoding: 8bit X-SPAM-LEVEL: Spam detection results: 0 AWL -0.150 Adjusted score from AWL reputation of From: address BAYES_00 -1.9 Bayes spam probability is 0 to 1% DMARC_MISSING 0.1 Missing DMARC policy KAM_DMARC_STATUS 0.01 Test Rule for DKIM or SPF Failure with Strict Alignment POISEN_SPAM_PILL 0.1 Meta: its spam POISEN_SPAM_PILL_1 0.1 random spam to be learned in bayes POISEN_SPAM_PILL_3 0.1 random spam to be learned in bayes PROLO_LEO1 0.1 Meta Catches all Leo drug variations so far SPF_HELO_NONE 0.001 SPF: HELO does not publish an SPF Record SPF_PASS -0.001 SPF: sender matches SPF record Message-ID-Hash: EGDRXFQL6RESFOC6EH36456NIFC5QT3C X-Message-ID-Hash: EGDRXFQL6RESFOC6EH36456NIFC5QT3C X-MailFrom: d.csapak@proxmox.com X-Mailman-Rule-Misses: dmarc-mitigation; no-senders; approved; loop; banned-address; emergency; member-moderation; nonmember-moderation; administrivia; implicit-dest; max-recipients; max-size; news-moderation; no-subject; digests; suspicious-header X-Mailman-Version: 3.3.10 Precedence: list List-Id: Proxmox Datacenter Manager development discussion List-Help: List-Owner: List-Post: List-Subscribe: List-Unsubscribe: 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 --- 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, Error>, Result, ), + OptionsLoadFinished(Result), } pub struct PveRemoteComp { @@ -149,10 +151,66 @@ pub struct PveRemoteComp { resources: Rc>, last_error: Option, updates: LoadResult, + 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>, +} + +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::(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, +} + impl LoadableComponent for PveRemoteComp { type Message = Msg; type Properties = PveRemote; @@ -160,12 +218,22 @@ impl LoadableComponent for PveRemoteComp { fn create(ctx: &LoadableComponentContext) -> 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, on_reload_click: Callback<()>, @@ -87,6 +91,7 @@ impl PveTree { remote: String, resources: Rc>, loading: bool, + tag_style_override: TagStyleOverride, on_select: impl Into>, on_reload_click: impl Into>, ) -> 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, remote: String, loading: bool, + tag_style_override: TagStyleOverride, ) -> Rc>> { 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