Respect configured usage thresholds in metric coloring (#1358)

This commit is contained in:
rcourtman 2026-03-25 09:55:27 +00:00
parent f9bf42498f
commit 7422de8505
16 changed files with 524 additions and 124 deletions

View file

@ -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) {
<div class="flex justify-between gap-3 py-0.5">
<span class="text-gray-400">Usage</span>
<span class={`font-medium ${props.usage > 90 ? 'text-red-400' : 'text-gray-200'}`}>
<span class={`font-medium ${severity() === 'red' ? 'text-red-400' : severity() === 'yellow' ? 'text-yellow-300' : 'text-gray-200'}`}>
{formatPercent(props.usage)}
</span>
</div>

View file

@ -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) {
<EnhancedCPUBar
usage={cpuPercent()}
cores={isMobile() ? undefined : props.guest.cpus}
thresholds={cpuThresholds()}
resourceId={metricsKey()}
anomaly={cpuAnomaly()}
/>
@ -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()}
/>
</Show>
</div>
@ -884,6 +891,7 @@ export function GuestRow(props: GuestRowProps) {
<StackedDiskBar
disks={props.guest.disks}
aggregateDisk={props.guest.disk}
thresholds={diskThresholds()}
anomaly={diskAnomaly()}
/>
}
@ -894,6 +902,7 @@ export function GuestRow(props: GuestRowProps) {
resourceId={metricsKey()}
isRunning={isRunning()}
showMobile={false}
thresholds={diskThresholds()}
/>
</Show>
</Show>

View file

@ -67,12 +67,12 @@ describe('MetricBar', () => {
expect(bar).toBeInTheDocument();
result.unmount();
result = render(() => <MetricBar value={80} label="val" type="cpu" />);
result = render(() => <MetricBar value={75} label="val" type="cpu" />);
bar = result.container.querySelector('.bg-yellow-500\\/60');
expect(bar).toBeInTheDocument();
result.unmount();
result = render(() => <MetricBar value={90} label="val" type="cpu" />);
result = render(() => <MetricBar value={80} label="val" type="cpu" />);
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(() => <MetricBar value={75} label="val" type="memory" />);
result = render(() => <MetricBar value={80} label="val" type="memory" />);
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(() => <MetricBar value={80} label="val" type="disk" />);
result = render(() => <MetricBar value={85} label="val" type="disk" />);
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(() => (
<MetricBar
value={80}
label="val"
type="cpu"
thresholds={{ warning: 80, critical: 85 }}
/>
));
let bar = result.container.querySelector('.bg-yellow-500\\/60');
expect(bar).toBeInTheDocument();
result.unmount();
result = render(() => (
<MetricBar
value={85}
label="val"
type="cpu"
thresholds={{ warning: 80, critical: 85 }}
/>
));
bar = result.container.querySelector('.bg-red-500\\/60');
expect(bar).toBeInTheDocument();
});
it('toggles sparkline view mode', () => {
mockUseMetricsViewMode.mockReturnValue({
viewMode: () => 'sparklines',

View file

@ -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

View file

@ -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 [];

View file

@ -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) {

View file

@ -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<DockerHostSummaryTableProps> = (p
const [sortKey, setSortKey] = createSignal<SortKey>('name');
const [sortDirection, setSortDirection] = createSignal<SortDirection>('asc');
const { isMobile } = useBreakpoint();
const alertsActivation = useAlertsActivation();
const handleSort = (key: SortKey) => {
if (sortKey() === key) {
@ -241,6 +243,9 @@ export const DockerHostSummaryTable: Component<DockerHostSummaryTableProps> = (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 (
<tr
@ -292,6 +297,7 @@ export const DockerHostSummaryTable: Component<DockerHostSummaryTableProps> = (p
usage={summary.cpuPercent}
loadAverage={summary.host.loadAverage?.[0]}
cores={isMobile() ? undefined : summary.host.cpus}
thresholds={cpuThresholds()}
resourceId={metricsKey}
/>
</div>
@ -305,6 +311,7 @@ export const DockerHostSummaryTable: Component<DockerHostSummaryTableProps> = (p
resourceId={metricsKey}
isRunning={online}
showMobile={false}
thresholds={memoryThresholds()}
/>
</div>
</td>
@ -317,6 +324,7 @@ export const DockerHostSummaryTable: Component<DockerHostSummaryTableProps> = (p
resourceId={metricsKey}
isRunning={!!summary.diskLabel}
showMobile={false}
thresholds={diskThresholds()}
/>
</div>
</td>

View file

@ -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<string, GuestMetadata>;
@ -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()}
/>
</div>
);
@ -1113,6 +1119,7 @@ const DockerContainerRow: Component<{
balloon={0}
swapUsed={0}
swapTotal={0}
thresholds={memoryThresholds()}
resourceId={metricsKey}
/>
</div>
@ -1131,6 +1138,7 @@ const DockerContainerRow: Component<{
isRunning={true}
showMobile={false}
class="w-full"
thresholds={diskThresholds()}
/>
</Show>
</Show>

View file

@ -1326,6 +1326,7 @@ interface HostRowProps {
const HostRow: Component<HostRowProps> = (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<string | undefined>(props.customUrl);
// Sync local state when parent prop changes (e.g., drawer edits URL)
@ -1384,6 +1385,9 @@ const HostRow: Component<HostRowProps> = (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<HostRowProps> = (props) => {
usage={cpuPercent()}
loadAverage={props.host.loadAverage?.[0]}
cores={props.isMobile() ? undefined : props.host.cpuCount}
thresholds={cpuThresholds()}
resourceId={buildMetricKey('host', props.host.id)}
/>
</Show>
@ -1472,6 +1477,7 @@ const HostRow: Component<HostRowProps> = (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)}
/>
</Show>
@ -1485,6 +1491,7 @@ const HostRow: Component<HostRowProps> = (props) => {
<StackedDiskBar
disks={props.host.disks}
mode="mini"
thresholds={diskThresholds()}
aggregateDisk={{
total: diskStats().total,
used: diskStats().used,

View file

@ -48,6 +48,7 @@ vi.mock('@/hooks/useColumnVisibility', () => ({
vi.mock('@/stores/alertsActivation', () => ({
useAlertsActivation: () => ({
getTemperatureThreshold: () => 80,
getMetricThresholds: () => ({ warning: 75, critical: 80 }),
}),
}));

View file

@ -42,9 +42,6 @@ export const NodeSummaryTable: Component<NodeSummaryTableProps> = (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<NodeSummaryTableProps> = (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<NodeSummaryTableProps> = (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}
/>
</div>
@ -647,6 +654,7 @@ export const NodeSummaryTable: Component<NodeSummaryTableProps> = (props) => {
sublabel={pbs!.memoryTotal ? `${formatBytes(pbs!.memoryUsed)}/${formatBytes(pbs!.memoryTotal)}` : undefined}
isRunning={online}
showMobile={false}
thresholds={memoryThresholds()}
/>
}>
<Show
@ -659,6 +667,7 @@ export const NodeSummaryTable: Component<NodeSummaryTableProps> = (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<NodeSummaryTableProps> = (props) => {
resourceId={metricsKey}
isRunning={online}
showMobile={false}
thresholds={memoryThresholds()}
/>
</Show>
</Show>
@ -686,6 +696,7 @@ export const NodeSummaryTable: Component<NodeSummaryTableProps> = (props) => {
sublabel={diskSublabel}
isRunning={online}
showMobile={false}
thresholds={diskThresholds()}
/>
}>
<Show
@ -698,6 +709,7 @@ export const NodeSummaryTable: Component<NodeSummaryTableProps> = (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<NodeSummaryTableProps> = (props) => {
resourceId={metricsKey}
isRunning={online}
showMobile={false}
thresholds={diskThresholds()}
/>
</Show>
</Show>
@ -751,8 +764,8 @@ export const NodeSummaryTable: Component<NodeSummaryTableProps> = (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)}
/>
</div>
);
@ -761,8 +774,8 @@ export const NodeSummaryTable: Component<NodeSummaryTableProps> = (props) => {
return (
<TemperatureGauge
value={value}
critical={temperatureThreshold()}
warning={Math.max(0, temperatureThreshold() - 5)}
critical={temperatureThresholds()?.critical ?? alertsActivation.getTemperatureThreshold()}
warning={temperatureThresholds()?.warning ?? Math.max(0, alertsActivation.getTemperatureThreshold() - 5)}
/>
);
})()}

View file

@ -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<ResponsiveMetricCellProps> = (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<ResponsiveMetricCellProps> = (props
label={displayLabel()}
sublabel={props.sublabel}
type={props.type}
thresholds={props.thresholds}
resourceId={props.resourceId}
/>
</div>
@ -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 (
<span class={`text-xs text-center ${colorClass()} ${props.class || ''}`}>
@ -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}
/>
);

View file

@ -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';

View file

@ -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,

View file

@ -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');
});
});

View file

@ -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<string, number> = {
cpu: 80,
memory: 85,
disk: 90,
diskRead: -1,
diskWrite: -1,
networkIn: -1,
networkOut: -1,
};
export const FACTORY_NODE_DEFAULTS: Record<string, number> = {
cpu: 80,
memory: 85,
disk: 90,
temperature: 80,
};
export const FACTORY_PBS_DEFAULTS: Record<string, number> = {
cpu: 80,
memory: 85,
};
export const FACTORY_HOST_DEFAULTS: Record<string, number> = {
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<AlertThresholdScope, Partial<Record<DisplayMetricType, number>>> = {
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<Record<DisplayMetricType, number | HysteresisThreshold>>)[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';
};