diff --git a/frontend-modern/src/components/Dashboard/Dashboard.tsx b/frontend-modern/src/components/Dashboard/Dashboard.tsx
index 4d8d54a2a..b3d45665c 100644
--- a/frontend-modern/src/components/Dashboard/Dashboard.tsx
+++ b/frontend-modern/src/components/Dashboard/Dashboard.tsx
@@ -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 (
{/* 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()}
/>
);
})()}
diff --git a/frontend-modern/src/components/Dashboard/DashboardFilter.tsx b/frontend-modern/src/components/Dashboard/DashboardFilter.tsx
index efb8a3cd3..155e29564 100644
--- a/frontend-modern/src/components/Dashboard/DashboardFilter.tsx
+++ b/frontend-modern/src/components/Dashboard/DashboardFilter.tsx
@@ -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
= (props) => {
{
if (!props.isSearchLocked()) {
@@ -43,34 +42,18 @@ export const DashboardFilter: Component = (props) => {
-
+
+
+
diff --git a/frontend-modern/src/components/Dashboard/GuestRow.tsx b/frontend-modern/src/components/Dashboard/GuestRow.tsx
index 4c3bcd39a..815797e4f 100644
--- a/frontend-modern/src/components/Dashboard/GuestRow.tsx
+++ b/frontend-modern/src/components/Dashboard/GuestRow.tsx
@@ -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(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 (
{/* Name - Sticky column */}
- |
+ |
{/* Status indicator */}
- {/* Alert indicators */}
-
-
-
+ {/* Tag badges */}
+
|
diff --git a/frontend-modern/src/components/Dashboard/TagBadges.tsx b/frontend-modern/src/components/Dashboard/TagBadges.tsx
new file mode 100644
index 000000000..0412011cf
--- /dev/null
+++ b/frontend-modern/src/components/Dashboard/TagBadges.tsx
@@ -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 = (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(null);
+ const [tooltipPos, setTooltipPos] = createSignal<{ x: number; y: number } | null>(null);
+
+ return (
+ 0}>
+
+
+ {(tag) => {
+ const colors = getTagColorWithSpecial(tag, isDark());
+ const isActive = () => props.activeSearch?.includes(`tags:${tag}`) || false;
+
+ return (
+ {
+ 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 */}
+
+
+ );
+ }}
+
+
+ {/* Show +X more indicator if there are hidden tags */}
+
+ {
+ setHoveredTag('more');
+ const rect = e.currentTarget.getBoundingClientRect();
+ setTooltipPos({ x: rect.left + rect.width / 2, y: rect.top });
+ }}
+ onMouseLeave={() => {
+ setHoveredTag(null);
+ setTooltipPos(null);
+ }}
+ >
+
+ +{hiddenTags().length}
+
+
+
+
+
+ {/* Render tooltips in a portal to avoid z-index issues */}
+
+
+ {hoveredTag() === 'more' ? (
+ // Tooltip for hidden tags
+
+ ) : (
+ // Tooltip for individual tag
+ (() => {
+ const tag = hoveredTag()!;
+ const colors = getTagColorWithSpecial(tag, isDark());
+ return (
+
+ {tag}
+
+ );
+ })()
+ )}
+
+
+
+ );
+};
\ No newline at end of file
diff --git a/frontend-modern/src/components/Storage/Storage.tsx b/frontend-modern/src/components/Storage/Storage.tsx
index 247b86e99..d04257be8 100644
--- a/frontend-modern/src/components/Storage/Storage.tsx
+++ b/frontend-modern/src/components/Storage/Storage.tsx
@@ -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 (
- |
+ |
|
diff --git a/frontend-modern/src/utils/alerts.ts b/frontend-modern/src/utils/alerts.ts
index 146e4aefe..19412a341 100644
--- a/frontend-modern/src/utils/alerts.ts
+++ b/frontend-modern/src/utils/alerts.ts
@@ -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
diff --git a/frontend-modern/src/utils/searchQuery.ts b/frontend-modern/src/utils/searchQuery.ts
index 86ef7fb6b..a6394379d 100644
--- a/frontend-modern/src/utils/searchQuery.ts
+++ b/frontend-modern/src/utils/searchQuery.ts
@@ -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;
});
diff --git a/frontend-modern/src/utils/tagColors.ts b/frontend-modern/src/utils/tagColors.ts
new file mode 100644
index 000000000..cea6d3b85
--- /dev/null
+++ b/frontend-modern/src/utils/tagColors.ts
@@ -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 = {
+ '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);
+}
\ No newline at end of file
diff --git a/internal/alerts/alerts.go b/internal/alerts/alerts.go
index 4510cde82..6b6be01e4 100644
--- a/internal/alerts/alerts.go
+++ b/internal/alerts/alerts.go
@@ -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)
}
}