Make physical disk temperature thresholds configurable per disk type

Settings > Alerts > Systems gains a 'Disk temperature by type' editor
(NVMe/SAS/SATA trigger degC) backing the existing per-type alert
thresholds that were previously hardcoded server-side. Saving alert
settings now round-trips diskTempByType and diskFillByType instead of
silently resetting them to defaults.

Disk temperature colors in the physical disks table, pool detail linked
disks, disk detail cards, and the Machines temperature fallback now
resolve from the live alert config per disk type (warning at trigger
minus the 5 degC hysteresis margin) instead of hardcoded 50/60 cutoffs.

Closes #1540
This commit is contained in:
rcourtman
2026-07-07 23:18:15 +01:00
parent b6fd06c63d
commit cbd72d3186
23 changed files with 298 additions and 15 deletions
@@ -1,9 +1,16 @@
import { Show } from 'solid-js';
import { For, Show } from 'solid-js';
import { Card } from '@/components/shared/Card';
import { ResourceTable } from './ResourceTable';
import { formatMetricValue } from '@/features/alerts/thresholds/helpers';
import type { ThresholdsTableSectionProps } from '@/features/alerts/thresholds/thresholdsTableSectionProps';
const DISK_TEMP_TYPE_FIELDS: readonly { key: string; label: string }[] = [
{ key: 'nvme', label: 'NVMe' },
{ key: 'sas', label: 'SAS' },
{ key: 'sata', label: 'SATA' },
];
export function ThresholdsTableAgentsResourcesSection(props: ThresholdsTableSectionProps) {
const { state, tableProps } = props;
@@ -51,6 +58,47 @@ export function ThresholdsTableAgentsResourcesSection(props: ThresholdsTableSect
factoryDefaults={tableProps.factoryAgentDefaults}
onResetDefaults={tableProps.resetAgentDefaults}
/>
<Card padding="md" tone="card" class="mt-4">
<h3 class="text-sm font-semibold text-base-content">Disk temperature by type</h3>
<p class="mt-1 text-xs text-muted">
Alert trigger in °C for each disk type. Warning colors start 5°C below the trigger.
Setting a Disk Temp override on a host above replaces these for all of that host's
disks.
</p>
<div class="mt-4 grid gap-4 sm:grid-cols-3">
<For each={DISK_TEMP_TYPE_FIELDS}>
{(field) => (
<div>
<label
for={`disk-temp-by-type-${field.key}`}
class="text-xs font-medium uppercase tracking-wide text-muted"
>
{field.label} °C
</label>
<input
type="number"
min="1"
max="100"
id={`disk-temp-by-type-${field.key}`}
value={tableProps.diskTempByType[field.key] ?? ''}
onInput={(event) => {
const value = Number(event.currentTarget.value);
if (!Number.isFinite(value) || value <= 0) return;
const normalized = Math.max(1, Math.min(100, Math.round(value)));
tableProps.setDiskTempByType((prev) => ({
...prev,
[field.key]: normalized,
}));
tableProps.setHasUnsavedChanges(true);
}}
class="mt-1 w-full rounded-md border border-border bg-surface p-2 text-sm text-base-content focus:border-sky-500 focus:outline-none focus:ring-2 focus:ring-sky-200 dark:focus:border-sky-400 dark:focus:ring-sky-600"
/>
</div>
)}
</For>
</div>
</Card>
</div>
</Show>
);
@@ -191,6 +191,8 @@ const baseProps = () => ({
setVMwareDefaults: vi.fn(),
agentDefaults: { cpu: 80, memory: 85, disk: 90 },
setAgentDefaults: vi.fn(),
diskTempByType: { nvme: 70, sas: 65, sata: 55 },
setDiskTempByType: vi.fn(),
dockerDefaults: DEFAULT_DOCKER_DEFAULTS,
dockerDisableConnectivity: () => false,
setDockerDisableConnectivity: vi.fn(),
@@ -13,6 +13,7 @@ import {
TableHeader,
TableRow,
} from '@/components/shared/Table';
import { useAlertsActivation } from '@/stores/alertsActivation';
import { formatBytes } from '@/utils/format';
import { formatTemperature, getTemperatureTextClass } from '@/utils/temperature';
import {
@@ -102,6 +103,7 @@ interface DiskListProps {
}
export const DiskList: Component<DiskListProps> = (props) => {
const { getDiskTemperatureThresholds } = useAlertsActivation();
const model = useDiskListModel({
disks: () => props.disks,
nodes: () => props.nodes,
@@ -355,7 +357,7 @@ export const DiskList: Component<DiskListProps> = (props) => {
<span
class={`${PHYSICAL_DISK_TEMPERATURE_CLASS} ${getTemperatureTextClass(
data.temperature,
undefined,
getDiskTemperatureThresholds(data.type),
'diskTemperature',
)}`}
>
@@ -12,6 +12,7 @@ import {
getLinkedDiskHealthDotVariant,
getLinkedDiskTemperatureTextClass,
} from '@/features/storageBackups/diskDetailPresentation';
import { useAlertsActivation } from '@/stores/alertsActivation';
import {
STORAGE_POOL_DETAIL_HISTORY_RANGE_OPTIONS,
getZfsDeviceStateTextClass,
@@ -50,6 +51,7 @@ interface StoragePoolDetailProps {
}
export const StoragePoolDetail: Component<StoragePoolDetailProps> = (props) => {
const { getDiskTemperatureThresholds } = useAlertsActivation();
const {
chartRange,
setChartRange,
@@ -232,6 +234,7 @@ export const StoragePoolDetail: Component<StoragePoolDetailProps> = (props) => {
<span
class={`font-medium ${getLinkedDiskTemperatureTextClass(
disk.temperature,
getDiskTemperatureThresholds(disk.diskType),
)}`}
>
{disk.temperature}°C
@@ -90,6 +90,7 @@ describe('useStoragePoolDetailModel', () => {
id: 'disk-1',
devPath: '/dev/sda',
model: 'Disk A',
diskType: '',
temperature: 44,
hasIssue: false,
errorCount: 0,
@@ -8,6 +8,7 @@ import {
getDiskDetailAttributeCards,
getDiskDetailHistoryCharts,
} from '@/features/storageBackups/diskDetailPresentation';
import { useAlertsActivation } from '@/stores/alertsActivation';
import type { Resource } from '@/types/resource';
import {
resolvePhysicalDiskHistoryResourceId,
@@ -19,12 +20,15 @@ type UseDiskDetailModelOptions = {
export const useDiskDetailModel = (options: UseDiskDetailModelOptions) => {
const [chartRange, setChartRange] = createSignal<HistoryTimeRange>('24h');
const { getDiskTemperatureThresholds } = useAlertsActivation();
const diskData = createMemo<PhysicalDiskPresentationData>(() =>
extractPhysicalDiskPresentationData(options.disk()),
);
const historyResourceId = createMemo(() => resolvePhysicalDiskHistoryResourceId(options.disk()));
const attributeCards = createMemo(() => getDiskDetailAttributeCards(diskData()));
const attributeCards = createMemo(() =>
getDiskDetailAttributeCards(diskData(), getDiskTemperatureThresholds(diskData().type)),
);
const historyCharts = createMemo(() => getDiskDetailHistoryCharts(diskData()));
const metricResourceId = createMemo(() => historyResourceId());
@@ -105,6 +105,8 @@ export function AlertsConfigurationSurface(props: AlertsConfigurationSurfaceProp
setNodeDefaults={state.setNodeDefaults}
agentDefaults={state.agentDefaults}
setAgentDefaults={state.setAgentDefaults}
diskTempByType={state.diskTempByType}
setDiskTempByType={state.setDiskTempByType}
pbsDefaults={state.pbsDefaults}
setPBSDefaults={state.setPBSDefaults}
kubernetesDefaults={state.kubernetesDefaults}
@@ -2,11 +2,13 @@ import type {
AlertConfig,
ActivationState,
BackupAlertConfig,
HysteresisThreshold,
SnapshotAlertConfig,
} from '@/types/alerts';
import {
FACTORY_AGENT_DEFAULTS,
FACTORY_BACKUP_DEFAULTS,
FACTORY_DISK_TEMP_BY_TYPE,
FACTORY_DOCKER_DEFAULTS,
FACTORY_DOCKER_STATE_DISABLE_CONNECTIVITY,
FACTORY_DOCKER_STATE_SEVERITY,
@@ -45,6 +47,7 @@ import { GROUPING_WINDOW_DEFAULT_SECONDS, clampCooldownMinutes } from './types';
export {
FACTORY_AGENT_DEFAULTS,
FACTORY_BACKUP_DEFAULTS,
FACTORY_DISK_TEMP_BY_TYPE,
FACTORY_DOCKER_DEFAULTS,
FACTORY_DOCKER_STATE_DISABLE_CONNECTIVITY,
FACTORY_DOCKER_STATE_SEVERITY,
@@ -75,6 +78,8 @@ export interface AlertsConfigurationSnapshot {
trueNASDiskDefaults: Record<string, number | undefined>;
vmwareDefaults: Record<string, number | undefined>;
agentDefaults: Record<string, number | undefined>;
diskTempByType: Record<string, number>;
diskFillByType: Record<string, HysteresisThreshold>;
dockerDefaults: typeof FACTORY_DOCKER_DEFAULTS;
dockerDisableConnectivity: boolean;
dockerPoweredOffSeverity: 'warning' | 'critical';
@@ -216,6 +221,8 @@ export function createDefaultAlertsConfigurationSnapshot(): AlertsConfigurationS
trueNASDiskDefaults: { ...FACTORY_TRUENAS_DISK_DEFAULTS },
vmwareDefaults: { ...FACTORY_VMWARE_DEFAULTS },
agentDefaults: { ...FACTORY_AGENT_DEFAULTS },
diskTempByType: { ...FACTORY_DISK_TEMP_BY_TYPE },
diskFillByType: {},
dockerDefaults: { ...FACTORY_DOCKER_DEFAULTS },
dockerDisableConnectivity: FACTORY_DOCKER_STATE_DISABLE_CONNECTIVITY,
dockerPoweredOffSeverity: FACTORY_DOCKER_STATE_SEVERITY,
@@ -398,6 +405,22 @@ export function readAlertsConfigurationSnapshot(config: AlertConfig): AlertsConf
};
}
if (config.diskTempByType) {
const diskTempByType: Record<string, number> = { ...FACTORY_DISK_TEMP_BY_TYPE };
Object.entries(config.diskTempByType).forEach(([key, value]) => {
const normalizedKey = key.trim().toLowerCase();
const trigger = getTriggerValue(value);
if (normalizedKey && trigger > 0) {
diskTempByType[normalizedKey] = trigger;
}
});
snapshot.diskTempByType = diskTempByType;
}
if (config.diskFillByType) {
snapshot.diskFillByType = { ...config.diskFillByType };
}
if (config.dockerDefaults) {
const serviceWarnGap = normalizeGap(
config.dockerDefaults.serviceWarnGapPercent,
@@ -695,6 +718,13 @@ export function buildAlertsConfigurationPayload({
disk: createHysteresisThreshold(snapshot.agentDefaults.disk),
diskTemperature: createHysteresisThreshold(snapshot.agentDefaults.diskTemperature),
},
diskTempByType: Object.fromEntries(
Object.entries(snapshot.diskTempByType).map(([key, trigger]) => [
key,
createHysteresisThreshold(trigger),
]),
),
diskFillByType: { ...snapshot.diskFillByType },
pbsDefaults: {
cpu: createHysteresisThreshold(snapshot.pbsDefaults.cpu),
memory: createHysteresisThreshold(snapshot.pbsDefaults.memory),
@@ -33,6 +33,8 @@ export function ThresholdsTab(props: ThresholdsTabProps) {
trueNASDiskDefaults={props.trueNASDiskDefaults()}
vmwareDefaults={props.vmwareDefaults()}
agentDefaults={props.agentDefaults()}
diskTempByType={props.diskTempByType()}
setDiskTempByType={props.setDiskTempByType}
setNodeDefaults={props.setNodeDefaults}
setPBSDefaults={props.setPBSDefaults}
setKubernetesDefaults={props.setKubernetesDefaults}
@@ -12,6 +12,7 @@ export interface ThresholdsTabProps extends Omit<
| 'trueNASDiskDefaults'
| 'vmwareDefaults'
| 'agentDefaults'
| 'diskTempByType'
| 'dockerDefaults'
> {
guestDefaults: Accessor<ThresholdsTableProps['guestDefaults']>;
@@ -22,5 +23,6 @@ export interface ThresholdsTabProps extends Omit<
trueNASDiskDefaults: Accessor<NonNullable<ThresholdsTableProps['trueNASDiskDefaults']>>;
vmwareDefaults: Accessor<NonNullable<ThresholdsTableProps['vmwareDefaults']>>;
agentDefaults: Accessor<ThresholdsTableProps['agentDefaults']>;
diskTempByType: Accessor<ThresholdsTableProps['diskTempByType']>;
dockerDefaults: Accessor<ThresholdsTableProps['dockerDefaults']>;
}
@@ -142,6 +142,10 @@ export interface ThresholdsTableProps {
| Record<string, number | undefined>
| ((prev: Record<string, number | undefined>) => Record<string, number | undefined>),
) => void;
diskTempByType: Record<string, number>;
setDiskTempByType: (
value: Record<string, number> | ((prev: Record<string, number>) => Record<string, number>),
) => void;
dockerDefaults: {
cpu: number;
memory: number;
@@ -1,11 +1,12 @@
import { createSignal } from 'solid-js';
import type { BackupAlertConfig, SnapshotAlertConfig } from '@/types/alerts';
import type { BackupAlertConfig, HysteresisThreshold, SnapshotAlertConfig } from '@/types/alerts';
import {
createDefaultAlertsConfigurationSnapshot,
FACTORY_AGENT_DEFAULTS,
FACTORY_BACKUP_DEFAULTS,
FACTORY_DISK_TEMP_BY_TYPE,
FACTORY_DOCKER_DEFAULTS,
FACTORY_DOCKER_STATE_DISABLE_CONNECTIVITY,
FACTORY_DOCKER_STATE_SEVERITY,
@@ -75,6 +76,13 @@ export function useAlertsConfigurationSnapshotState(
const [agentDefaults, setAgentDefaults] = createSignal<Record<string, number | undefined>>(
defaultSnapshot.agentDefaults,
);
const [diskTempByType, setDiskTempByType] = createSignal<Record<string, number>>(
defaultSnapshot.diskTempByType,
);
// Pass-through only: preserved on save so the backend doesn't reset it.
const [diskFillByType, setDiskFillByType] = createSignal<Record<string, HysteresisThreshold>>(
defaultSnapshot.diskFillByType,
);
const [dockerDefaults, setDockerDefaults] = createSignal(defaultSnapshot.dockerDefaults);
const [dockerDisableConnectivity, setDockerDisableConnectivity] = createSignal(
defaultSnapshot.dockerDisableConnectivity,
@@ -168,6 +176,8 @@ export function useAlertsConfigurationSnapshotState(
setTrueNASDiskDefaults({ ...snapshot.trueNASDiskDefaults });
setVMwareDefaults({ ...snapshot.vmwareDefaults });
setAgentDefaults({ ...snapshot.agentDefaults });
setDiskTempByType({ ...snapshot.diskTempByType });
setDiskFillByType({ ...snapshot.diskFillByType });
setDockerDefaults({ ...snapshot.dockerDefaults });
setDockerDisableConnectivity(snapshot.dockerDisableConnectivity);
setDockerPoweredOffSeverity(snapshot.dockerPoweredOffSeverity);
@@ -227,6 +237,8 @@ export function useAlertsConfigurationSnapshotState(
trueNASDiskDefaults: { ...trueNASDiskDefaults() },
vmwareDefaults: { ...vmwareDefaults() },
agentDefaults: { ...agentDefaults() },
diskTempByType: { ...diskTempByType() },
diskFillByType: { ...diskFillByType() },
dockerDefaults: { ...dockerDefaults() },
dockerDisableConnectivity: dockerDisableConnectivity(),
dockerPoweredOffSeverity: dockerPoweredOffSeverity(),
@@ -297,6 +309,7 @@ export function useAlertsConfigurationSnapshotState(
};
const resetAgentDefaults = () => {
setAgentDefaults({ ...FACTORY_AGENT_DEFAULTS });
setDiskTempByType({ ...FACTORY_DISK_TEMP_BY_TYPE });
markUnsaved();
};
const resetDockerDefaults = () => {
@@ -353,6 +366,10 @@ export function useAlertsConfigurationSnapshotState(
setVMwareDefaults,
agentDefaults,
setAgentDefaults,
diskTempByType,
setDiskTempByType,
diskFillByType,
setDiskFillByType,
dockerDefaults,
setDockerDefaults,
dockerDisableConnectivity,
@@ -438,6 +455,7 @@ export function useAlertsConfigurationSnapshotState(
factoryTrueNASDiskDefaults: FACTORY_TRUENAS_DISK_DEFAULTS,
factoryVMwareDefaults: FACTORY_VMWARE_DEFAULTS,
factoryAgentDefaults: FACTORY_AGENT_DEFAULTS,
factoryDiskTempByType: FACTORY_DISK_TEMP_BY_TYPE,
factoryDockerDefaults: FACTORY_DOCKER_DEFAULTS,
factoryStorageDefault: FACTORY_STORAGE_DEFAULT,
snapshotFactoryDefaults: FACTORY_SNAPSHOT_DEFAULTS,
@@ -89,6 +89,7 @@ import {
getAgentMachineRaidSummary,
getAgentMachineTemperatureCelsius,
getAgentMachineTemperatureDetailSections,
getAgentMachineHottestSmartDiskType,
getAgentMachineTemperatureMetric,
getAgentMachineTemperatureTitle,
getAgentMachineThermalPressurePresentation,
@@ -1488,11 +1489,16 @@ export const AgentsMachinesTable: Component<{
const temperature = () => getAgentMachineTemperatureCelsius(machine);
const temperatureMetric = () => getAgentMachineTemperatureMetric(machine);
const temperatureThresholds = () =>
alertsActivation.getMetricThresholds(
temperatureMetric() === 'diskTemperature' ? 'agent' : 'node',
temperatureMetric(),
alertResourceIds(),
);
temperatureMetric() === 'diskTemperature'
? alertsActivation.getDiskTemperatureThresholds(
getAgentMachineHottestSmartDiskType(machine),
alertResourceIds(),
)
: alertsActivation.getMetricThresholds(
'node',
temperatureMetric(),
alertResourceIds(),
);
const temperatureSections = () =>
getAgentMachineTemperatureDetailSections(machine);
const temperatureTitle = () => getAgentMachineTemperatureTitle(machine);
@@ -122,6 +122,7 @@ type TemperatureReading = {
type SmartTemperatureReading = TemperatureReading & {
standby?: boolean;
diskType?: string;
};
export type AgentMachineTemperatureDetailRow = {
@@ -220,6 +221,7 @@ const getSmartTemperatureReadings = (machine: Resource): SmartTemperatureReading
label: model ? `${device} ${model}` : device,
value: temperature ?? 0,
standby: disk.standby,
diskType: asTrimmedString(disk.type),
});
return readings;
}, []);
@@ -460,6 +462,17 @@ export const getAgentMachineTemperatureCelsius = (machine: Resource): number | u
);
};
// Disk type of the hottest active SMART reading — the disk whose temperature
// the cell displays when it falls back to the diskTemperature metric.
export const getAgentMachineHottestSmartDiskType = (machine: Resource): string | undefined => {
const readings = getSmartTemperatureReadings(machine).filter(
(reading) => !reading.standby && reading.value > 0,
);
if (readings.length === 0) return undefined;
return readings.reduce((worst, reading) => (reading.value > worst.value ? reading : worst))
.diskType;
};
export const getAgentMachineTemperatureMetric = (
machine: Resource,
): AgentMachineTemperatureMetric => {
@@ -20,8 +20,18 @@ describe('diskDetailPresentation', () => {
it('returns canonical linked-disk state presentation', () => {
expect(getLinkedDiskHealthDotVariant(true)).toBe('warning');
expect(getLinkedDiskHealthDotVariant(false)).toBe('success');
// Default agent thresholds (warning 50, critical 55).
expect(getLinkedDiskTemperatureTextClass(65)).toBe('text-red-500');
expect(getLinkedDiskTemperatureTextClass(55)).toBe('text-yellow-500');
expect(getLinkedDiskTemperatureTextClass(52)).toBe('text-yellow-500');
expect(getLinkedDiskTemperatureTextClass(45)).toBe('text-muted');
// Explicit thresholds (e.g. resolved for an NVMe disk) shift the colors.
expect(getLinkedDiskTemperatureTextClass(55, { warning: 65, critical: 70 })).toBe('text-muted');
expect(getLinkedDiskTemperatureTextClass(67, { warning: 65, critical: 70 })).toBe(
'text-yellow-500',
);
expect(getLinkedDiskTemperatureTextClass(71, { warning: 65, critical: 70 })).toBe(
'text-red-500',
);
});
it('builds canonical SATA and NVMe attribute cards', () => {
@@ -80,7 +90,7 @@ describe('diskDetailPresentation', () => {
mediaErrors: 0,
unsafeShutdowns: 4,
},
}),
}, { warning: 65, critical: 70 }),
).toEqual(
expect.arrayContaining([
{ label: 'Temperature', value: '55°C', ok: true },
@@ -143,6 +143,7 @@ describe('storagePoolDetailPresentation', () => {
role: 'data',
state: 'online',
sizeLabel: '1000 B',
diskType: '',
temperature: 44,
hasIssue: true,
spunDown: false,
@@ -1,6 +1,7 @@
import type { PhysicalDiskPresentationData } from '@/features/storageBackups/diskPresentation';
import type { HistoryTimeRange } from '@/api/charts';
import { formatPowerOnHours } from '@/utils/format';
import { getMetricSeverity, type MetricDisplayThresholds } from '@/utils/metricThresholds';
import { formatTemperature } from '@/utils/temperature';
import type { StatusIndicatorVariant } from '@/utils/status';
@@ -12,14 +13,18 @@ export function getLinkedDiskHealthDotVariant(hasIssue: boolean): StatusIndicato
return hasIssue ? 'warning' : 'success';
}
export function getLinkedDiskTemperatureTextClass(tempCelsius: number): string {
export function getLinkedDiskTemperatureTextClass(
tempCelsius: number,
thresholds?: MetricDisplayThresholds | null,
): string {
if (!Number.isFinite(tempCelsius) || tempCelsius <= 0) {
return 'text-muted';
}
if (tempCelsius > 60) {
const severity = getMetricSeverity(tempCelsius, 'diskTemperature', thresholds);
if (severity === 'critical') {
return 'text-red-500';
}
if (tempCelsius > 50) {
if (severity === 'warning') {
return 'text-yellow-500';
}
return 'text-muted';
@@ -74,6 +79,7 @@ export const getDiskDetailHistoryFallbackMessage = (): string =>
export function getDiskDetailAttributeCards(
disk: PhysicalDiskPresentationData,
diskTempThresholds?: MetricDisplayThresholds | null,
): DiskDetailAttributeCard[] {
const attrs = disk.smartAttributes;
if (!attrs) return [];
@@ -93,7 +99,7 @@ export function getDiskDetailAttributeCards(
cards.push({
label: 'Temperature',
value: formatTemperature(disk.temperature),
ok: disk.temperature <= 60,
ok: getMetricSeverity(disk.temperature, 'diskTemperature', diskTempThresholds) !== 'critical',
});
}
@@ -26,6 +26,7 @@ export type StoragePoolDetailLinkedDisk = {
role: string;
state: string;
sizeLabel: string;
diskType: string;
temperature: number;
hasIssue: boolean;
spunDown: boolean;
@@ -279,6 +280,11 @@ const readDiskTemperature = (disk: Resource): number => {
return typeof physicalDisk.temperature === 'number' ? physicalDisk.temperature : 0;
};
const readDiskType = (disk: Resource): string => {
const physicalDisk = readPhysicalDisk(disk);
return typeof physicalDisk.diskType === 'string' ? physicalDisk.diskType.trim() : '';
};
const readDiskHasIssue = (disk: Resource): boolean => {
const physicalDisk = readPhysicalDisk(disk);
const smart = physicalDisk.smart as Record<string, unknown> | undefined;
@@ -364,6 +370,7 @@ export function getStoragePoolLinkedDisks(
role: readDiskRole(disk),
state: readDiskState(disk),
sizeLabel: readDiskSizeLabel(disk),
diskType: readDiskType(disk),
temperature: readDiskTemperature(disk),
hasIssue: readDiskHasIssue(disk),
spunDown: readDiskSpunDown(disk),
@@ -8,6 +8,7 @@ import { logger } from '@/utils/logger';
import {
type AlertThresholdScope,
type DisplayMetricType,
resolveDiskTemperatureDisplayThresholds,
resolveMetricDisplayThresholds,
} from '@/utils/metricThresholds';
import { eventBus } from './events';
@@ -147,6 +148,14 @@ const getMetricThresholds = (
return resolveMetricDisplayThresholds(config(), scope, metric, resourceIds);
};
// Per-type disk SMART temperature thresholds (for display coloring).
const getDiskTemperatureThresholds = (
diskType: string | null | undefined,
resourceIds?: string | string[],
) => {
return resolveDiskTemperatureDisplayThresholds(config(), diskType, resourceIds);
};
eventBus.on('org_switched', () => {
setConfig(null);
applyActivationState(null);
@@ -169,6 +178,7 @@ export const useAlertsActivation = () => ({
getBackupThresholds,
getTemperatureThreshold,
getMetricThresholds,
getDiskTemperatureThresholds,
// Actions
refreshConfig,
+2
View File
@@ -138,6 +138,8 @@ export interface AlertConfig {
vmwareDefaults?: AlertThresholds;
snapshotDefaults?: SnapshotAlertConfig;
backupDefaults?: BackupAlertConfig;
diskFillByType?: Record<string, HysteresisThreshold>;
diskTempByType?: Record<string, HysteresisThreshold>;
customRules?: CustomAlertRule[];
overrides: Record<string, RawOverrideConfig>; // key: resource ID
minimumDelta?: number;
@@ -7,6 +7,7 @@ import {
getMetricTextColorClass,
getDefaultDisplayMetricThresholds,
getDefaultMetricDisplayThresholds,
resolveDiskTemperatureDisplayThresholds,
resolveMetricDisplayThresholds,
METRIC_THRESHOLDS,
type MetricType,
@@ -263,6 +264,71 @@ describe('metricThresholds', () => {
});
});
it('resolves disk temperature display thresholds per disk type', () => {
const config = {
enabled: true,
guestDefaults: {},
nodeDefaults: {},
agentDefaults: {
diskTemperature: { trigger: 55, clear: 50 },
},
diskTempByType: {
nvme: { trigger: 72, clear: 66 },
sas: { trigger: 65, clear: 60 },
sata: { trigger: 55, clear: 50 },
},
storageDefault: { trigger: 85, clear: 80 },
overrides: {
'host-1': {
diskTemperature: { trigger: 80, clear: 75 },
},
},
} as AlertConfig;
// Per-type map wins over the global agent default.
expect(resolveDiskTemperatureDisplayThresholds(config, 'nvme')).toEqual({
warning: 66,
critical: 72,
});
expect(resolveDiskTemperatureDisplayThresholds(config, 'NVMe')).toEqual({
warning: 66,
critical: 72,
});
// Unknown types fall back to the global agent default.
expect(resolveDiskTemperatureDisplayThresholds(config, 'scsi')).toEqual({
warning: 50,
critical: 55,
});
expect(resolveDiskTemperatureDisplayThresholds(config, undefined)).toEqual({
warning: 50,
critical: 55,
});
// An explicit host override beats the per-type map, mirroring the backend.
expect(resolveDiskTemperatureDisplayThresholds(config, 'nvme', 'host-1')).toEqual({
warning: 75,
critical: 80,
});
});
it('falls back to seeded per-type disk temperature defaults without config', () => {
expect(resolveDiskTemperatureDisplayThresholds(null, 'nvme')).toEqual({
warning: 65,
critical: 70,
});
expect(resolveDiskTemperatureDisplayThresholds(null, 'sas')).toEqual({
warning: 60,
critical: 65,
});
expect(resolveDiskTemperatureDisplayThresholds(null, 'sata')).toEqual({
warning: 50,
critical: 55,
});
expect(resolveDiskTemperatureDisplayThresholds(null, '')).toEqual({
warning: 50,
critical: 55,
});
});
it('resolves node temperature display thresholds from configured alert defaults', () => {
const config = {
enabled: true,
@@ -66,6 +66,13 @@ export const FACTORY_AGENT_DEFAULTS = {
diskTemperature: 55,
};
// Mirrors the backend's seeded DiskTempByType defaults (trigger °C).
export const FACTORY_DISK_TEMP_BY_TYPE: Record<string, number> = {
nvme: 70,
sas: 65,
sata: 55,
};
export const FACTORY_DOCKER_DEFAULTS = {
cpu: 80,
memory: 85,
@@ -15,6 +15,7 @@ import type {
} from '@/types/alerts';
import {
FACTORY_AGENT_DEFAULTS,
FACTORY_DISK_TEMP_BY_TYPE,
FACTORY_DOCKER_DEFAULTS,
FACTORY_GUEST_DEFAULTS,
FACTORY_NODE_DEFAULTS,
@@ -251,6 +252,42 @@ export const resolveMetricDisplayThresholds = (
return resolveThreshold(overrideValue ?? baseValue, getFallbackCritical(scope, metric), margin);
};
/**
* Resolve display thresholds for a physical disk's SMART temperature.
* Mirrors the backend precedence: an explicit diskTemperature override on the
* host (or inherited linked resource) wins, then the per-type map
* (diskTempByType: nvme/sas/sata), then the global agent default.
*/
export const resolveDiskTemperatureDisplayThresholds = (
config: AlertConfig | null,
diskType: string | null | undefined,
resourceIds?: string | string[],
): MetricDisplayThresholds | null => {
const margin = normalizeMargin(config?.hysteresisMargin);
const normalizedType = (diskType ?? '').trim().toLowerCase();
const override = findOverride(config?.overrides, resourceIds);
const overrideValue = getOverrideValue(override, 'diskTemperature');
if (overrideValue !== undefined) {
return resolveThreshold(overrideValue, FACTORY_AGENT_DEFAULTS.diskTemperature, margin);
}
const byTypeFallback = normalizedType ? FACTORY_DISK_TEMP_BY_TYPE[normalizedType] : undefined;
if (normalizedType) {
const byType = config?.diskTempByType?.[normalizedType];
if (isHysteresisThreshold(byType)) {
return resolveThreshold(byType, byTypeFallback, margin);
}
}
const baseValue = getBaseThresholdValue(config?.agentDefaults, 'diskTemperature');
return resolveThreshold(
baseValue,
byTypeFallback ?? FACTORY_AGENT_DEFAULTS.diskTemperature,
margin,
);
};
const getFallbackSeverityThresholds = (metric: DisplayMetricType): MetricDisplayThresholds => {
if (metric === 'cpu' || metric === 'memory' || metric === 'disk') {
return METRIC_THRESHOLDS[metric];