mirror of
https://github.com/rcourtman/Pulse.git
synced 2026-09-22 03:04:03 +00:00
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:
@@ -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'}>
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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;
|
||||
});
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user