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';
+};