From cbd72d318668faf8b5fa11b91357cdb273eabbc9 Mon Sep 17 00:00:00 2001 From: rcourtman Date: Tue, 7 Jul 2026 23:18:15 +0100 Subject: [PATCH] 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 --- .../ThresholdsTableAgentsResourcesSection.tsx | 50 +++++++++++++- .../Alerts/__tests__/ThresholdsTable.test.tsx | 2 + .../src/components/Storage/DiskList.tsx | 4 +- .../components/Storage/StoragePoolDetail.tsx | 3 + .../useStoragePoolDetailModel.test.ts | 1 + .../components/Storage/useDiskDetailModel.ts | 6 +- .../alerts/AlertsConfigurationSurface.tsx | 2 + .../alerts/alertsConfigurationModel.ts | 30 +++++++++ .../features/alerts/tabs/ThresholdsTab.tsx | 2 + .../alerts/thresholds/thresholdsTabModel.ts | 2 + .../src/features/alerts/thresholds/types.ts | 4 ++ .../useAlertsConfigurationSnapshotState.ts | 20 +++++- .../standalone/AgentsMachinesTable.tsx | 16 +++-- .../standalone/agentMachineTableModel.ts | 13 ++++ .../__tests__/diskDetailPresentation.test.ts | 14 +++- .../storagePoolDetailPresentation.test.ts | 1 + .../storageBackups/diskDetailPresentation.ts | 14 ++-- .../storagePoolDetailPresentation.ts | 7 ++ .../src/stores/alertsActivation.ts | 10 +++ frontend-modern/src/types/alerts.ts | 2 + .../utils/__tests__/metricThresholds.test.ts | 66 +++++++++++++++++++ .../src/utils/alertThresholdDefaults.ts | 7 ++ frontend-modern/src/utils/metricThresholds.ts | 37 +++++++++++ 23 files changed, 298 insertions(+), 15 deletions(-) diff --git a/frontend-modern/src/components/Alerts/ThresholdsTableAgentsResourcesSection.tsx b/frontend-modern/src/components/Alerts/ThresholdsTableAgentsResourcesSection.tsx index df6c44a23..c1444f699 100644 --- a/frontend-modern/src/components/Alerts/ThresholdsTableAgentsResourcesSection.tsx +++ b/frontend-modern/src/components/Alerts/ThresholdsTableAgentsResourcesSection.tsx @@ -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} /> + + +

Disk temperature by type

+

+ 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. +

+
+ + {(field) => ( +
+ + { + 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" + /> +
+ )} +
+
+
); diff --git a/frontend-modern/src/components/Alerts/__tests__/ThresholdsTable.test.tsx b/frontend-modern/src/components/Alerts/__tests__/ThresholdsTable.test.tsx index a7c3da149..b00f5b854 100644 --- a/frontend-modern/src/components/Alerts/__tests__/ThresholdsTable.test.tsx +++ b/frontend-modern/src/components/Alerts/__tests__/ThresholdsTable.test.tsx @@ -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(), diff --git a/frontend-modern/src/components/Storage/DiskList.tsx b/frontend-modern/src/components/Storage/DiskList.tsx index 2ca148e4d..a27227f7c 100644 --- a/frontend-modern/src/components/Storage/DiskList.tsx +++ b/frontend-modern/src/components/Storage/DiskList.tsx @@ -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 = (props) => { + const { getDiskTemperatureThresholds } = useAlertsActivation(); const model = useDiskListModel({ disks: () => props.disks, nodes: () => props.nodes, @@ -355,7 +357,7 @@ export const DiskList: Component = (props) => { diff --git a/frontend-modern/src/components/Storage/StoragePoolDetail.tsx b/frontend-modern/src/components/Storage/StoragePoolDetail.tsx index d0f368d1b..78a4e6270 100644 --- a/frontend-modern/src/components/Storage/StoragePoolDetail.tsx +++ b/frontend-modern/src/components/Storage/StoragePoolDetail.tsx @@ -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 = (props) => { + const { getDiskTemperatureThresholds } = useAlertsActivation(); const { chartRange, setChartRange, @@ -232,6 +234,7 @@ export const StoragePoolDetail: Component = (props) => { {disk.temperature}°C diff --git a/frontend-modern/src/components/Storage/__tests__/useStoragePoolDetailModel.test.ts b/frontend-modern/src/components/Storage/__tests__/useStoragePoolDetailModel.test.ts index f239e85c0..8409a99bd 100644 --- a/frontend-modern/src/components/Storage/__tests__/useStoragePoolDetailModel.test.ts +++ b/frontend-modern/src/components/Storage/__tests__/useStoragePoolDetailModel.test.ts @@ -90,6 +90,7 @@ describe('useStoragePoolDetailModel', () => { id: 'disk-1', devPath: '/dev/sda', model: 'Disk A', + diskType: '', temperature: 44, hasIssue: false, errorCount: 0, diff --git a/frontend-modern/src/components/Storage/useDiskDetailModel.ts b/frontend-modern/src/components/Storage/useDiskDetailModel.ts index e121278a8..f4d3d6cfb 100644 --- a/frontend-modern/src/components/Storage/useDiskDetailModel.ts +++ b/frontend-modern/src/components/Storage/useDiskDetailModel.ts @@ -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('24h'); + const { getDiskTemperatureThresholds } = useAlertsActivation(); const diskData = createMemo(() => 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()); diff --git a/frontend-modern/src/features/alerts/AlertsConfigurationSurface.tsx b/frontend-modern/src/features/alerts/AlertsConfigurationSurface.tsx index 294988d39..6623b84d8 100644 --- a/frontend-modern/src/features/alerts/AlertsConfigurationSurface.tsx +++ b/frontend-modern/src/features/alerts/AlertsConfigurationSurface.tsx @@ -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} diff --git a/frontend-modern/src/features/alerts/alertsConfigurationModel.ts b/frontend-modern/src/features/alerts/alertsConfigurationModel.ts index 3a7f540f2..26b8f3345 100644 --- a/frontend-modern/src/features/alerts/alertsConfigurationModel.ts +++ b/frontend-modern/src/features/alerts/alertsConfigurationModel.ts @@ -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; vmwareDefaults: Record; agentDefaults: Record; + diskTempByType: Record; + diskFillByType: Record; 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 = { ...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), diff --git a/frontend-modern/src/features/alerts/tabs/ThresholdsTab.tsx b/frontend-modern/src/features/alerts/tabs/ThresholdsTab.tsx index f440e023b..807d655cb 100644 --- a/frontend-modern/src/features/alerts/tabs/ThresholdsTab.tsx +++ b/frontend-modern/src/features/alerts/tabs/ThresholdsTab.tsx @@ -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} diff --git a/frontend-modern/src/features/alerts/thresholds/thresholdsTabModel.ts b/frontend-modern/src/features/alerts/thresholds/thresholdsTabModel.ts index 9440fd7d6..b45583073 100644 --- a/frontend-modern/src/features/alerts/thresholds/thresholdsTabModel.ts +++ b/frontend-modern/src/features/alerts/thresholds/thresholdsTabModel.ts @@ -12,6 +12,7 @@ export interface ThresholdsTabProps extends Omit< | 'trueNASDiskDefaults' | 'vmwareDefaults' | 'agentDefaults' + | 'diskTempByType' | 'dockerDefaults' > { guestDefaults: Accessor; @@ -22,5 +23,6 @@ export interface ThresholdsTabProps extends Omit< trueNASDiskDefaults: Accessor>; vmwareDefaults: Accessor>; agentDefaults: Accessor; + diskTempByType: Accessor; dockerDefaults: Accessor; } diff --git a/frontend-modern/src/features/alerts/thresholds/types.ts b/frontend-modern/src/features/alerts/thresholds/types.ts index 358f0d703..b3da06d3d 100644 --- a/frontend-modern/src/features/alerts/thresholds/types.ts +++ b/frontend-modern/src/features/alerts/thresholds/types.ts @@ -142,6 +142,10 @@ export interface ThresholdsTableProps { | Record | ((prev: Record) => Record), ) => void; + diskTempByType: Record; + setDiskTempByType: ( + value: Record | ((prev: Record) => Record), + ) => void; dockerDefaults: { cpu: number; memory: number; diff --git a/frontend-modern/src/features/alerts/useAlertsConfigurationSnapshotState.ts b/frontend-modern/src/features/alerts/useAlertsConfigurationSnapshotState.ts index 2d78ba81f..40a20650e 100644 --- a/frontend-modern/src/features/alerts/useAlertsConfigurationSnapshotState.ts +++ b/frontend-modern/src/features/alerts/useAlertsConfigurationSnapshotState.ts @@ -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>( defaultSnapshot.agentDefaults, ); + const [diskTempByType, setDiskTempByType] = createSignal>( + defaultSnapshot.diskTempByType, + ); + // Pass-through only: preserved on save so the backend doesn't reset it. + const [diskFillByType, setDiskFillByType] = createSignal>( + 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, diff --git a/frontend-modern/src/features/standalone/AgentsMachinesTable.tsx b/frontend-modern/src/features/standalone/AgentsMachinesTable.tsx index a8defb099..da74591b8 100644 --- a/frontend-modern/src/features/standalone/AgentsMachinesTable.tsx +++ b/frontend-modern/src/features/standalone/AgentsMachinesTable.tsx @@ -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); diff --git a/frontend-modern/src/features/standalone/agentMachineTableModel.ts b/frontend-modern/src/features/standalone/agentMachineTableModel.ts index 944219095..01033dcdb 100644 --- a/frontend-modern/src/features/standalone/agentMachineTableModel.ts +++ b/frontend-modern/src/features/standalone/agentMachineTableModel.ts @@ -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 => { diff --git a/frontend-modern/src/features/storageBackups/__tests__/diskDetailPresentation.test.ts b/frontend-modern/src/features/storageBackups/__tests__/diskDetailPresentation.test.ts index 828c0de7e..5d4a1fb9f 100644 --- a/frontend-modern/src/features/storageBackups/__tests__/diskDetailPresentation.test.ts +++ b/frontend-modern/src/features/storageBackups/__tests__/diskDetailPresentation.test.ts @@ -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 }, diff --git a/frontend-modern/src/features/storageBackups/__tests__/storagePoolDetailPresentation.test.ts b/frontend-modern/src/features/storageBackups/__tests__/storagePoolDetailPresentation.test.ts index 4d0f9525d..3bd84e13f 100644 --- a/frontend-modern/src/features/storageBackups/__tests__/storagePoolDetailPresentation.test.ts +++ b/frontend-modern/src/features/storageBackups/__tests__/storagePoolDetailPresentation.test.ts @@ -143,6 +143,7 @@ describe('storagePoolDetailPresentation', () => { role: 'data', state: 'online', sizeLabel: '1000 B', + diskType: '', temperature: 44, hasIssue: true, spunDown: false, diff --git a/frontend-modern/src/features/storageBackups/diskDetailPresentation.ts b/frontend-modern/src/features/storageBackups/diskDetailPresentation.ts index 6990856e7..6e2282e75 100644 --- a/frontend-modern/src/features/storageBackups/diskDetailPresentation.ts +++ b/frontend-modern/src/features/storageBackups/diskDetailPresentation.ts @@ -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', }); } diff --git a/frontend-modern/src/features/storageBackups/storagePoolDetailPresentation.ts b/frontend-modern/src/features/storageBackups/storagePoolDetailPresentation.ts index f7c045388..6034a798a 100644 --- a/frontend-modern/src/features/storageBackups/storagePoolDetailPresentation.ts +++ b/frontend-modern/src/features/storageBackups/storagePoolDetailPresentation.ts @@ -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 | 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), diff --git a/frontend-modern/src/stores/alertsActivation.ts b/frontend-modern/src/stores/alertsActivation.ts index 74888fcbf..dcf22fb72 100644 --- a/frontend-modern/src/stores/alertsActivation.ts +++ b/frontend-modern/src/stores/alertsActivation.ts @@ -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, diff --git a/frontend-modern/src/types/alerts.ts b/frontend-modern/src/types/alerts.ts index 3627f41c2..a0460eec9 100644 --- a/frontend-modern/src/types/alerts.ts +++ b/frontend-modern/src/types/alerts.ts @@ -138,6 +138,8 @@ export interface AlertConfig { vmwareDefaults?: AlertThresholds; snapshotDefaults?: SnapshotAlertConfig; backupDefaults?: BackupAlertConfig; + diskFillByType?: Record; + diskTempByType?: Record; customRules?: CustomAlertRule[]; overrides: Record; // key: resource ID minimumDelta?: number; diff --git a/frontend-modern/src/utils/__tests__/metricThresholds.test.ts b/frontend-modern/src/utils/__tests__/metricThresholds.test.ts index 7d2830bd2..504a3e83a 100644 --- a/frontend-modern/src/utils/__tests__/metricThresholds.test.ts +++ b/frontend-modern/src/utils/__tests__/metricThresholds.test.ts @@ -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, diff --git a/frontend-modern/src/utils/alertThresholdDefaults.ts b/frontend-modern/src/utils/alertThresholdDefaults.ts index 2c25815ee..9558b26be 100644 --- a/frontend-modern/src/utils/alertThresholdDefaults.ts +++ b/frontend-modern/src/utils/alertThresholdDefaults.ts @@ -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 = { + nvme: 70, + sas: 65, + sata: 55, +}; + export const FACTORY_DOCKER_DEFAULTS = { cpu: 80, memory: 85, diff --git a/frontend-modern/src/utils/metricThresholds.ts b/frontend-modern/src/utils/metricThresholds.ts index 241c33f5a..87d1664a1 100644 --- a/frontend-modern/src/utils/metricThresholds.ts +++ b/frontend-modern/src/utils/metricThresholds.ts @@ -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];