feat: improve dashboard UI with tags and better filtering

- Add clickable tag badges with Proxmox-style coloring for VM/container tags
- Implement grouping toggle to switch between node-grouped and flat list views
- Replace alert dot indicators with left border highlighting to avoid visual conflicts
- Add tag filtering support in search (tags:tagname)
- Fix alert visualization on storage page to use left borders
- Improve search functionality with uptime metric support
- Remove info tooltip from search field, add clear button instead
- Update placeholder text to be more helpful

addresses #370, addresses #371
This commit is contained in:
Pulse Monitor
2025-08-28 17:26:57 +00:00
parent 63803a444f
commit 6d00b099bf
9 changed files with 384 additions and 78 deletions
@@ -402,6 +402,56 @@ export function Dashboard(props: DashboardProps) {
}
};
const handleTagClick = (tag: string) => {
const currentSearch = search().trim();
const tagFilter = `tags:${tag}`;
// Check if this tag filter already exists
if (currentSearch.includes(tagFilter)) {
// Remove the tag filter
let newSearch = currentSearch;
// Handle different cases of where the tag filter might be
if (currentSearch === tagFilter) {
// It's the only filter
newSearch = '';
} else if (currentSearch.startsWith(tagFilter + ',')) {
// It's at the beginning
newSearch = currentSearch.replace(tagFilter + ',', '').trim();
} else if (currentSearch.endsWith(', ' + tagFilter)) {
// It's at the end
newSearch = currentSearch.replace(', ' + tagFilter, '').trim();
} else if (currentSearch.includes(', ' + tagFilter + ',')) {
// It's in the middle
newSearch = currentSearch.replace(', ' + tagFilter + ',', ',').trim();
} else if (currentSearch.includes(tagFilter + ', ')) {
// It's at the beginning with space after comma
newSearch = currentSearch.replace(tagFilter + ', ', '').trim();
}
setSearch(newSearch);
if (!newSearch) {
setIsSearchLocked(false);
}
} else {
// Add the tag filter
if (!currentSearch || isSearchLocked()) {
setSearch(tagFilter);
setIsSearchLocked(false);
} else {
// Add tag filter to existing search with comma separator
setSearch(`${currentSearch}, ${tagFilter}`);
}
// Make sure filters are visible
if (!showFilters()) {
setShowFilters(true);
}
}
};
return (
<div>
{/* Unified Node Selector */}
@@ -724,6 +774,8 @@ export function Dashboard(props: DashboardProps) {
guest={guest}
showNode={groupingMode() === 'flat'}
alertStyles={getAlertStyles(guestId, activeAlerts)}
onTagClick={handleTagClick}
activeSearch={search()}
/>
);
})()}
@@ -1,5 +1,4 @@
import { Component, Show } from 'solid-js';
import { showTooltip, hideTooltip } from '@/components/shared/Tooltip';
interface DashboardFilterProps {
search: () => string;
@@ -26,7 +25,7 @@ export const DashboardFilter: Component<DashboardFilterProps> = (props) => {
<input
ref={props.searchInputRef}
type="text"
placeholder="Search: name, jellyfin, or cpu>80"
placeholder="Search by name, cpu>80, memory<20, tags:prod, node:pve1"
value={props.search()}
onInput={(e) => {
if (!props.isSearchLocked()) {
@@ -43,34 +42,18 @@ export const DashboardFilter: Component<DashboardFilterProps> = (props) => {
<svg class="absolute left-3 top-2 h-4 w-4 text-gray-400 dark:text-gray-500" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M21 21l-6-6m2-5a7 7 0 11-14 0 7 7 0 0114 0z" />
</svg>
<button type="button"
class="absolute right-3 top-2 text-gray-400 dark:text-gray-500 hover:text-gray-600 dark:hover:text-gray-300 transition-colors"
onMouseEnter={(e) => {
const rect = e.currentTarget.getBoundingClientRect();
const tooltipContent = `
<div class="space-y-2 p-1">
<div class="font-semibold mb-2">Search Examples:</div>
<div class="space-y-1">
<div><span class="font-mono bg-gray-700 px-1 rounded">jellyfin</span> - Find guests with "jellyfin" in name</div>
<div><span class="font-mono bg-gray-700 px-1 rounded">plex,media</span> - Find guests with "plex" OR "media"</div>
<div><span class="font-mono bg-gray-700 px-1 rounded">cpu>80</span> - Guests using >80% CPU</div>
<div><span class="font-mono bg-gray-700 px-1 rounded">memory<20</span> - Guests using <20% memory</div>
<div><span class="font-mono bg-gray-700 px-1 rounded">disk>90</span> - Guests using >90% disk</div>
<div><span class="font-mono bg-gray-700 px-1 rounded">node:pve1</span> - Guests on specific node</div>
<div><span class="font-mono bg-gray-700 px-1 rounded">vmid:104</span> - Find specific VM/container</div>
</div>
</div>
`;
showTooltip(tooltipContent, rect.left, rect.top);
}}
onMouseLeave={() => hideTooltip()}
onClick={(e) => e.preventDefault()}
aria-label="Search help"
>
<svg class="h-4 w-4" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M13 16h-1v-4h-1m1-4h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z" />
</svg>
</button>
<Show when={props.search()}>
<button type="button"
class="absolute right-3 top-2 text-gray-400 dark:text-gray-500 hover:text-gray-600 dark:hover:text-gray-300 transition-colors"
onClick={() => props.setSearch('')}
aria-label="Clear search"
title="Clear search"
>
<svg class="h-4 w-4" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M6 18L18 6M6 6l12 12" />
</svg>
</button>
</Show>
</div>
</div>
@@ -1,11 +1,9 @@
import { Show, createMemo, createSignal, createEffect, onMount } from 'solid-js';
import type { VM, Container } from '@/types/api';
import { AlertIndicator, AlertCountBadge } from '@/components/shared/AlertIndicators';
import { formatBytes, formatUptime } from '@/utils/format';
import { MetricBar } from './MetricBar';
import { IOMetric } from './IOMetric';
import { getResourceAlerts } from '@/utils/alerts';
import { useWebSocket } from '@/App';
import { TagBadges } from './TagBadges';
import { GuestMetadataAPI } from '@/api/guestMetadata';
type Guest = VM | Container;
@@ -28,10 +26,11 @@ interface GuestRowProps {
severity: 'critical' | 'warning' | null;
};
customUrl?: string;
onTagClick?: (tag: string) => void;
activeSearch?: string;
}
export function GuestRow(props: GuestRowProps) {
const { activeAlerts } = useWebSocket();
const [customUrl, setCustomUrl] = createSignal<string | undefined>(props.customUrl);
// Create guest ID for metadata
@@ -76,26 +75,37 @@ export function GuestRow(props: GuestRowProps) {
const isRunning = createMemo(() => props.guest.status === 'running');
// Get alerts for this guest
const guestAlerts = createMemo(() => {
const guestId = props.guest.id || `${props.guest.instance}-${props.guest.name}-${props.guest.vmid}`;
return getResourceAlerts(guestId, activeAlerts);
});
// Get row styling - include alert styles if present
const rowClass = createMemo(() => {
const base = 'transition-all duration-200';
const hover = 'hover:shadow-sm';
const alertClass = props.alertStyles?.rowClass || '';
const defaultHover = alertClass ? '' : 'hover:bg-gray-50 dark:hover:bg-gray-700';
return `${base} ${hover} ${defaultHover} ${alertClass}`;
// Extract only the background color from alert styles, not the border
const alertBg = props.alertStyles?.hasAlert
? (props.alertStyles.severity === 'critical'
? 'bg-red-50 dark:bg-red-950/30'
: 'bg-yellow-50 dark:bg-yellow-950/20')
: '';
const defaultHover = props.alertStyles?.hasAlert ? '' : 'hover:bg-gray-50 dark:hover:bg-gray-700';
return `${base} ${hover} ${defaultHover} ${alertBg}`;
});
// Get first cell styling with left border for alerts
const firstCellClass = createMemo(() => {
const base = 'p-1 px-2 whitespace-nowrap relative';
const alertBorder = props.alertStyles?.hasAlert
? (props.alertStyles.severity === 'critical'
? 'border-l-4 border-l-red-500 dark:border-l-red-400'
: 'border-l-4 border-l-yellow-500 dark:border-l-yellow-400')
: '';
return `${base} ${alertBorder}`;
});
return (
<tr class={rowClass()}>
{/* Name - Sticky column */}
<td class="p-1 px-2 whitespace-nowrap">
<td class={firstCellClass()}>
<div class="flex items-center gap-2">
{/* Status indicator */}
<span class={`h-2 w-2 rounded-full flex-shrink-0 ${
@@ -119,15 +129,13 @@ export function GuestRow(props: GuestRowProps) {
</a>
</Show>
{/* Alert indicators */}
<Show when={props.alertStyles?.hasAlert}>
<div class="flex items-center gap-1">
<AlertIndicator severity={props.alertStyles?.severity || null} alerts={guestAlerts()} />
<Show when={props.alertStyles?.alertCount && props.alertStyles.alertCount > 1}>
<AlertCountBadge count={props.alertStyles!.alertCount} severity={props.alertStyles!.severity || 'warning'} alerts={guestAlerts()} />
</Show>
</div>
</Show>
{/* Tag badges */}
<TagBadges
tags={props.guest.tags}
maxVisible={3}
onTagClick={props.onTagClick}
activeSearch={props.activeSearch}
/>
</div>
</td>
@@ -0,0 +1,134 @@
import { Component, For, Show, createSignal } from 'solid-js';
import { Portal } from 'solid-js/web';
import { getTagColorWithSpecial } from '@/utils/tagColors';
interface TagBadgesProps {
tags: string[];
maxVisible?: number;
isDarkMode?: boolean;
onTagClick?: (tag: string) => void;
activeSearch?: string;
}
export const TagBadges: Component<TagBadgesProps> = (props) => {
const maxVisible = () => props.maxVisible ?? 3;
const isDark = () => props.isDarkMode ?? document.documentElement.classList.contains('dark');
const visibleTags = () => props.tags?.slice(0, maxVisible()) || [];
const hiddenTags = () => props.tags?.slice(maxVisible()) || [];
const hasHiddenTags = () => hiddenTags().length > 0;
const [hoveredTag, setHoveredTag] = createSignal<string | null>(null);
const [tooltipPos, setTooltipPos] = createSignal<{ x: number; y: number } | null>(null);
return (
<Show when={props.tags && props.tags.length > 0}>
<div class="inline-flex items-center gap-1 ml-2">
<For each={visibleTags()}>
{(tag) => {
const colors = getTagColorWithSpecial(tag, isDark());
const isActive = () => props.activeSearch?.includes(`tags:${tag}`) || false;
return (
<div
class="relative group"
onMouseEnter={(e) => {
setHoveredTag(tag);
const rect = e.currentTarget.getBoundingClientRect();
setTooltipPos({ x: rect.left + rect.width / 2, y: rect.top });
}}
onMouseLeave={() => {
setHoveredTag(null);
setTooltipPos(null);
}}
onClick={(e) => {
e.stopPropagation();
props.onTagClick?.(tag);
}}
>
{/* Colored dot indicator */}
<div
class="w-1.5 h-1.5 rounded-full hover:scale-110 transition-all cursor-pointer"
style={{
'background-color': colors.bg,
'box-shadow': isActive()
? isDark()
? `0 0 0 1px ${colors.border}, 0 0 0 2.5px rgba(255, 255, 255, 0.9)` // White ring in dark mode
: `0 0 0 1px ${colors.border}, 0 0 0 2.5px rgba(0, 0, 0, 0.8)` // Black ring in light mode
: `0 0 0 1px ${colors.border}`,
}}
/>
</div>
);
}}
</For>
{/* Show +X more indicator if there are hidden tags */}
<Show when={hasHiddenTags()}>
<div
class="relative group"
onMouseEnter={(e) => {
setHoveredTag('more');
const rect = e.currentTarget.getBoundingClientRect();
setTooltipPos({ x: rect.left + rect.width / 2, y: rect.top });
}}
onMouseLeave={() => {
setHoveredTag(null);
setTooltipPos(null);
}}
>
<div class="text-[10px] text-gray-500 dark:text-gray-400 cursor-pointer hover:text-gray-700 dark:hover:text-gray-300">
+{hiddenTags().length}
</div>
</div>
</Show>
</div>
{/* Render tooltips in a portal to avoid z-index issues */}
<Portal>
<Show when={hoveredTag() && tooltipPos()}>
{hoveredTag() === 'more' ? (
// Tooltip for hidden tags
<div
class="fixed px-2 py-1 bg-gray-800 dark:bg-gray-700 text-white text-xs rounded shadow-lg pointer-events-none"
style={{
left: `${tooltipPos()!.x}px`,
top: `${tooltipPos()!.y - 28}px`,
transform: 'translateX(-50%)',
'z-index': '999999',
}}
>
<div class="space-y-0.5">
<For each={hiddenTags()}>
{(tag) => <div>{tag}</div>}
</For>
</div>
</div>
) : (
// Tooltip for individual tag
(() => {
const tag = hoveredTag()!;
const colors = getTagColorWithSpecial(tag, isDark());
return (
<div
class="fixed px-2 py-1 text-xs rounded shadow-lg pointer-events-none"
style={{
left: `${tooltipPos()!.x}px`,
top: `${tooltipPos()!.y - 24}px`,
transform: 'translateX(-50%)',
'background-color': colors.bg,
'color': colors.text,
'border': `1px solid ${colors.border}`,
'z-index': '999999',
}}
>
{tag}
</div>
);
})()
)}
</Show>
</Portal>
</Show>
);
};
@@ -1,7 +1,6 @@
import { Component, For, Show, createSignal, createMemo, createEffect } from 'solid-js';
import { useWebSocket } from '@/App';
import { getAlertStyles } from '@/utils/alerts';
import { AlertIndicator, AlertCountBadge } from '@/components/shared/AlertIndicators';
import { formatBytes } from '@/utils/format';
import { createTooltipSystem } from '@/components/shared/Tooltip';
import type { Storage as StorageType } from '@/types/api';
@@ -315,23 +314,26 @@ const Storage: Component = () => {
const isDisabled = storage.status !== 'available';
const alertStyles = getAlertStyles(storage.id || `${storage.instance}-${storage.name}`, activeAlerts);
const rowClass = `${isDisabled ? 'opacity-60' : ''} ${alertStyles.rowClass} hover:shadow-sm transition-all duration-200`;
const alertBg = alertStyles.hasAlert
? (alertStyles.severity === 'critical'
? 'bg-red-50 dark:bg-red-950/30'
: 'bg-yellow-50 dark:bg-yellow-950/20')
: '';
const rowClass = `${isDisabled ? 'opacity-60' : ''} ${alertBg} hover:shadow-sm transition-all duration-200`;
const firstCellClass = alertStyles.hasAlert
? (alertStyles.severity === 'critical'
? 'p-0.5 px-1.5 border-l-4 border-l-red-500 dark:border-l-red-400'
: 'p-0.5 px-1.5 border-l-4 border-l-yellow-500 dark:border-l-yellow-400')
: 'p-0.5 px-1.5';
return (
<tr class={`${rowClass} hover:bg-gray-50 dark:hover:bg-gray-700/50 transition-colors`}>
<td class="p-0.5 px-1.5">
<td class={firstCellClass}>
<div class="flex items-center gap-2">
<span class="text-sm font-medium text-gray-900 dark:text-gray-100">
{storage.name}
</span>
<Show when={alertStyles.hasAlert}>
<div class="flex items-center gap-1">
<AlertIndicator severity={alertStyles.severity} alerts={[]} />
<Show when={alertStyles.alertCount > 1}>
<AlertCountBadge count={alertStyles.alertCount} severity={alertStyles.severity!} alerts={[]} />
</Show>
</div>
</Show>
</div>
</td>
<Show when={viewMode() === 'node'}>
+4 -4
View File
@@ -21,7 +21,7 @@ export const getAlertStyles = (
// Return appropriate styling based on alert severity
if (highestSeverity === 'critical') {
return {
rowClass: 'bg-red-50 dark:bg-red-900/20 border-l-4 border-red-500',
rowClass: 'bg-red-50 dark:bg-red-950/30 border-l-4 border-red-500 dark:border-red-400',
indicatorClass: 'bg-red-500',
badgeClass: 'bg-red-100 text-red-800 dark:bg-red-900 dark:text-red-200',
hasAlert: true,
@@ -32,9 +32,9 @@ export const getAlertStyles = (
if (highestSeverity === 'warning') {
return {
rowClass: 'bg-orange-50 dark:bg-orange-900/20 border-l-4 border-orange-500',
indicatorClass: 'bg-orange-500',
badgeClass: 'bg-orange-100 text-orange-800 dark:bg-orange-900 dark:text-orange-200',
rowClass: 'bg-yellow-50 dark:bg-yellow-950/20 border-l-4 border-yellow-500 dark:border-yellow-400',
indicatorClass: 'bg-yellow-500',
badgeClass: 'bg-yellow-100 text-yellow-800 dark:bg-yellow-900 dark:text-yellow-200',
hasAlert: true,
alertCount,
severity: 'warning' as const
+39 -9
View File
@@ -4,13 +4,13 @@ export type ComparisonOperator = '>' | '<' | '>=' | '<=' | '=' | '==';
export type LogicalOperator = 'AND' | 'OR';
export interface MetricCondition {
field: 'cpu' | 'memory' | 'disk' | 'diskRead' | 'diskWrite' | 'networkIn' | 'networkOut';
field: 'cpu' | 'memory' | 'disk' | 'diskRead' | 'diskWrite' | 'networkIn' | 'networkOut' | 'uptime';
operator: ComparisonOperator;
value: number;
}
export interface TextCondition {
field: 'name' | 'node' | 'vmid';
field: 'name' | 'node' | 'vmid' | 'tags';
value: string;
}
@@ -62,7 +62,7 @@ export function parseFilter(term: string): ParsedFilter {
};
}
// Try to parse text condition (e.g., "name:prod", "storage:local", "type:VM")
// Try to parse text condition (e.g., "name:prod", "tags:production", "storage:local", "type:VM")
const textMatch = term.match(/^(\w+)\s*:\s*(.+)$/i);
if (textMatch) {
const [, field, value] = textMatch;
@@ -129,12 +129,12 @@ function parseCondition(conditionStr: string): Condition | null {
} as MetricCondition;
}
// Try to parse text condition (e.g., "name:prod", "storage:local", etc.)
// Try to parse text condition (e.g., "name:prod", "tags:production", "storage:local", etc.)
const textMatch = conditionStr.match(/^(\w+)\s*:\s*(.+)$/i);
if (textMatch) {
const [, field, value] = textMatch;
return {
field: field.toLowerCase() as 'name' | 'node' | 'vmid',
field: field.toLowerCase() as 'name' | 'node' | 'vmid' | 'tags',
value: value.trim()
} as TextCondition;
}
@@ -200,6 +200,10 @@ function evaluateMetricCondition(guest: VM | Container | any, condition: MetricC
case 'disk':
value = guest.disk ? guest.disk.usage : 0;
break;
case 'uptime':
// Uptime in seconds (only for running VMs/containers)
value = guest.status === 'running' ? (guest.uptime || 0) : 0;
break;
default:
// For backup-specific numeric fields like 'size'
if (guest[condition.field] !== undefined) {
@@ -236,6 +240,17 @@ function evaluateTextCondition(guest: VM | Container | any, condition: TextCondi
return guest.node?.toLowerCase().includes(searchValue) || false;
case 'vmid':
return guest.vmid?.toString().includes(searchValue) || false;
case 'tags':
// Check if guest has any tags that match the search value
if (!guest.tags || !Array.isArray(guest.tags) || guest.tags.length === 0) return false;
// Support comma-separated tag searches (OR logic)
const searchTags = searchValue.split(',').map(t => t.trim()).filter(t => t.length > 0);
return searchTags.some(searchTag =>
guest.tags.some((tag: string) => {
if (typeof tag !== 'string') return false;
return tag.toLowerCase().includes(searchTag.toLowerCase());
})
);
default:
// For backup-specific fields
if (guest[condition.field]) {
@@ -301,6 +316,14 @@ export function evaluateFilterStack(guest: VM | Container | any, stack: FilterSt
};
return evaluateMetricCondition(guest, condition);
} else if (filter.type === 'text' && filter.field && filter.value) {
// Handle tags field specifically since it might not be in the type union
if (filter.field === 'tags') {
const condition: TextCondition = {
field: 'tags',
value: filter.value as string
};
return evaluateTextCondition(guest, condition);
}
const condition: TextCondition = {
field: filter.field as TextCondition['field'],
value: filter.value as string
@@ -308,10 +331,17 @@ export function evaluateFilterStack(guest: VM | Container | any, stack: FilterSt
return evaluateTextCondition(guest, condition);
} else if (filter.type === 'raw' && filter.rawText) {
const term = filter.rawText.toLowerCase();
return guest.name.toLowerCase().includes(term) ||
guest.vmid.toString().includes(term) ||
guest.node.toLowerCase().includes(term) ||
guest.status.toLowerCase().includes(term);
// Check name, vmid, node, status, and tags for raw text matches
const basicMatch = guest.name.toLowerCase().includes(term) ||
guest.vmid.toString().includes(term) ||
guest.node.toLowerCase().includes(term) ||
guest.status.toLowerCase().includes(term);
// Also check if any tags contain the search term
const tagMatch = guest.tags && Array.isArray(guest.tags) &&
guest.tags.some((tag: string) => tag.toLowerCase().includes(term));
return basicMatch || tagMatch;
}
return true;
});
+95
View File
@@ -0,0 +1,95 @@
// 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
*/
export 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
*/
export 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
};
}
/**
* Proxmox's default tag colors for special tags
* These override the hash-based colors for specific tags
*/
const specialTagColors: Record<string, { light: any; dark: any }> = {
'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)' }
}
};
/**
* Get color for a tag, checking special colors first
*/
export function getTagColorWithSpecial(tag: string, isDarkMode: boolean): { bg: string; text: string; border: string } {
const lowerTag = tag.toLowerCase();
// 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);
}
+4 -2
View File
@@ -652,8 +652,10 @@ func (m *Manager) CheckStorage(storage models.Storage) {
return
}
// Only check usage if storage is active
if storage.Active {
// Check usage if storage has valid data (even if not currently active on this node)
// In clusters, storage may show as inactive on nodes where it's not currently mounted
// but we still want to alert on high usage
if storage.Status != "offline" && storage.Status != "unavailable" && storage.Usage > 0 {
m.checkMetric(storage.ID, storage.Name, storage.Node, storage.Instance, "Storage", "usage", storage.Usage, &threshold)
}
}