From 6d00b099bf0776fb3bf19ea11d39bdc5d8375692 Mon Sep 17 00:00:00 2001 From: Pulse Monitor Date: Thu, 28 Aug 2025 17:26:57 +0000 Subject: [PATCH] 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 --- .../src/components/Dashboard/Dashboard.tsx | 52 +++++++ .../components/Dashboard/DashboardFilter.tsx | 43 ++---- .../src/components/Dashboard/GuestRow.tsx | 52 ++++--- .../src/components/Dashboard/TagBadges.tsx | 134 ++++++++++++++++++ .../src/components/Storage/Storage.tsx | 24 ++-- frontend-modern/src/utils/alerts.ts | 8 +- frontend-modern/src/utils/searchQuery.ts | 48 +++++-- frontend-modern/src/utils/tagColors.ts | 95 +++++++++++++ internal/alerts/alerts.go | 6 +- 9 files changed, 384 insertions(+), 78 deletions(-) create mode 100644 frontend-modern/src/components/Dashboard/TagBadges.tsx create mode 100644 frontend-modern/src/utils/tagColors.ts 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 */} - -
- - 1}> - - -
-
+ {/* 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 +
+
+ + {(tag) =>
{tag}
} +
+
+
+ ) : ( + // 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 ( - +
{storage.name} - -
- - 1}> - - -
-
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) } }