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.
This commit is contained in:
rcourtman 2026-03-15 19:49:46 +00:00
parent da928cd9d3
commit caff845c1a
9 changed files with 98 additions and 3 deletions

View file

@ -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<TagBadgesProps> = (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 (

View file

@ -83,6 +83,7 @@ export function createWebSocketStore(url: string) {
activeAlerts: [],
recentlyResolved: [],
lastUpdate: '',
pveTagColors: {},
// Unified resources for cross-platform monitoring
resources: [],
});

View file

@ -30,6 +30,7 @@ export interface State {
recentlyResolved: ResolvedAlert[];
lastUpdate: string;
temperatureMonitoringEnabled?: boolean;
pveTagColors?: Record<string, string>;
// Unified resources (new data model - eventually replaces legacy arrays above)
resources?: Resource[];
}

View file

@ -93,14 +93,23 @@ const specialTagColors: Record<string, TagColorTheme> = {
};
/**
* 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<string, string>,
): { 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;

View file

@ -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()

View file

@ -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"`
}

View file

@ -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,
}
}

View file

@ -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 {

View file

@ -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"`