diff --git a/frontend-modern/src/components/Dashboard/EnhancedCPUBar.tsx b/frontend-modern/src/components/Dashboard/EnhancedCPUBar.tsx index f4b017ea5..3d1d26383 100644 --- a/frontend-modern/src/components/Dashboard/EnhancedCPUBar.tsx +++ b/frontend-modern/src/components/Dashboard/EnhancedCPUBar.tsx @@ -5,6 +5,11 @@ import { useMetricsViewMode } from '@/stores/metricsViewMode'; import { getMetricHistoryForRange, getMetricsVersion } from '@/stores/metricsHistory'; import { Sparkline } from '@/components/shared/Sparkline'; import type { AnomalyReport } from '@/types/aiIntelligence'; +import { + getDefaultMetricDisplayThresholds, + getMetricSeverity, + type MetricDisplayThresholds, +} from '@/utils/alertThresholds'; interface EnhancedCPUBarProps { usage: number; // CPU Usage % (0-100) @@ -13,6 +18,7 @@ interface EnhancedCPUBarProps { model?: string; // CPU Model name (for tooltip) resourceId?: string; // For sparkline history anomaly?: AnomalyReport | null; // Baseline anomaly if detected + thresholds?: MetricDisplayThresholds | null; } // Anomaly severity colors @@ -29,9 +35,19 @@ export function EnhancedCPUBar(props: EnhancedCPUBarProps) { let containerRef: HTMLDivElement | undefined; // Bar color based on usage + const severity = createMemo(() => { + const thresholds = props.thresholds === undefined + ? getDefaultMetricDisplayThresholds('cpu') + : props.thresholds; + return getMetricSeverity( + props.usage, + thresholds, + ); + }); + const barColor = createMemo(() => { - if (props.usage >= 90) return 'bg-red-500/60 dark:bg-red-500/50'; - if (props.usage >= 80) return 'bg-yellow-500/60 dark:bg-yellow-500/50'; + if (severity() === 'red') return 'bg-red-500/60 dark:bg-red-500/50'; + if (severity() === 'yellow') return 'bg-yellow-500/60 dark:bg-yellow-500/50'; return 'bg-green-500/60 dark:bg-green-500/50'; }); @@ -124,7 +140,7 @@ export function EnhancedCPUBar(props: EnhancedCPUBarProps) {
Usage - 90 ? 'text-red-400' : 'text-gray-200'}`}> + {formatPercent(props.usage)}
diff --git a/frontend-modern/src/components/Dashboard/GuestRow.tsx b/frontend-modern/src/components/Dashboard/GuestRow.tsx index c108f39d6..6859c1b26 100644 --- a/frontend-modern/src/components/Dashboard/GuestRow.tsx +++ b/frontend-modern/src/components/Dashboard/GuestRow.tsx @@ -490,6 +490,7 @@ interface GuestRowProps { export function GuestRow(props: GuestRowProps) { const guestId = createMemo(() => buildGuestId(props.guest)); + const alertsActivation = useAlertsActivation(); // Use breakpoint hook directly for responsive behavior const { isMobile } = useBreakpoint(); @@ -515,6 +516,9 @@ export function GuestRow(props: GuestRowProps) { const kind = props.guest.type === 'qemu' ? 'vm' : 'container'; return buildMetricKey(kind, guestId()); }); + const cpuThresholds = createMemo(() => alertsActivation.getMetricThresholds('guest', 'cpu', guestId())); + const memoryThresholds = createMemo(() => alertsActivation.getMetricThresholds('guest', 'memory', guestId())); + const diskThresholds = createMemo(() => alertsActivation.getMetricThresholds('guest', 'disk', guestId())); // Get anomalies for this guest's metrics (deterministic, no LLM) const cpuAnomaly = useAnomalyForMetric(() => props.guest.id, () => 'cpu'); @@ -826,6 +830,7 @@ export function GuestRow(props: GuestRowProps) { @@ -847,6 +852,7 @@ export function GuestRow(props: GuestRowProps) { balloon={props.guest.memory?.balloon || 0} swapUsed={props.guest.memory?.swapUsed || 0} swapTotal={props.guest.memory?.swapTotal || 0} + thresholds={memoryThresholds()} resourceId={metricsKey()} anomaly={memoryAnomaly()} /> @@ -859,6 +865,7 @@ export function GuestRow(props: GuestRowProps) { sublabel={memoryUsageLabel()} isRunning={isRunning()} showMobile={false} + thresholds={memoryThresholds()} /> @@ -884,6 +891,7 @@ export function GuestRow(props: GuestRowProps) { } @@ -894,6 +902,7 @@ export function GuestRow(props: GuestRowProps) { resourceId={metricsKey()} isRunning={isRunning()} showMobile={false} + thresholds={diskThresholds()} /> diff --git a/frontend-modern/src/components/Dashboard/MetricBar.test.tsx b/frontend-modern/src/components/Dashboard/MetricBar.test.tsx index 9f0339285..add6e7d2c 100644 --- a/frontend-modern/src/components/Dashboard/MetricBar.test.tsx +++ b/frontend-modern/src/components/Dashboard/MetricBar.test.tsx @@ -67,12 +67,12 @@ describe('MetricBar', () => { expect(bar).toBeInTheDocument(); result.unmount(); - result = render(() => ); + result = render(() => ); bar = result.container.querySelector('.bg-yellow-500\\/60'); expect(bar).toBeInTheDocument(); result.unmount(); - result = render(() => ); + result = render(() => ); bar = result.container.querySelector('.bg-red-500\\/60'); expect(bar).toBeInTheDocument(); result.unmount(); @@ -84,7 +84,7 @@ describe('MetricBar', () => { expect(bar).toBeInTheDocument(); result.unmount(); - result = render(() => ); + result = render(() => ); bar = result.container.querySelector('.bg-yellow-500\\/60'); expect(bar).toBeInTheDocument(); result.unmount(); @@ -101,7 +101,7 @@ describe('MetricBar', () => { expect(bar).toBeInTheDocument(); result.unmount(); - result = render(() => ); + result = render(() => ); bar = result.container.querySelector('.bg-yellow-500\\/60'); expect(bar).toBeInTheDocument(); result.unmount(); @@ -129,6 +129,31 @@ describe('MetricBar', () => { result.unmount(); }); + it('uses explicit thresholds when provided', () => { + let result = render(() => ( + + )); + let bar = result.container.querySelector('.bg-yellow-500\\/60'); + expect(bar).toBeInTheDocument(); + result.unmount(); + + result = render(() => ( + + )); + bar = result.container.querySelector('.bg-red-500\\/60'); + expect(bar).toBeInTheDocument(); + }); + it('toggles sparkline view mode', () => { mockUseMetricsViewMode.mockReturnValue({ viewMode: () => 'sparklines', diff --git a/frontend-modern/src/components/Dashboard/MetricBar.tsx b/frontend-modern/src/components/Dashboard/MetricBar.tsx index d6d0a0da7..686f1bd00 100644 --- a/frontend-modern/src/components/Dashboard/MetricBar.tsx +++ b/frontend-modern/src/components/Dashboard/MetricBar.tsx @@ -2,12 +2,18 @@ import { Show, createMemo, createSignal, onMount, onCleanup } from 'solid-js'; import { Sparkline } from '@/components/shared/Sparkline'; import { useMetricsViewMode } from '@/stores/metricsViewMode'; import { getMetricHistoryForRange, getMetricsVersion } from '@/stores/metricsHistory'; +import { + getDefaultMetricDisplayThresholds, + getMetricSeverity, + type MetricDisplayThresholds, +} from '@/utils/alertThresholds'; interface MetricBarProps { value: number; label: string; sublabel?: string; type?: 'cpu' | 'memory' | 'disk' | 'generic'; + thresholds?: MetricDisplayThresholds | null; resourceId?: string; // Required for sparkline mode to fetch history class?: string; } @@ -52,27 +58,12 @@ export function MetricBar(props: MetricBarProps) { }); // Get color based on percentage and metric type (matching original) - const getColor = createMemo(() => { - const percentage = props.value; + const severity = createMemo(() => { const metric = props.type || 'generic'; - - if (metric === 'cpu') { - if (percentage >= 90) return 'red'; - if (percentage >= 80) return 'yellow'; - return 'green'; - } else if (metric === 'memory') { - if (percentage >= 85) return 'red'; - if (percentage >= 75) return 'yellow'; - return 'green'; - } else if (metric === 'disk') { - if (percentage >= 90) return 'red'; - if (percentage >= 80) return 'yellow'; - return 'green'; - } else { - if (percentage >= 90) return 'red'; - if (percentage >= 75) return 'yellow'; - return 'green'; - } + const thresholds = props.thresholds === undefined + ? getDefaultMetricDisplayThresholds(metric) + : props.thresholds; + return getMetricSeverity(props.value, thresholds); }); // Map color to CSS classes @@ -82,7 +73,7 @@ export function MetricBar(props: MetricBarProps) { yellow: 'bg-yellow-500/60 dark:bg-yellow-500/50', green: 'bg-green-500/60 dark:bg-green-500/50', }; - return colorMap[getColor()] || 'bg-gray-500/60 dark:bg-gray-500/50'; + return colorMap[severity()] || 'bg-gray-500/60 dark:bg-gray-500/50'; }); // Get metric history for sparkline diff --git a/frontend-modern/src/components/Dashboard/StackedDiskBar.tsx b/frontend-modern/src/components/Dashboard/StackedDiskBar.tsx index 7c7d74144..76e248c26 100644 --- a/frontend-modern/src/components/Dashboard/StackedDiskBar.tsx +++ b/frontend-modern/src/components/Dashboard/StackedDiskBar.tsx @@ -3,6 +3,11 @@ import { Portal } from 'solid-js/web'; import type { Disk } from '@/types/api'; import { formatBytes, formatPercent } from '@/utils/format'; import type { AnomalyReport } from '@/types/aiIntelligence'; +import { + getDefaultMetricDisplayThresholds, + getMetricSeverity, + type MetricDisplayThresholds, +} from '@/utils/alertThresholds'; interface StackedDiskBarProps { /** Array of disk objects - if empty/undefined, falls back to aggregate */ @@ -13,6 +18,8 @@ interface StackedDiskBarProps { mode?: 'stacked' | 'aggregate' | 'mini'; /** Baseline anomaly if detected */ anomaly?: AnomalyReport | null; + /** Warning/critical thresholds for usage coloring */ + thresholds?: MetricDisplayThresholds | null; } // Anomaly severity colors @@ -34,9 +41,19 @@ const SEGMENT_COLORS = [ ]; // Get color based on usage percentage -function getUsageColor(percentage: number): string { - if (percentage >= 90) return 'rgba(239, 68, 68, 0.6)'; // red - if (percentage >= 80) return 'rgba(234, 179, 8, 0.6)'; // yellow +function getUsageColor( + percentage: number, + thresholds?: MetricDisplayThresholds | null, +): string { + const resolvedThresholds = thresholds === undefined + ? getDefaultMetricDisplayThresholds('disk') + : thresholds; + const severity = getMetricSeverity( + percentage, + resolvedThresholds, + ); + if (severity === 'red') return 'rgba(239, 68, 68, 0.6)'; // red + if (severity === 'yellow') return 'rgba(234, 179, 8, 0.6)'; // yellow return 'rgba(34, 197, 94, 0.6)'; // green } @@ -118,8 +135,12 @@ export function StackedDiskBar(props: StackedDiskBarProps) { const usedPercent = (disk.used / total) * 100; const diskPercent = disk.total > 0 ? (disk.used / disk.total) * 100 : 0; // Use warning/critical colors for high usage, otherwise use the color palette - const color = diskPercent >= 90 ? getUsageColor(90) : - diskPercent >= 80 ? getUsageColor(80) : + const color = getMetricSeverity( + diskPercent, + props.thresholds === undefined ? getDefaultMetricDisplayThresholds('disk') : props.thresholds, + ) !== 'green' + ? getUsageColor(diskPercent, props.thresholds) + : SEGMENT_COLORS[idx % SEGMENT_COLORS.length]; return { disk, @@ -144,7 +165,7 @@ export function StackedDiskBar(props: StackedDiskBarProps) { return { label, percent, - color: getUsageColor(percent), + color: getUsageColor(percent, props.thresholds), }; }); }); @@ -182,7 +203,7 @@ export function StackedDiskBar(props: StackedDiskBarProps) { if (aggregateMode() && hasMultipleDisks() && info) { return getUsageColor(info.percent); } - return getUsageColor(overallPercent()); + return getUsageColor(overallPercent(), props.thresholds); }); // Generate tooltip content @@ -198,12 +219,13 @@ export function StackedDiskBar(props: StackedDiskBarProps) { total: formatBytes(disk.total), percent: formatPercent(percent), color: useUsageColors - ? getUsageColor(percent) - : percent >= 90 - ? getUsageColor(90) - : percent >= 80 - ? getUsageColor(80) - : SEGMENT_COLORS[idx % SEGMENT_COLORS.length], + ? getUsageColor(percent, props.thresholds) + : getMetricSeverity( + percent, + props.thresholds === undefined ? getDefaultMetricDisplayThresholds('disk') : props.thresholds, + ) !== 'green' + ? getUsageColor(percent, props.thresholds) + : SEGMENT_COLORS[idx % SEGMENT_COLORS.length], }; }); } @@ -215,7 +237,7 @@ export function StackedDiskBar(props: StackedDiskBarProps) { used: formatBytes(props.aggregateDisk.used), total: formatBytes(props.aggregateDisk.total), percent: formatPercent(percent), - color: getUsageColor(percent), + color: getUsageColor(percent, props.thresholds), }]; } return []; diff --git a/frontend-modern/src/components/Dashboard/StackedMemoryBar.tsx b/frontend-modern/src/components/Dashboard/StackedMemoryBar.tsx index 9fcfefe6f..1a5e0270e 100644 --- a/frontend-modern/src/components/Dashboard/StackedMemoryBar.tsx +++ b/frontend-modern/src/components/Dashboard/StackedMemoryBar.tsx @@ -5,6 +5,11 @@ import { Sparkline } from '@/components/shared/Sparkline'; import { useMetricsViewMode } from '@/stores/metricsViewMode'; import { getMetricHistoryForRange, getMetricsVersion } from '@/stores/metricsHistory'; import type { AnomalyReport } from '@/types/aiIntelligence'; +import { + getDefaultMetricDisplayThresholds, + getMetricSeverity, + type MetricDisplayThresholds, +} from '@/utils/alertThresholds'; interface StackedMemoryBarProps { used: number; @@ -15,6 +20,7 @@ interface StackedMemoryBarProps { balloon?: number; resourceId?: string; // Required for sparkline mode to fetch history anomaly?: AnomalyReport | null; // Baseline anomaly if detected + thresholds?: MetricDisplayThresholds | null; } // Anomaly severity colors @@ -34,9 +40,19 @@ const MEMORY_COLORS = { }; // Threshold-based colors for memory usage (matches disk bar behavior) -const getMemoryColor = (percent: number): string => { - if (percent >= 90) return 'rgba(239, 68, 68, 0.7)'; // red - if (percent >= 70) return 'rgba(234, 179, 8, 0.7)'; // yellow/orange +const getMemoryColor = ( + percent: number, + thresholds?: MetricDisplayThresholds | null, +): string => { + const resolvedThresholds = thresholds === undefined + ? getDefaultMetricDisplayThresholds('memory') + : thresholds; + const severity = getMetricSeverity( + percent, + resolvedThresholds, + ); + if (severity === 'red') return 'rgba(239, 68, 68, 0.7)'; // red + if (severity === 'yellow') return 'rgba(234, 179, 8, 0.7)'; // yellow/orange return 'rgba(34, 197, 94, 0.6)'; // green }; @@ -102,7 +118,12 @@ export function StackedMemoryBar(props: StackedMemoryBarProps) { const segs: Array<{ type: string; bytes: number; percent: number; color: string }> = []; // Always show the used segment - segs.push({ type: 'Used', bytes: props.used, percent: usedPercent, color: getMemoryColor(usedPercent) }); + segs.push({ + type: 'Used', + bytes: props.used, + percent: usedPercent, + color: getMemoryColor(usedPercent, props.thresholds), + }); // Reclaimable segment (buff/cache) — shown as muted amber if (cache > 0) { diff --git a/frontend-modern/src/components/Docker/DockerHostSummaryTable.tsx b/frontend-modern/src/components/Docker/DockerHostSummaryTable.tsx index db4bff61a..e6f5f0798 100644 --- a/frontend-modern/src/components/Docker/DockerHostSummaryTable.tsx +++ b/frontend-modern/src/components/Docker/DockerHostSummaryTable.tsx @@ -11,6 +11,7 @@ import { useBreakpoint } from '@/hooks/useBreakpoint'; import { ResponsiveMetricCell } from '@/components/shared/responsive'; import { EnhancedCPUBar } from '@/components/Dashboard/EnhancedCPUBar'; import { isAgentOutdated, getAgentVersionTooltip } from '@/utils/agentVersion'; +import { useAlertsActivation } from '@/stores/alertsActivation'; export interface DockerHostSummary { @@ -53,6 +54,7 @@ export const DockerHostSummaryTable: Component = (p const [sortKey, setSortKey] = createSignal('name'); const [sortDirection, setSortDirection] = createSignal('asc'); const { isMobile } = useBreakpoint(); + const alertsActivation = useAlertsActivation(); const handleSort = (key: SortKey) => { if (sortKey() === key) { @@ -241,6 +243,9 @@ export const DockerHostSummaryTable: Component = (p const runtimeVersion = summary.host.runtimeVersion || summary.host.dockerVersion; const metricsKey = buildMetricKey('dockerHost', summary.host.id); const hostStatus = createMemo(() => getDockerHostStatusIndicator(summary.host)); + const cpuThresholds = createMemo(() => alertsActivation.getMetricThresholds('docker', 'cpu', summary.host.id)); + const memoryThresholds = createMemo(() => alertsActivation.getMetricThresholds('docker', 'memory', summary.host.id)); + const diskThresholds = createMemo(() => alertsActivation.getMetricThresholds('docker', 'disk', summary.host.id)); return ( = (p usage={summary.cpuPercent} loadAverage={summary.host.loadAverage?.[0]} cores={isMobile() ? undefined : summary.host.cpus} + thresholds={cpuThresholds()} resourceId={metricsKey} /> @@ -305,6 +311,7 @@ export const DockerHostSummaryTable: Component = (p resourceId={metricsKey} isRunning={online} showMobile={false} + thresholds={memoryThresholds()} /> @@ -317,6 +324,7 @@ export const DockerHostSummaryTable: Component = (p resourceId={metricsKey} isRunning={!!summary.diskLabel} showMobile={false} + thresholds={diskThresholds()} /> diff --git a/frontend-modern/src/components/Docker/DockerUnifiedTable.tsx b/frontend-modern/src/components/Docker/DockerUnifiedTable.tsx index 15decf751..ed551aff5 100644 --- a/frontend-modern/src/components/Docker/DockerUnifiedTable.tsx +++ b/frontend-modern/src/components/Docker/DockerUnifiedTable.tsx @@ -40,6 +40,7 @@ import { GuestMetadataAPI, type GuestMetadata } from '@/api/guestMetadata'; import { UrlEditPopover, createUrlEditState } from '@/components/shared/UrlEditPopover'; import { showSuccess, showError } from '@/utils/toast'; import { logger } from '@/utils/logger'; +import { useAlertsActivation } from '@/stores/alertsActivation'; type GuestMetadataRecord = Record; @@ -810,6 +811,7 @@ const DockerContainerRow: Component<{ onCustomUrlChange?: (guestId: string, url: string) => void; }> = (props) => { const { host, container } = props.row; + const alertsActivation = useAlertsActivation(); const runtimeInfo = resolveHostRuntime(host); const runtimeVersion = () => host.runtimeVersion || host.dockerVersion || null; const hostStatus = createMemo(() => getDockerHostStatusIndicator(host)); @@ -960,6 +962,9 @@ const DockerContainerRow: Component<{ const cpuPercent = () => Math.max(0, Math.min(100, container.cpuPercent ?? 0)); const metricsKey = buildMetricKey('dockerContainer', container.id); + const cpuThresholds = createMemo(() => alertsActivation.getMetricThresholds('docker', 'cpu', container.id)); + const memoryThresholds = createMemo(() => alertsActivation.getMetricThresholds('docker', 'memory', container.id)); + const diskThresholds = createMemo(() => alertsActivation.getMetricThresholds('docker', 'disk', container.id)); const uptime = () => (container.uptimeSeconds ? formatUptime(container.uptimeSeconds) : '—'); const restarts = () => container.restartCount ?? 0; @@ -1096,6 +1101,7 @@ const DockerContainerRow: Component<{ isRunning={isRunning()} showMobile={false} class="w-full" + thresholds={cpuThresholds()} /> ); @@ -1113,6 +1119,7 @@ const DockerContainerRow: Component<{ balloon={0} swapUsed={0} swapTotal={0} + thresholds={memoryThresholds()} resourceId={metricsKey} /> @@ -1131,6 +1138,7 @@ const DockerContainerRow: Component<{ isRunning={true} showMobile={false} class="w-full" + thresholds={diskThresholds()} /> diff --git a/frontend-modern/src/components/Hosts/HostsOverview.tsx b/frontend-modern/src/components/Hosts/HostsOverview.tsx index 4baf59db2..7dd79bee6 100644 --- a/frontend-modern/src/components/Hosts/HostsOverview.tsx +++ b/frontend-modern/src/components/Hosts/HostsOverview.tsx @@ -1326,6 +1326,7 @@ interface HostRowProps { const HostRow: Component = (props) => { // NOTE: Do NOT destructure props.host - it breaks SolidJS reactivity! // Always access props.host directly to ensure updates flow through. + const alertsActivation = useAlertsActivation(); const [localCustomUrl, setLocalCustomUrl] = createSignal(props.customUrl); // Sync local state when parent prop changes (e.g., drawer edits URL) @@ -1384,6 +1385,9 @@ const HostRow: Component = (props) => { const isOnline = () => props.host.status === 'online'; const cpuPercent = () => props.host.cpuUsage ?? 0; const diskStats = () => props.getDiskStats(props.host); + const cpuThresholds = createMemo(() => alertsActivation.getMetricThresholds('host', 'cpu', props.host.id)); + const memoryThresholds = createMemo(() => alertsActivation.getMetricThresholds('host', 'memory', props.host.id)); + const diskThresholds = createMemo(() => alertsActivation.getMetricThresholds('host', 'disk', props.host.id)); const rowClass = () => { const base = 'transition-all duration-200 cursor-pointer'; @@ -1455,6 +1459,7 @@ const HostRow: Component = (props) => { usage={cpuPercent()} loadAverage={props.host.loadAverage?.[0]} cores={props.isMobile() ? undefined : props.host.cpuCount} + thresholds={cpuThresholds()} resourceId={buildMetricKey('host', props.host.id)} /> @@ -1472,6 +1477,7 @@ const HostRow: Component = (props) => { balloon={props.host.memory?.balloon || 0} swapUsed={props.host.memory?.swapUsed || 0} swapTotal={props.host.memory?.swapTotal || 0} + thresholds={memoryThresholds()} resourceId={buildMetricKey('host', props.host.id)} /> @@ -1485,6 +1491,7 @@ const HostRow: Component = (props) => { ({ vi.mock('@/stores/alertsActivation', () => ({ useAlertsActivation: () => ({ getTemperatureThreshold: () => 80, + getMetricThresholds: () => ({ warning: 75, critical: 80 }), }), })); diff --git a/frontend-modern/src/components/shared/NodeSummaryTable.tsx b/frontend-modern/src/components/shared/NodeSummaryTable.tsx index 3679bd1e1..b9c6efe8b 100644 --- a/frontend-modern/src/components/shared/NodeSummaryTable.tsx +++ b/frontend-modern/src/components/shared/NodeSummaryTable.tsx @@ -42,9 +42,6 @@ export const NodeSummaryTable: Component = (props) => { const { isMobile } = useBreakpoint(); const { viewMode } = useMetricsViewMode(); - // Get user-configured temperature threshold for display coloring - const temperatureThreshold = createMemo(() => alertsActivation.getTemperatureThreshold()); - const isTemperatureMonitoringEnabled = (node: Node): boolean => { const globalEnabled = props.globalTemperatureMonitoringEnabled ?? true; if (node.temperatureMonitoringEnabled !== undefined && node.temperatureMonitoringEnabled !== null) { @@ -457,6 +454,15 @@ export const NodeSummaryTable: Component = (props) => { const isExpanded = () => expandedNodeId() === nodeId; const resourceId = isPVEItem ? node!.id || node!.name : pbs!.id || pbs!.name; const metricsKey = buildMetricKey('node', resourceId); + const scope = isPVEItem ? 'node' : 'pbs'; + const cpuThresholds = createMemo(() => alertsActivation.getMetricThresholds(scope, 'cpu', resourceId)); + const memoryThresholds = createMemo(() => alertsActivation.getMetricThresholds(scope, 'memory', resourceId)); + const diskThresholds = createMemo(() => alertsActivation.getMetricThresholds(scope, 'disk', resourceId)); + const temperatureThresholds = createMemo(() => ( + isPVEItem + ? alertsActivation.getMetricThresholds('node', 'temperature', resourceId) + : null + )); const alertStyles = createMemo(() => getAlertStyles(resourceId, activeAlerts, alertsEnabled()), ); @@ -631,6 +637,7 @@ export const NodeSummaryTable: Component = (props) => { loadAverage={isPVEItem ? node!.loadAverage?.[0] : undefined} cores={isMobile() ? undefined : (isPVEItem ? node!.cpuInfo?.cores : undefined)} model={isPVEItem ? node!.cpuInfo?.model : undefined} + thresholds={cpuThresholds()} resourceId={metricsKey} /> @@ -647,6 +654,7 @@ export const NodeSummaryTable: Component = (props) => { sublabel={pbs!.memoryTotal ? `${formatBytes(pbs!.memoryUsed)}/${formatBytes(pbs!.memoryTotal)}` : undefined} isRunning={online} showMobile={false} + thresholds={memoryThresholds()} /> }> = (props) => { balloon={node!.memory?.balloon || 0} swapUsed={node!.memory?.swapUsed || 0} swapTotal={node!.memory?.swapTotal || 0} + thresholds={memoryThresholds()} resourceId={metricsKey} /> } @@ -669,6 +678,7 @@ export const NodeSummaryTable: Component = (props) => { resourceId={metricsKey} isRunning={online} showMobile={false} + thresholds={memoryThresholds()} /> @@ -686,6 +696,7 @@ export const NodeSummaryTable: Component = (props) => { sublabel={diskSublabel} isRunning={online} showMobile={false} + thresholds={diskThresholds()} /> }> = (props) => { free: (node!.disk?.total || 0) - (node!.disk?.used || 0), usage: node!.disk?.total ? (node!.disk.used / node!.disk.total) : 0 }} + thresholds={diskThresholds()} /> } > @@ -707,6 +719,7 @@ export const NodeSummaryTable: Component = (props) => { resourceId={metricsKey} isRunning={online} showMobile={false} + thresholds={diskThresholds()} /> @@ -751,8 +764,8 @@ export const NodeSummaryTable: Component = (props) => { value={value} min={min} max={max} - critical={temperatureThreshold()} - warning={Math.max(0, temperatureThreshold() - 5)} + critical={temperatureThresholds()?.critical ?? alertsActivation.getTemperatureThreshold()} + warning={temperatureThresholds()?.warning ?? Math.max(0, alertsActivation.getTemperatureThreshold() - 5)} /> ); @@ -761,8 +774,8 @@ export const NodeSummaryTable: Component = (props) => { return ( ); })()} diff --git a/frontend-modern/src/components/shared/responsive/ResponsiveMetricCell.tsx b/frontend-modern/src/components/shared/responsive/ResponsiveMetricCell.tsx index c5f65c6e2..09b9bc55a 100644 --- a/frontend-modern/src/components/shared/responsive/ResponsiveMetricCell.tsx +++ b/frontend-modern/src/components/shared/responsive/ResponsiveMetricCell.tsx @@ -1,6 +1,11 @@ import { Component, Show, createMemo, JSX } from 'solid-js'; import { MetricBar } from '@/components/Dashboard/MetricBar'; import { formatPercent } from '@/utils/format'; +import { + getDefaultMetricDisplayThresholds, + getMetricSeverity, + type MetricDisplayThresholds, +} from '@/utils/alertThresholds'; export interface ResponsiveMetricCellProps { /** Metric value (0-100 percentage) */ @@ -18,6 +23,9 @@ export interface ResponsiveMetricCellProps { /** Resource ID for sparkline tracking */ resourceId?: string; + /** Display thresholds for warning/critical coloring */ + thresholds?: MetricDisplayThresholds | null; + /** Whether the resource is running/online - if false, shows fallback */ isRunning?: boolean; @@ -34,26 +42,17 @@ export interface ResponsiveMetricCellProps { /** * Get the appropriate text color class based on metric value and type */ -function getMetricColorClass(value: number, type: 'cpu' | 'memory' | 'disk'): string { - // Thresholds match MetricBar component - if (type === 'cpu') { - if (value >= 90) return 'text-red-600 dark:text-red-400 font-bold'; - if (value >= 80) return 'text-orange-600 dark:text-orange-400 font-medium'; - return 'text-gray-600 dark:text-gray-400'; - } - - if (type === 'memory') { - if (value >= 85) return 'text-red-600 dark:text-red-400 font-bold'; - if (value >= 75) return 'text-orange-600 dark:text-orange-400 font-medium'; - return 'text-gray-600 dark:text-gray-400'; - } - - if (type === 'disk') { - if (value >= 90) return 'text-red-600 dark:text-red-400 font-bold'; - if (value >= 80) return 'text-orange-600 dark:text-orange-400 font-medium'; - return 'text-gray-600 dark:text-gray-400'; - } - +function getMetricColorClass( + value: number, + type: 'cpu' | 'memory' | 'disk', + thresholds?: MetricDisplayThresholds | null, +): string { + const resolvedThresholds = thresholds === undefined + ? getDefaultMetricDisplayThresholds(type) + : thresholds; + const severity = getMetricSeverity(value, resolvedThresholds); + if (severity === 'red') return 'text-red-600 dark:text-red-400 font-bold'; + if (severity === 'yellow') return 'text-orange-600 dark:text-orange-400 font-medium'; return 'text-gray-600 dark:text-gray-400'; } @@ -74,7 +73,7 @@ function getMetricColorClass(value: number, type: 'cpu' | 'memory' | 'disk'): st */ export const ResponsiveMetricCell: Component = (props) => { const displayLabel = createMemo(() => props.label ?? formatPercent(props.value)); - const colorClass = createMemo(() => getMetricColorClass(props.value, props.type)); + const colorClass = createMemo(() => getMetricColorClass(props.value, props.type, props.thresholds)); const isRunning = () => props.isRunning !== false; // Default to true if not specified const defaultFallback = ( @@ -100,6 +99,7 @@ export const ResponsiveMetricCell: Component = (props label={displayLabel()} sublabel={props.sublabel} type={props.type} + thresholds={props.thresholds} resourceId={props.resourceId} /> @@ -116,10 +116,11 @@ export const MetricText: Component<{ value: number; type: 'cpu' | 'memory' | 'disk'; label?: string; + thresholds?: MetricDisplayThresholds | null; class?: string; }> = (props) => { const displayLabel = createMemo(() => props.label ?? formatPercent(props.value)); - const colorClass = createMemo(() => getMetricColorClass(props.value, props.type)); + const colorClass = createMemo(() => getMetricColorClass(props.value, props.type, props.thresholds)); return ( @@ -138,6 +139,7 @@ export const DualMetricCell: Component<{ label?: string; sublabel?: string; resourceId?: string; + thresholds?: MetricDisplayThresholds | null; isRunning?: boolean; showMobile: boolean; mobileContent?: JSX.Element; @@ -146,7 +148,7 @@ export const DualMetricCell: Component<{ class?: string; }> = (props) => { const displayLabel = createMemo(() => props.label ?? formatPercent(props.value)); - const colorClass = createMemo(() => getMetricColorClass(props.value, props.type)); + const colorClass = createMemo(() => getMetricColorClass(props.value, props.type, props.thresholds)); const isRunning = () => props.isRunning !== false; const defaultFallback = ( @@ -167,6 +169,7 @@ export const DualMetricCell: Component<{ label={displayLabel()} sublabel={props.sublabel} type={props.type} + thresholds={props.thresholds} resourceId={props.resourceId} /> ); diff --git a/frontend-modern/src/pages/Alerts.tsx b/frontend-modern/src/pages/Alerts.tsx index bc85cf906..67b573da8 100644 --- a/frontend-modern/src/pages/Alerts.tsx +++ b/frontend-modern/src/pages/Alerts.tsx @@ -31,6 +31,13 @@ import History from 'lucide-solid/icons/history'; import Gauge from 'lucide-solid/icons/gauge'; import Send from 'lucide-solid/icons/send'; import Calendar from 'lucide-solid/icons/calendar'; +import { + FACTORY_DOCKER_DEFAULTS, + FACTORY_GUEST_DEFAULTS, + FACTORY_HOST_DEFAULTS, + FACTORY_NODE_DEFAULTS, + FACTORY_PBS_DEFAULTS, +} from '@/utils/alertThresholds'; type AlertTab = 'overview' | 'thresholds' | 'destinations' | 'schedule' | 'history'; @@ -1475,46 +1482,6 @@ export function Alerts() { }, ); - // Factory defaults - constants for reset functionality - const FACTORY_GUEST_DEFAULTS = { - cpu: 80, - memory: 85, - disk: 90, - diskRead: -1, - diskWrite: -1, - networkIn: -1, - networkOut: -1, - }; - - const FACTORY_NODE_DEFAULTS = { - cpu: 80, - memory: 85, - disk: 90, - temperature: 80, - }; - const FACTORY_PBS_DEFAULTS = { - cpu: 80, - memory: 85, - }; - - const FACTORY_HOST_DEFAULTS = { - cpu: 80, - memory: 85, - disk: 90, - diskTemperature: 55, - }; - - const FACTORY_DOCKER_DEFAULTS = { - cpu: 80, - memory: 85, - disk: 85, - restartCount: 3, - restartWindow: 300, - memoryWarnPct: 90, - memoryCriticalPct: 95, - serviceWarnGapPercent: 10, - serviceCriticalGapPercent: 50, - }; const FACTORY_DOCKER_STATE_DISABLE_CONNECTIVITY = false; const FACTORY_DOCKER_STATE_SEVERITY: 'warning' | 'critical' = 'warning'; diff --git a/frontend-modern/src/stores/alertsActivation.ts b/frontend-modern/src/stores/alertsActivation.ts index 21de3e53e..fcd7885e8 100644 --- a/frontend-modern/src/stores/alertsActivation.ts +++ b/frontend-modern/src/stores/alertsActivation.ts @@ -3,6 +3,12 @@ import { AlertsAPI } from '@/api/alerts'; import type { AlertConfig, ActivationState as ActivationStateType } from '@/types/alerts'; import type { Alert } from '@/types/api'; import { setGlobalActivationState } from '@/utils/alertsActivation'; +import { + FACTORY_NODE_DEFAULTS, + type AlertThresholdScope, + type DisplayMetricType, + resolveMetricDisplayThresholds, +} from '@/utils/alertThresholds'; import { logger } from '@/utils/logger'; // Create signals for activation state @@ -129,14 +135,15 @@ const getBackupThresholds = (): { freshHours: number; staleHours: number } => { // Get temperature threshold from config (for display coloring) const getTemperatureThreshold = (): number => { - const cfg = config(); - // nodeDefaults.temperature is a HysteresisThreshold with trigger/clear - const tempConfig = cfg?.nodeDefaults?.temperature; - if (typeof tempConfig === 'object' && tempConfig !== null && 'trigger' in tempConfig) { - return (tempConfig as { trigger: number }).trigger; - } - // Fallback to default 80°C - return 80; + return getMetricThresholds('node', 'temperature')?.critical ?? FACTORY_NODE_DEFAULTS.temperature; +}; + +const getMetricThresholds = ( + scope: AlertThresholdScope, + metric: DisplayMetricType, + resourceId?: string, +) => { + return resolveMetricDisplayThresholds(config(), scope, metric, resourceId); }; // Export the store @@ -152,6 +159,7 @@ export const useAlertsActivation = () => ({ isPastObservationWindow, getBackupThresholds, getTemperatureThreshold, + getMetricThresholds, // Actions refreshConfig, diff --git a/frontend-modern/src/utils/__tests__/alertThresholds.test.ts b/frontend-modern/src/utils/__tests__/alertThresholds.test.ts new file mode 100644 index 000000000..1788a1ccb --- /dev/null +++ b/frontend-modern/src/utils/__tests__/alertThresholds.test.ts @@ -0,0 +1,78 @@ +import { describe, expect, it } from 'vitest'; + +import { + getDefaultMetricDisplayThresholds, + getMetricSeverity, + resolveMetricDisplayThresholds, +} from '../alertThresholds'; +import type { AlertConfig } from '@/types/alerts'; + +describe('alertThresholds', () => { + it('uses alert defaults for display bands', () => { + expect(getDefaultMetricDisplayThresholds('cpu')).toEqual({ warning: 75, critical: 80 }); + expect(getDefaultMetricDisplayThresholds('memory')).toEqual({ warning: 80, critical: 85 }); + expect(getDefaultMetricDisplayThresholds('disk')).toEqual({ warning: 85, critical: 90 }); + expect(getDefaultMetricDisplayThresholds('generic')).toEqual({ warning: 75, critical: 90 }); + }); + + it('resolves scope defaults when no config is loaded', () => { + expect(resolveMetricDisplayThresholds(null, 'node', 'memory')).toEqual({ + warning: 80, + critical: 85, + }); + expect(resolveMetricDisplayThresholds(null, 'docker', 'disk')).toEqual({ + warning: 80, + critical: 85, + }); + }); + + it('prefers resource overrides and uses explicit clear values', () => { + const config = { + enabled: true, + guestDefaults: {}, + nodeDefaults: { + cpu: { trigger: 80, clear: 75 }, + }, + storageDefault: { trigger: 85, clear: 80 }, + overrides: { + 'node-1': { + cpu: { trigger: 85, clear: 80 }, + }, + }, + } as AlertConfig; + + expect(resolveMetricDisplayThresholds(config, 'node', 'cpu', 'node-1')).toEqual({ + warning: 80, + critical: 85, + }); + expect(resolveMetricDisplayThresholds(config, 'node', 'cpu', 'node-2')).toEqual({ + warning: 75, + critical: 80, + }); + }); + + it('treats disabled thresholds as absent', () => { + const config = { + enabled: true, + guestDefaults: {}, + nodeDefaults: { + cpu: { trigger: 80, clear: 75 }, + }, + storageDefault: { trigger: 85, clear: 80 }, + overrides: { + 'node-1': { + cpu: { trigger: -1, clear: 0 }, + }, + }, + } as AlertConfig; + + expect(resolveMetricDisplayThresholds(config, 'node', 'cpu', 'node-1')).toBeNull(); + }); + + it('maps usage values to severity bands', () => { + const thresholds = { warning: 80, critical: 85 }; + expect(getMetricSeverity(79, thresholds)).toBe('green'); + expect(getMetricSeverity(80, thresholds)).toBe('yellow'); + expect(getMetricSeverity(85, thresholds)).toBe('red'); + }); +}); diff --git a/frontend-modern/src/utils/alertThresholds.ts b/frontend-modern/src/utils/alertThresholds.ts new file mode 100644 index 000000000..6a5e664f2 --- /dev/null +++ b/frontend-modern/src/utils/alertThresholds.ts @@ -0,0 +1,223 @@ +import type { + AlertConfig, + AlertThresholds, + DockerThresholdConfig, + HysteresisThreshold, + RawOverrideConfig, +} from '@/types/alerts'; + +export type DisplayMetricType = 'cpu' | 'memory' | 'disk' | 'temperature' | 'diskTemperature'; +export type DisplayMetricBarType = 'cpu' | 'memory' | 'disk' | 'generic'; +export type AlertThresholdScope = 'guest' | 'node' | 'pbs' | 'host' | 'docker'; +export type MetricSeverity = 'green' | 'yellow' | 'red'; + +export interface MetricDisplayThresholds { + warning: number; + critical: number; +} + +export interface FactoryDockerDefaults { + [key: string]: number; + cpu: number; + memory: number; + disk: number; + restartCount: number; + restartWindow: number; + memoryWarnPct: number; + memoryCriticalPct: number; + serviceWarnGapPercent: number; + serviceCriticalGapPercent: number; +} + +export const FACTORY_GUEST_DEFAULTS: Record = { + cpu: 80, + memory: 85, + disk: 90, + diskRead: -1, + diskWrite: -1, + networkIn: -1, + networkOut: -1, +}; + +export const FACTORY_NODE_DEFAULTS: Record = { + cpu: 80, + memory: 85, + disk: 90, + temperature: 80, +}; + +export const FACTORY_PBS_DEFAULTS: Record = { + cpu: 80, + memory: 85, +}; + +export const FACTORY_HOST_DEFAULTS: Record = { + cpu: 80, + memory: 85, + disk: 90, + diskTemperature: 55, +}; + +export const FACTORY_DOCKER_DEFAULTS: FactoryDockerDefaults = { + cpu: 80, + memory: 85, + disk: 85, + restartCount: 3, + restartWindow: 300, + memoryWarnPct: 90, + memoryCriticalPct: 95, + serviceWarnGapPercent: 10, + serviceCriticalGapPercent: 50, +}; + +const DEFAULT_GENERIC_THRESHOLDS: MetricDisplayThresholds = { + warning: 75, + critical: 90, +}; + +const DEFAULT_HYSTERESIS_MARGIN = 5; + +const SCOPE_DEFAULTS: Record>> = { + guest: FACTORY_GUEST_DEFAULTS, + node: FACTORY_NODE_DEFAULTS, + pbs: FACTORY_PBS_DEFAULTS, + host: FACTORY_HOST_DEFAULTS, + docker: FACTORY_DOCKER_DEFAULTS, +}; + +const toFiniteNumber = (value: unknown): number | null => { + if (typeof value !== 'number' || !Number.isFinite(value)) { + return null; + } + return value; +}; + +const normalizeMargin = (value: number | undefined): number => { + const numeric = toFiniteNumber(value); + if (numeric === null) { + return DEFAULT_HYSTERESIS_MARGIN; + } + return Math.max(0, numeric); +}; + +const isHysteresisThreshold = (value: unknown): value is HysteresisThreshold => { + return value !== null && typeof value === 'object' && 'trigger' in value; +}; + +const getScopeThresholds = ( + config: AlertConfig | null, + scope: AlertThresholdScope, +): AlertThresholds | DockerThresholdConfig | undefined => { + switch (scope) { + case 'guest': + return config?.guestDefaults; + case 'node': + return config?.nodeDefaults; + case 'pbs': + return config?.pbsDefaults; + case 'host': + return config?.hostDefaults; + case 'docker': + return config?.dockerDefaults; + } +}; + +const getOverrideValue = ( + override: RawOverrideConfig | undefined, + metric: DisplayMetricType, +): number | HysteresisThreshold | undefined => { + const value = override?.[metric]; + if (typeof value === 'number' || isHysteresisThreshold(value)) { + return value; + } + return undefined; +}; + +const resolveThreshold = ( + value: number | HysteresisThreshold | undefined, + fallbackCritical: number | undefined, + margin: number, +): MetricDisplayThresholds | null => { + if (isHysteresisThreshold(value)) { + const critical = toFiniteNumber(value.trigger); + if (critical === null || critical <= 0) { + return null; + } + const clear = toFiniteNumber(value.clear); + const warning = clear === null + ? Math.max(0, critical - margin) + : Math.max(0, Math.min(clear, critical)); + return { warning, critical }; + } + + const numeric = toFiniteNumber(value); + if (numeric !== null) { + if (numeric <= 0) { + return null; + } + return { + warning: Math.max(0, numeric - margin), + critical: numeric, + }; + } + + if (!fallbackCritical || fallbackCritical <= 0) { + return null; + } + + return { + warning: Math.max(0, fallbackCritical - margin), + critical: fallbackCritical, + }; +}; + +export const getDefaultMetricDisplayThresholds = ( + metric: DisplayMetricBarType, +): MetricDisplayThresholds => { + if (metric === 'generic') { + return DEFAULT_GENERIC_THRESHOLDS; + } + + const critical = SCOPE_DEFAULTS.guest[metric]; + if (!critical || critical <= 0) { + return DEFAULT_GENERIC_THRESHOLDS; + } + + return { + warning: Math.max(0, critical - DEFAULT_HYSTERESIS_MARGIN), + critical, + }; +}; + +export const resolveMetricDisplayThresholds = ( + config: AlertConfig | null, + scope: AlertThresholdScope, + metric: DisplayMetricType, + resourceId?: string, +): MetricDisplayThresholds | null => { + const margin = normalizeMargin(config?.hysteresisMargin); + const scopeThresholds = getScopeThresholds(config, scope); + const override = resourceId ? config?.overrides?.[resourceId] : undefined; + const overrideValue = getOverrideValue(override, metric); + const baseValue = scopeThresholds + ? (scopeThresholds as Partial>)[metric] + : undefined; + const fallbackCritical = SCOPE_DEFAULTS[scope][metric]; + return resolveThreshold(overrideValue ?? (typeof baseValue === 'number' || isHysteresisThreshold(baseValue) ? baseValue : undefined), fallbackCritical, margin); +}; + +export const getMetricSeverity = ( + value: number, + thresholds: MetricDisplayThresholds | null | undefined, +): MetricSeverity => { + if (!thresholds) { + return 'green'; + } + if (value >= thresholds.critical) { + return 'red'; + } + if (value >= thresholds.warning) { + return 'yellow'; + } + return 'green'; +};