Match Proxmox tag color generation (#1348)

This commit is contained in:
rcourtman 2026-03-26 00:27:07 +00:00
parent e9bbc35bae
commit ca66581b6e
2 changed files with 127 additions and 166 deletions

View file

@ -1,71 +1,59 @@
import { describe, it, expect } from 'vitest';
import { describe, expect, it } from 'vitest';
import { getTagColorWithSpecial } from '../tagColors';
describe('tagColors', () => {
describe('getTagColorWithSpecial', () => {
it('returns consistent colors for special tags in light mode', () => {
const production = getTagColorWithSpecial('production', false);
expect(production).toEqual({
bg: 'rgb(254, 226, 226)',
text: 'rgb(153, 27, 27)',
border: 'rgb(239, 68, 68)',
});
describe('getTagColorWithSpecial', () => {
it('matches Proxmox fallback colors for tags', () => {
expect(getTagColorWithSpecial('production', false)).toEqual({
bg: 'rgb(191.3, 176.60000000000002, 238.89999999999998)',
text: '#000000',
border: 'rgb(191.3, 176.60000000000002, 238.89999999999998)',
});
const staging = getTagColorWithSpecial('STAGING', false); // case insensitive
expect(staging).toEqual({
bg: 'rgb(254, 243, 199)',
text: 'rgb(146, 64, 14)',
border: 'rgb(245, 158, 11)',
});
});
it('returns consistent colors for special tags in dark mode', () => {
const backup = getTagColorWithSpecial('backup', true);
expect(backup).toEqual({
bg: 'rgb(30, 58, 138)',
text: 'rgb(191, 219, 254)',
border: 'rgb(59, 130, 246)',
});
});
it('generates hash-based colors for non-special tags', () => {
const color1 = getTagColorWithSpecial('mytag', false);
const color2 = getTagColorWithSpecial('mytag', false);
// Consistency
expect(color1).toEqual(color2);
// Values (H, S, L format)
expect(color1.bg).toMatch(/hsl\(\d+, 65%, 60%\)/);
expect(color1.text).toMatch(/hsl\(\d+, 65%, 25%\)/);
expect(color1.border).toMatch(/hsl\(\d+, 65%, 50%\)/);
});
it('generates different colors for different tags', () => {
const colorA = getTagColorWithSpecial('tagA', false);
const colorB = getTagColorWithSpecial('tagB', false);
expect(colorA.bg).not.toBe(colorB.bg);
});
it('prefers proxmox-supplied colors over fallback palettes', () => {
const color = getTagColorWithSpecial('Production', false, {
production: '#112233',
});
expect(color).toEqual({
bg: '#112233',
text: '#ffffff',
border: '#112233',
});
});
it('generates dark mode hash-based colors', () => {
const color = getTagColorWithSpecial('custom', true);
expect(color.bg).toMatch(/hsl\(\d+, 55%, 35%\)/);
expect(color.text).toMatch(/hsl\(\d+, 55%, 85%\)/);
expect(color.border).toMatch(/hsl\(\d+, 55%, 45%\)/);
});
expect(getTagColorWithSpecial('STAGING', false)).toEqual({
bg: 'rgb(103.10000000000001, 103.10000000000001, 175.9)',
text: '#ffffff',
border: 'rgb(103.10000000000001, 103.10000000000001, 175.9)',
});
});
it('is deterministic for the same tag', () => {
const color1 = getTagColorWithSpecial('mytag', false);
const color2 = getTagColorWithSpecial('mytag', true);
expect(color1).toEqual(color2);
expect(color1).toEqual({
bg: 'rgb(228.39999999999998, 164.7, 128.3)',
text: '#000000',
border: 'rgb(228.39999999999998, 164.7, 128.3)',
});
});
it('generates different colors for different tags', () => {
const colorA = getTagColorWithSpecial('tagA', false);
const colorB = getTagColorWithSpecial('tagB', false);
expect(colorA.bg).not.toBe(colorB.bg);
});
it('prefers proxmox-supplied colors over generated fallback colors', () => {
expect(getTagColorWithSpecial('Production', false, {
production: '#112233',
})).toEqual({
bg: 'rgb(17, 34, 51)',
text: '#ffffff',
border: 'rgb(17, 34, 51)',
});
});
it('falls back to generated colors when proxmox color is invalid', () => {
expect(getTagColorWithSpecial('backup', false, {
backup: 'not-a-color',
})).toEqual({
bg: 'rgb(108.00000000000001, 149.3, 143.7)',
text: '#ffffff',
border: 'rgb(108.00000000000001, 149.3, 143.7)',
});
});
});
});

View file

@ -1,120 +1,93 @@
// Generate consistent colors for tags based on their text
// This replicates Proxmox's tag color generation logic
/**
* Simple hash function to generate a number from a string
* This ensures the same tag always gets the same color
*/
function hashString(str: string): number {
let hash = 0;
for (let i = 0; i < str.length; i++) {
const char = str.charCodeAt(i);
hash = (hash << 5) - hash + char;
hash = hash & hash; // Convert to 32bit integer
}
return Math.abs(hash);
}
/**
* Generate a color for a tag based on its text
* Uses HSL to ensure good visibility and consistent saturation/lightness
* (Internal helper - use getTagColorWithSpecial instead)
*/
function getTagColor(tag: string): { bg: string; text: string; border: string } {
// Get a hash of the tag
const hash = hashString(tag.toLowerCase());
// Generate hue from hash (0-360 degrees)
const hue = hash % 360;
// Use moderate saturation for subtle but visible colors
// These values are tuned to be noticeable without being distracting
const saturation = 65; // Moderate saturation
const lightnessBg = 60; // Slightly muted background
const lightnessText = 25; // Dark text for contrast
const lightnessBorder = 50; // Medium border
// For dark mode, we'll adjust these in the component
return {
bg: `hsl(${hue}, ${saturation}%, ${lightnessBg}%)`,
text: `hsl(${hue}, ${saturation}%, ${lightnessText}%)`,
border: `hsl(${hue}, ${saturation}%, ${lightnessBorder}%)`,
};
}
/**
* Get tag colors adjusted for dark mode
* (Internal helper - use getTagColorWithSpecial instead)
*/
function getTagColorDark(tag: string): { bg: string; text: string; border: string } {
const hash = hashString(tag.toLowerCase());
const hue = hash % 360;
const saturation = 55; // Moderate saturation in dark mode
return {
bg: `hsl(${hue}, ${saturation}%, 35%)`, // Subtler background
text: `hsl(${hue}, ${saturation}%, 85%)`, // Light text
border: `hsl(${hue}, ${saturation}%, 45%)`, // Subtle border
};
}
interface TagColorStyle {
bg: string;
text: string;
border: string;
}
interface TagColorTheme {
light: TagColorStyle;
dark: TagColorStyle;
function stringToRGB(tag: string): [number, number, number] {
let hash = 0;
if (!tag) {
return [255, 255, 255];
}
const value = `${tag.toLowerCase()}prox`;
for (let i = 0; i < value.length; i++) {
hash = value.charCodeAt(i) + ((hash << 5) - hash);
hash &= hash;
}
const alpha = 0.7;
const bg = 255;
return [
(hash & 255) * alpha + bg * (1 - alpha),
((hash >> 8) & 255) * alpha + bg * (1 - alpha),
((hash >> 16) & 255) * alpha + bg * (1 - alpha),
];
}
/**
* Proxmox's default tag colors for special tags
* These override the hash-based colors for specific tags
*/
const specialTagColors: Record<string, TagColorTheme> = {
production: {
light: { bg: 'rgb(254, 226, 226)', text: 'rgb(153, 27, 27)', border: 'rgb(239, 68, 68)' },
dark: { bg: 'rgb(127, 29, 29)', text: 'rgb(254, 202, 202)', border: 'rgb(185, 28, 28)' },
},
staging: {
light: { bg: 'rgb(254, 243, 199)', text: 'rgb(146, 64, 14)', border: 'rgb(245, 158, 11)' },
dark: { bg: 'rgb(120, 53, 15)', text: 'rgb(253, 230, 138)', border: 'rgb(217, 119, 6)' },
},
development: {
light: { bg: 'rgb(220, 252, 231)', text: 'rgb(22, 101, 52)', border: 'rgb(34, 197, 94)' },
dark: { bg: 'rgb(20, 83, 45)', text: 'rgb(187, 247, 208)', border: 'rgb(34, 197, 94)' },
},
backup: {
light: { bg: 'rgb(219, 234, 254)', text: 'rgb(30, 58, 138)', border: 'rgb(59, 130, 246)' },
dark: { bg: 'rgb(30, 58, 138)', text: 'rgb(191, 219, 254)', border: 'rgb(59, 130, 246)' },
},
};
function rgbToCss(rgb: [number, number, number]): string {
return `rgb(${rgb[0]}, ${rgb[1]}, ${rgb[2]})`;
}
function getTextContrastClass(rgb: [number, number, number]): 'light' | 'dark' {
const blkThrs = 0.022;
const blkClmp = 1.414;
const r = (rgb[0] / 255) ** 2.4;
const g = (rgb[1] / 255) ** 2.4;
const b = (rgb[2] / 255) ** 2.4;
let bg = r * 0.2126729 + g * 0.7151522 + b * 0.072175;
bg = bg > blkThrs ? bg : bg + (blkThrs - bg) ** blkClmp;
const contrastLight = bg ** 0.65 - 1;
const contrastDark = bg ** 0.56 - 0.046134502;
return Math.abs(contrastLight) >= Math.abs(contrastDark) ? 'light' : 'dark';
}
function parseHexColor(hex: string): [number, number, number] | null {
const normalized = hex.trim().replace(/^#/, '');
if (!/^[0-9a-fA-F]{6}$/.test(normalized)) {
return null;
}
return [
parseInt(normalized.slice(0, 2), 16),
parseInt(normalized.slice(2, 4), 16),
parseInt(normalized.slice(4, 6), 16),
];
}
function buildStyleFromRGB(rgb: [number, number, number]): TagColorStyle {
const bg = rgbToCss(rgb);
const text = getTextContrastClass(rgb) === 'light' ? '#ffffff' : '#000000';
return {
bg,
text,
border: bg,
};
}
/**
* 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.
* Priority: Proxmox-supplied hex color -> Proxmox fallback hash algorithm.
*/
export function getTagColorWithSpecial(
tag: string,
isDarkMode: boolean,
_isDarkMode: boolean,
colorMap?: Record<string, string>,
): { bg: string; text: string; border: string } {
): TagColorStyle {
const lowerTag = tag.toLowerCase();
// Use Proxmox-supplied colour when available
if (colorMap?.[lowerTag]) {
const hex = colorMap[lowerTag];
return { bg: hex, text: '#ffffff', border: hex };
const proxmoxHex = colorMap?.[lowerTag];
if (proxmoxHex) {
const rgb = parseHexColor(proxmoxHex);
if (rgb) {
return buildStyleFromRGB(rgb);
}
}
// Check if it's a special tag
if (specialTagColors[lowerTag]) {
return isDarkMode ? specialTagColors[lowerTag].dark : specialTagColors[lowerTag].light;
}
// Otherwise use hash-based color
return isDarkMode ? getTagColorDark(tag) : getTagColor(tag);
return buildStyleFromRGB(stringToRGB(tag));
}