From caff845c1acc152297b84803c5dfc7cae29ada86 Mon Sep 17 00:00:00 2001 From: rcourtman Date: Sun, 15 Mar 2026 19:49:46 +0000 Subject: [PATCH] fix(ui): use Proxmox tag colours from datacenter config Pulse was generating tag colours from a hash of the tag name instead of using the colours configured in Proxmox. Now polls /cluster/options once per PVE instance and merges the tag-style colour map into state, which the frontend uses as the first-priority colour source for tag badges. Falls back to the existing special-tag and hash-based colours when Proxmox hasn't set a custom colour for a tag. --- .../src/components/Dashboard/TagBadges.tsx | 5 ++- frontend-modern/src/stores/websocket.ts | 1 + frontend-modern/src/types/api.ts | 1 + frontend-modern/src/utils/tagColors.ts | 11 ++++- internal/models/models.go | 17 ++++++++ internal/models/models_frontend.go | 1 + internal/models/state_snapshot.go | 10 ++++- internal/monitoring/monitor.go | 13 ++++++ pkg/proxmox/client.go | 42 +++++++++++++++++++ 9 files changed, 98 insertions(+), 3 deletions(-) diff --git a/frontend-modern/src/components/Dashboard/TagBadges.tsx b/frontend-modern/src/components/Dashboard/TagBadges.tsx index bf6dc2f93..c7d5aa9ff 100644 --- a/frontend-modern/src/components/Dashboard/TagBadges.tsx +++ b/frontend-modern/src/components/Dashboard/TagBadges.tsx @@ -2,6 +2,7 @@ import { Component, For, Show } from 'solid-js'; import { getTagColorWithSpecial } from '@/utils/tagColors'; import { useDarkMode } from '@/App'; import { showTooltip, hideTooltip } from '@/components/shared/Tooltip'; +import { getGlobalWebSocketStore } from '@/stores/websocket-global'; interface TagBadgesProps { tags?: string[]; @@ -16,12 +17,14 @@ export const TagBadges: Component = (props) => { const maxVisible = () => props.maxVisible === 0 ? Infinity : (props.maxVisible ?? 3); const darkModeSignal = useDarkMode(); const isDark = () => props.isDarkMode ?? darkModeSignal(); + const ws = getGlobalWebSocketStore(); + const pveTagColors = () => ws.state.pveTagColors; const visibleTags = () => props.tags?.slice(0, maxVisible()) || []; const hiddenTags = () => props.tags?.slice(maxVisible()) || []; const TagDot: Component<{ tag: string }> = (dotProps) => { - const colors = () => getTagColorWithSpecial(dotProps.tag, isDark()); + const colors = () => getTagColorWithSpecial(dotProps.tag, isDark(), pveTagColors()); const isActive = () => props.activeSearch?.includes(`tags:${dotProps.tag}`) || false; return ( diff --git a/frontend-modern/src/stores/websocket.ts b/frontend-modern/src/stores/websocket.ts index 4318fd3df..491b14a2b 100644 --- a/frontend-modern/src/stores/websocket.ts +++ b/frontend-modern/src/stores/websocket.ts @@ -83,6 +83,7 @@ export function createWebSocketStore(url: string) { activeAlerts: [], recentlyResolved: [], lastUpdate: '', + pveTagColors: {}, // Unified resources for cross-platform monitoring resources: [], }); diff --git a/frontend-modern/src/types/api.ts b/frontend-modern/src/types/api.ts index aac394657..5aa51ba02 100644 --- a/frontend-modern/src/types/api.ts +++ b/frontend-modern/src/types/api.ts @@ -30,6 +30,7 @@ export interface State { recentlyResolved: ResolvedAlert[]; lastUpdate: string; temperatureMonitoringEnabled?: boolean; + pveTagColors?: Record; // Unified resources (new data model - eventually replaces legacy arrays above) resources?: Resource[]; } diff --git a/frontend-modern/src/utils/tagColors.ts b/frontend-modern/src/utils/tagColors.ts index 0f9dd8942..8363ae883 100644 --- a/frontend-modern/src/utils/tagColors.ts +++ b/frontend-modern/src/utils/tagColors.ts @@ -93,14 +93,23 @@ const specialTagColors: Record = { }; /** - * Get color for a tag, checking special colors first + * Get color for a tag. + * Priority: Proxmox-supplied hex color → special hardcoded colors → hash-based fallback. + * @param colorMap Optional map of lowercase tag name → "#rrggbb" from Proxmox datacenter config. */ export function getTagColorWithSpecial( tag: string, isDarkMode: boolean, + colorMap?: Record, ): { bg: string; text: string; border: string } { const lowerTag = tag.toLowerCase(); + // Use Proxmox-supplied colour when available + if (colorMap?.[lowerTag]) { + const hex = colorMap[lowerTag]; + return { bg: hex, text: '#ffffff', border: hex }; + } + // Check if it's a special tag if (specialTagColors[lowerTag]) { return isDarkMode ? specialTagColors[lowerTag].dark : specialTagColors[lowerTag].light; diff --git a/internal/models/models.go b/internal/models/models.go index ff69226ae..b38db4b39 100644 --- a/internal/models/models.go +++ b/internal/models/models.go @@ -39,6 +39,7 @@ type State struct { RecentlyResolved []ResolvedAlert `json:"recentlyResolved"` LastUpdate time.Time `json:"lastUpdate"` TemperatureMonitoringEnabled bool `json:"temperatureMonitoringEnabled"` + PVETagColors map[string]string `json:"pveTagColors,omitempty"` } // Alert represents an active alert (simplified for State) @@ -2968,6 +2969,22 @@ func (s *State) SetConnectionHealth(instanceID string, healthy bool) { s.ConnectionHealth[instanceID] = healthy } +// MergeTagColors merges tag→colour entries into the shared PVETagColors map. +// Entries from the latest poll overwrite previous ones for the same tag name. +func (s *State) MergeTagColors(colors map[string]string) { + if len(colors) == 0 { + return + } + s.mu.Lock() + defer s.mu.Unlock() + if s.PVETagColors == nil { + s.PVETagColors = make(map[string]string, len(colors)) + } + for k, v := range colors { + s.PVETagColors[k] = v + } +} + // RemoveConnectionHealth removes a connection health entry if it exists. func (s *State) RemoveConnectionHealth(instanceID string) { s.mu.Lock() diff --git a/internal/models/models_frontend.go b/internal/models/models_frontend.go index c57c7394c..8d5fc7426 100644 --- a/internal/models/models_frontend.go +++ b/internal/models/models_frontend.go @@ -586,6 +586,7 @@ type StateFrontend struct { Stats map[string]any `json:"stats"` // Empty object for now LastUpdate int64 `json:"lastUpdate"` // Unix timestamp TemperatureMonitoringEnabled bool `json:"temperatureMonitoringEnabled"` // Global temperature monitoring setting + PVETagColors map[string]string `json:"pveTagColors,omitempty"` // Tag name → "#rrggbb" from Proxmox datacenter config // Unified resources - the new way to access all monitored entities Resources []ResourceFrontend `json:"resources,omitempty"` } diff --git a/internal/models/state_snapshot.go b/internal/models/state_snapshot.go index da2678b32..f121ef021 100644 --- a/internal/models/state_snapshot.go +++ b/internal/models/state_snapshot.go @@ -31,6 +31,7 @@ type StateSnapshot struct { RecentlyResolved []ResolvedAlert `json:"recentlyResolved"` LastUpdate time.Time `json:"lastUpdate"` TemperatureMonitoringEnabled bool `json:"temperatureMonitoringEnabled"` + PVETagColors map[string]string `json:"pveTagColors,omitempty"` } // GetSnapshot returns a snapshot of the current state without mutex @@ -81,10 +82,16 @@ func (s *State) GetSnapshot() StateSnapshot { TemperatureMonitoringEnabled: s.TemperatureMonitoringEnabled, } - // Copy map + // Copy maps for k, v := range s.ConnectionHealth { snapshot.ConnectionHealth[k] = v } + if len(s.PVETagColors) > 0 { + snapshot.PVETagColors = make(map[string]string, len(s.PVETagColors)) + for k, v := range s.PVETagColors { + snapshot.PVETagColors[k] = v + } + } return snapshot } @@ -418,5 +425,6 @@ func (s StateSnapshot) ToFrontend() StateFrontend { Stats: make(map[string]any), LastUpdate: s.LastUpdate.Unix() * 1000, // JavaScript timestamp TemperatureMonitoringEnabled: s.TemperatureMonitoringEnabled, + PVETagColors: s.PVETagColors, } } diff --git a/internal/monitoring/monitor.go b/internal/monitoring/monitor.go index cf190cf21..af697fdce 100644 --- a/internal/monitoring/monitor.go +++ b/internal/monitoring/monitor.go @@ -6380,6 +6380,19 @@ func (m *Monitor) pollPVEInstance(ctx context.Context, instanceName string, clie // Reset auth failures on successful connection m.resetAuthFailures(instanceName, "pve") + // Best-effort: fetch tag colour map from Proxmox datacenter config. + // Not all Proxmox versions expose this; errors are silently ignored. + type clusterOptionsGetter interface { + GetClusterOptions(ctx context.Context) (*proxmox.ClusterOptions, error) + } + if og, ok := client.(clusterOptionsGetter); ok { + if opts, err := og.GetClusterOptions(ctx); err == nil && opts != nil { + if colors := proxmox.ParseTagColorMap(opts.TagStyle); len(colors) > 0 { + m.state.MergeTagColors(colors) + } + } + } + // Check if client is a ClusterClient to determine health status connectionHealthStr := "healthy" if clusterClient, ok := client.(*proxmox.ClusterClient); ok { diff --git a/pkg/proxmox/client.go b/pkg/proxmox/client.go index 1111d5aee..9c12ad978 100644 --- a/pkg/proxmox/client.go +++ b/pkg/proxmox/client.go @@ -1788,6 +1788,48 @@ func (c *Client) GetClusterResources(ctx context.Context, resourceType string) ( return result.Data, nil } +// ClusterOptions holds selected Proxmox datacenter configuration options. +type ClusterOptions struct { + TagStyle string `json:"tag-style,omitempty"` +} + +// GetClusterOptions fetches datacenter-level options (e.g. tag colour map). +func (c *Client) GetClusterOptions(ctx context.Context) (*ClusterOptions, error) { + resp, err := c.get(ctx, "/cluster/options") + if err != nil { + return nil, err + } + defer resp.Body.Close() + + var result struct { + Data ClusterOptions `json:"data"` + } + if err := json.NewDecoder(resp.Body).Decode(&result); err != nil { + return nil, err + } + return &result.Data, nil +} + +// ParseTagColorMap parses a Proxmox tag-style string and returns a map of +// lowercase tag name → "#rrggbb" hex colour string. +// Example input: "color-map=production:ff0000;staging:ffaa00,ordering=config" +func ParseTagColorMap(tagStyle string) map[string]string { + colors := make(map[string]string) + for _, part := range strings.Split(tagStyle, ",") { + part = strings.TrimSpace(part) + if !strings.HasPrefix(part, "color-map=") { + continue + } + for _, pair := range strings.Split(strings.TrimPrefix(part, "color-map="), ";") { + kv := strings.SplitN(pair, ":", 2) + if len(kv) == 2 && len(kv[1]) == 6 { + colors[strings.ToLower(strings.TrimSpace(kv[0]))] = "#" + kv[1] + } + } + } + return colors +} + // ZFSPoolStatus represents the status of a ZFS pool (list endpoint) type ZFSPoolStatus struct { Name string `json:"name"`