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