mirror of
https://github.com/rcourtman/Pulse.git
synced 2026-04-28 03:20:11 +00:00
Respect configured usage thresholds in metric coloring (#1358)
This commit is contained in:
parent
f9bf42498f
commit
7422de8505
16 changed files with 524 additions and 124 deletions
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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',
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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 [];
|
||||
|
|
|
|||
|
|
@ -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) {
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -48,6 +48,7 @@ vi.mock('@/hooks/useColumnVisibility', () => ({
|
|||
vi.mock('@/stores/alertsActivation', () => ({
|
||||
useAlertsActivation: () => ({
|
||||
getTemperatureThreshold: () => 80,
|
||||
getMetricThresholds: () => ({ warning: 75, critical: 80 }),
|
||||
}),
|
||||
}));
|
||||
|
||||
|
|
|
|||
|
|
@ -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)}
|
||||
/>
|
||||
);
|
||||
})()}
|
||||
|
|
|
|||
|
|
@ -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}
|
||||
/>
|
||||
);
|
||||
|
|
|
|||
|
|
@ -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';
|
||||
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
78
frontend-modern/src/utils/__tests__/alertThresholds.test.ts
Normal file
78
frontend-modern/src/utils/__tests__/alertThresholds.test.ts
Normal 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');
|
||||
});
|
||||
});
|
||||
223
frontend-modern/src/utils/alertThresholds.ts
Normal file
223
frontend-modern/src/utils/alertThresholds.ts
Normal 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';
|
||||
};
|
||||
Loading…
Add table
Add a link
Reference in a new issue