diff --git a/docs/release-control/v6/internal/subsystems/alerts.md b/docs/release-control/v6/internal/subsystems/alerts.md index 21b5cecb3f..e06267b592 100644 --- a/docs/release-control/v6/internal/subsystems/alerts.md +++ b/docs/release-control/v6/internal/subsystems/alerts.md @@ -133,10 +133,14 @@ threshold editor saves or re-renders them. That threshold editor data shaping now lives under `frontend-modern/src/features/alerts/thresholds/hooks/useThresholdsData.ts`, while `frontend-modern/src/features/alerts/thresholds/hooks/useThresholdsTableState.ts` -owns threshold-table route sync, edit state, bulk-edit flow, and override -mutation control. `frontend-modern/src/components/Alerts/ThresholdsTable.tsx` -is now limited to table interaction and presentation. New threshold row -grouping, override-ID compatibility, resource normalization, or thresholds-table +owns threshold-table route sync, section collapse state, search/edit shell +state, and bulk-edit dialog control. Override persistence, bulk threshold +application, powered-off/connectivity state transitions, and alert-removal side +effects now route through +`frontend-modern/src/features/alerts/thresholds/hooks/useThresholdsOverrideMutations.ts`. +`frontend-modern/src/components/Alerts/ThresholdsTable.tsx` is now limited to +table interaction and presentation. New threshold row grouping, +override-ID compatibility, resource normalization, or thresholds-table controller logic should land in those hooks rather than being rebuilt inside the table component. The alert resource thresholds editor now follows the same shape: shared metric diff --git a/docs/release-control/v6/internal/subsystems/frontend-primitives.md b/docs/release-control/v6/internal/subsystems/frontend-primitives.md index 25a12542d2..fdc2478cf3 100644 --- a/docs/release-control/v6/internal/subsystems/frontend-primitives.md +++ b/docs/release-control/v6/internal/subsystems/frontend-primitives.md @@ -233,6 +233,12 @@ schedule render shell, while reset/update policy and canonical default application. The tab should not re-accumulate quiet-hours, cooldown, grouping, or escalation mutation logic inline. +The thresholds editor now follows that same split more tightly: +`frontend-modern/src/features/alerts/thresholds/hooks/useThresholdsTableState.ts` +must stay the table-shell owner for route sync and local UI state, while +`frontend-modern/src/features/alerts/thresholds/hooks/useThresholdsOverrideMutations.ts` +owns override save/bulk/toggle persistence and alert-removal side effects. The +table-shell hook should not re-accumulate raw override mutation logic inline. The updates settings surface now follows the same presentation-owner rule. `frontend-modern/src/components/Settings/UpdatesSettingsPanel.tsx` stays the diff --git a/frontend-modern/src/features/alerts/thresholds/hooks/__tests__/useThresholdsOverrideMutations.test.tsx b/frontend-modern/src/features/alerts/thresholds/hooks/__tests__/useThresholdsOverrideMutations.test.tsx new file mode 100644 index 0000000000..2b5d7a1ce5 --- /dev/null +++ b/frontend-modern/src/features/alerts/thresholds/hooks/__tests__/useThresholdsOverrideMutations.test.tsx @@ -0,0 +1,175 @@ +import { renderHook } from '@solidjs/testing-library'; +import { createSignal } from 'solid-js'; +import { describe, expect, it, vi } from 'vitest'; + +import type { ThresholdsTableProps } from '@/features/alerts/thresholds/types'; +import type { Resource as TableResource } from '@/features/alerts/thresholds/tableTypes'; + +import { useThresholdsOverrideMutations } from '../useThresholdsOverrideMutations'; + +const buildTableProps = (overridesSignal: ReturnType>) => { + const [overrides, setOverrides] = overridesSignal; + const [rawOverridesConfig, setRawOverridesConfig] = createSignal>({}); + const [backupDefaults] = createSignal({ + enabled: false, + warningDays: 7, + criticalDays: 14, + }); + const [snapshotDefaults] = createSignal({ + enabled: false, + warningDays: 30, + criticalDays: 45, + warningSizeGiB: 0, + criticalSizeGiB: 0, + }); + const setHasUnsavedChanges = vi.fn(); + const removeAlerts = vi.fn(); + + const props = { + overrides, + setOverrides, + rawOverridesConfig, + setRawOverridesConfig, + backupDefaults, + snapshotDefaults, + guestDisableConnectivity: () => false, + guestPoweredOffSeverity: () => 'warning' as const, + dockerDisableConnectivity: () => false, + dockerPoweredOffSeverity: () => 'warning' as const, + setHasUnsavedChanges, + removeAlerts, + } as unknown as ThresholdsTableProps; + + return { + props, + rawOverridesConfig, + setHasUnsavedChanges, + removeAlerts, + }; +}; + +describe('useThresholdsOverrideMutations', () => { + it('owns threshold override save persistence outside the table-state shell', () => { + const overrideSignal = createSignal([]); + const { props, rawOverridesConfig, setHasUnsavedChanges } = buildTableProps(overrideSignal); + const [editingThresholds] = createSignal>({ cpu: 95 }); + const [editingNote] = createSignal('Investigate host pressure'); + const [bulkEditIds] = createSignal([]); + const cancelEdit = vi.fn(); + const guestResource: TableResource = { + id: 'vm-100', + name: 'db-01', + type: 'guest', + resourceType: 'VM', + vmid: 100, + node: 'pve-1', + instance: 'qemu/100', + defaults: { cpu: 80 }, + thresholds: { cpu: 80 }, + }; + + const { result } = renderHook(() => + useThresholdsOverrideMutations({ + props, + resources: { + nodesWithOverrides: () => [], + agentsWithOverrides: () => [], + agentDisksWithOverrides: () => [], + dockerHostsWithOverrides: () => [], + guestsFlat: () => [guestResource], + dockerContainersFlat: () => [], + pbsServersWithOverrides: () => [], + pmgServersWithOverrides: () => [], + storageWithOverrides: () => [], + }, + editingThresholds, + editingNote, + bulkEditIds, + cancelEdit, + updateBackupDefaults: vi.fn(), + updateSnapshotDefaults: vi.fn(), + }), + ); + + result.saveEdit('vm-100'); + + expect(overrideSignal[0]()).toEqual([ + expect.objectContaining({ + id: 'vm-100', + type: 'guest', + note: 'Investigate host pressure', + thresholds: { cpu: 95 }, + }), + ]); + expect(rawOverridesConfig()).toEqual({ + 'vm-100': { + cpu: { + clear: 90, + trigger: 95, + }, + note: 'Investigate host pressure', + }, + }); + expect(setHasUnsavedChanges).toHaveBeenCalledWith(true); + expect(cancelEdit).toHaveBeenCalledTimes(1); + }); + + it('owns powered-off severity and disable-connectivity persistence for guest resources', () => { + const overrideSignal = createSignal([]); + const { props, rawOverridesConfig, setHasUnsavedChanges, removeAlerts } = + buildTableProps(overrideSignal); + const [editingThresholds] = createSignal>({}); + const [editingNote] = createSignal(''); + const [bulkEditIds] = createSignal([]); + const guestResource: TableResource = { + id: 'vm-100', + name: 'db-01', + type: 'guest', + resourceType: 'VM', + vmid: 100, + node: 'pve-1', + instance: 'qemu/100', + thresholds: {}, + }; + + const { result } = renderHook(() => + useThresholdsOverrideMutations({ + props, + resources: { + nodesWithOverrides: () => [], + agentsWithOverrides: () => [], + agentDisksWithOverrides: () => [], + dockerHostsWithOverrides: () => [], + guestsFlat: () => [guestResource], + dockerContainersFlat: () => [], + pbsServersWithOverrides: () => [], + pmgServersWithOverrides: () => [], + storageWithOverrides: () => [], + }, + editingThresholds, + editingNote, + bulkEditIds, + cancelEdit: vi.fn(), + updateBackupDefaults: vi.fn(), + updateSnapshotDefaults: vi.fn(), + }), + ); + + result.setOfflineState('vm-100', 'critical'); + + expect(overrideSignal[0]()).toEqual([ + expect.objectContaining({ + id: 'vm-100', + disableConnectivity: false, + poweredOffSeverity: 'critical', + }), + ]); + expect(rawOverridesConfig()).toEqual({ + 'vm-100': { + poweredOffSeverity: 'critical', + }, + }); + expect(setHasUnsavedChanges).toHaveBeenCalledWith(true); + expect(removeAlerts).not.toHaveBeenCalled(); + }); +}); diff --git a/frontend-modern/src/features/alerts/thresholds/hooks/useThresholdsOverrideMutations.ts b/frontend-modern/src/features/alerts/thresholds/hooks/useThresholdsOverrideMutations.ts new file mode 100644 index 0000000000..7262fe3888 --- /dev/null +++ b/frontend-modern/src/features/alerts/thresholds/hooks/useThresholdsOverrideMutations.ts @@ -0,0 +1,702 @@ +import type { Accessor } from 'solid-js'; + +import type { + RawOverrideConfig, + BackupAlertConfig, + SnapshotAlertConfig, +} from '@/types/alerts'; +import { matchesAlertIdentifier } from '@/features/alerts/identity'; +import { + DEFAULT_SNAPSHOT_WARNING, + DEFAULT_SNAPSHOT_CRITICAL, + DEFAULT_SNAPSHOT_WARNING_SIZE, + DEFAULT_SNAPSHOT_CRITICAL_SIZE, + DEFAULT_BACKUP_WARNING, + DEFAULT_BACKUP_CRITICAL, +} from '@/features/alerts/thresholds/constants'; +import type { + Override, + OverrideType, + OfflineState, + ThresholdsTableProps, +} from '@/features/alerts/thresholds/types'; +import type { Resource as TableResource } from '@/features/alerts/thresholds/tableTypes'; + +interface ThresholdsOverrideMutationResources { + nodesWithOverrides: Accessor; + agentsWithOverrides: Accessor; + agentDisksWithOverrides: Accessor; + dockerHostsWithOverrides: Accessor; + guestsFlat: Accessor; + dockerContainersFlat: Accessor; + pbsServersWithOverrides: Accessor; + pmgServersWithOverrides: Accessor; + storageWithOverrides: Accessor; +} + +interface ThresholdsOverrideMutationProps { + props: ThresholdsTableProps; + resources: ThresholdsOverrideMutationResources; + editingThresholds: Accessor>; + editingNote: Accessor; + bulkEditIds: Accessor; + cancelEdit: () => void; + updateBackupDefaults: ( + updater: BackupAlertConfig | ((prev: BackupAlertConfig) => BackupAlertConfig), + ) => void; + updateSnapshotDefaults: ( + updater: SnapshotAlertConfig | ((prev: SnapshotAlertConfig) => SnapshotAlertConfig), + ) => void; +} + +const upsertOverride = (overrides: Override[], override: Override): Override[] => { + const existingIndex = overrides.findIndex((entry) => entry.id === override.id); + if (existingIndex >= 0) { + const nextOverrides = [...overrides]; + nextOverrides[existingIndex] = override; + return nextOverrides; + } + + return [...overrides, override]; +}; + +const withThresholdEntries = ( + rawConfig: RawOverrideConfig, + thresholds: Record, +): RawOverrideConfig => { + const next = { ...rawConfig }; + + Object.entries(thresholds).forEach(([metric, value]) => { + if (value !== undefined && value !== null) { + next[metric] = { + clear: Math.max(0, value - 5), + trigger: value, + }; + } + }); + + return next; +}; + +const stripStateKeys = (thresholds: Record): Record => { + const next = { ...thresholds }; + delete (next as Record).disabled; + delete (next as Record).disableConnectivity; + delete (next as Record).poweredOffSeverity; + return next; +}; + +export function useThresholdsOverrideMutations({ + props, + resources, + editingThresholds, + editingNote, + bulkEditIds, + cancelEdit, + updateBackupDefaults, + updateSnapshotDefaults, +}: ThresholdsOverrideMutationProps) { + const proxmoxResources = () => [ + ...resources.nodesWithOverrides(), + ...resources.agentsWithOverrides(), + ...resources.agentDisksWithOverrides(), + ...resources.dockerHostsWithOverrides(), + ...resources.pbsServersWithOverrides(), + ...resources.pmgServersWithOverrides(), + ...resources.storageWithOverrides(), + ]; + + const guestLikeResources = () => [ + ...resources.guestsFlat(), + ...resources.dockerContainersFlat(), + ]; + + const allThresholdResources = () => [...proxmoxResources(), ...guestLikeResources()]; + + const removeOverride = (resourceId: string) => { + props.setOverrides(props.overrides().filter((override) => override.id !== resourceId)); + const newRawConfig = { ...props.rawOverridesConfig() }; + delete newRawConfig[resourceId]; + props.setRawOverridesConfig(newRawConfig); + props.setHasUnsavedChanges(true); + }; + + const saveEdit = (resourceId: string) => { + const resource = allThresholdResources().find((entry) => entry.id === resourceId); + if (!resource) return; + + const editedThresholdMap = editingThresholds(); + const trimmedNote = editingNote().trim(); + const noteForOverride = trimmedNote.length > 0 ? trimmedNote : undefined; + + if (resource.editScope === 'backup') { + const currentBackupDefaults = props.backupDefaults(); + updateBackupDefaults({ + criticalDays: + editedThresholdMap['critical days'] ?? + currentBackupDefaults.criticalDays ?? + DEFAULT_BACKUP_CRITICAL, + enabled: currentBackupDefaults.enabled, + warningDays: + editedThresholdMap['warning days'] ?? + currentBackupDefaults.warningDays ?? + DEFAULT_BACKUP_WARNING, + }); + cancelEdit(); + return; + } + + if (resource.editScope === 'snapshot') { + const currentSnapshotDefaults = props.snapshotDefaults(); + updateSnapshotDefaults({ + criticalDays: + editedThresholdMap['critical days'] ?? + currentSnapshotDefaults.criticalDays ?? + DEFAULT_SNAPSHOT_CRITICAL, + criticalSizeGiB: + editedThresholdMap['critical size (gib)'] ?? + currentSnapshotDefaults.criticalSizeGiB ?? + DEFAULT_SNAPSHOT_CRITICAL_SIZE, + enabled: currentSnapshotDefaults.enabled, + warningDays: + editedThresholdMap['warning days'] ?? + currentSnapshotDefaults.warningDays ?? + DEFAULT_SNAPSHOT_WARNING, + warningSizeGiB: + editedThresholdMap['warning size (gib)'] ?? + currentSnapshotDefaults.warningSizeGiB ?? + DEFAULT_SNAPSHOT_WARNING_SIZE, + }); + cancelEdit(); + return; + } + + const defaultThresholds = (resource.defaults ?? {}) as Record; + const overrideThresholds: Record = {}; + + Object.keys(editedThresholdMap).forEach((key) => { + const editedValue = editedThresholdMap[key]; + const defaultValue = defaultThresholds[key]; + if (editedValue !== undefined && editedValue !== defaultValue) { + overrideThresholds[key] = editedValue; + } + }); + + const existingOverride = props.overrides().find((override) => override.id === resourceId); + const hasStateOnlyOverride = Boolean( + resource.disabled || + resource.disableConnectivity || + resource.poweredOffSeverity !== undefined || + noteForOverride !== undefined || + existingOverride?.backup || + existingOverride?.snapshot, + ); + + if (Object.keys(overrideThresholds).length === 0 && !hasStateOnlyOverride) { + if (resource.hasOverride) { + removeOverride(resourceId); + } + cancelEdit(); + return; + } + + const override: Override = { + id: resourceId, + name: resource.name, + type: resource.type as OverrideType, + resourceType: resource.resourceType, + vmid: 'vmid' in resource ? resource.vmid : undefined, + node: 'node' in resource ? resource.node : undefined, + instance: 'instance' in resource ? resource.instance : undefined, + disabled: resource.disabled, + disableConnectivity: resource.disableConnectivity, + poweredOffSeverity: resource.poweredOffSeverity, + note: noteForOverride, + backup: existingOverride?.backup, + snapshot: existingOverride?.snapshot, + thresholds: overrideThresholds, + }; + + props.setOverrides(upsertOverride(props.overrides(), override)); + + const previousRaw = props.rawOverridesConfig()[resourceId]; + let hysteresisThresholds: RawOverrideConfig = {}; + + if (previousRaw) { + if (previousRaw.disabled !== undefined) hysteresisThresholds.disabled = previousRaw.disabled; + if (previousRaw.disableConnectivity !== undefined) { + hysteresisThresholds.disableConnectivity = previousRaw.disableConnectivity; + } + if (previousRaw.poweredOffSeverity) { + hysteresisThresholds.poweredOffSeverity = previousRaw.poweredOffSeverity; + } + if (previousRaw.backup) hysteresisThresholds.backup = previousRaw.backup; + if (previousRaw.snapshot) hysteresisThresholds.snapshot = previousRaw.snapshot; + } + + hysteresisThresholds = withThresholdEntries(hysteresisThresholds, overrideThresholds); + + if (resource.disabled) { + hysteresisThresholds.disabled = true; + } else { + delete hysteresisThresholds.disabled; + } + + if (resource.disableConnectivity) { + hysteresisThresholds.disableConnectivity = true; + delete hysteresisThresholds.poweredOffSeverity; + } else { + if ( + (resource.type === 'guest' || resource.type === 'dockerContainer') && + props.guestDisableConnectivity() + ) { + hysteresisThresholds.disableConnectivity = false; + } else { + delete hysteresisThresholds.disableConnectivity; + } + if (resource.poweredOffSeverity) { + hysteresisThresholds.poweredOffSeverity = resource.poweredOffSeverity; + } else { + delete hysteresisThresholds.poweredOffSeverity; + } + } + + if (noteForOverride) { + hysteresisThresholds.note = noteForOverride; + } else { + delete hysteresisThresholds.note; + } + + props.setRawOverridesConfig({ + ...props.rawOverridesConfig(), + [resourceId]: hysteresisThresholds, + }); + props.setHasUnsavedChanges(true); + cancelEdit(); + }; + + const handleSaveBulkEdit = (thresholds: Record) => { + const nextOverrides = [...props.overrides()]; + const nextRawConfig = { ...props.rawOverridesConfig() }; + + for (const id of bulkEditIds()) { + const resource = proxmoxResources().find((entry) => entry.id === id); + if (!resource) continue; + + const defaultThresholds = (resource.defaults ?? {}) as Record; + const existingOverride = nextOverrides.find((override) => override.id === id); + const previousRaw = nextRawConfig[id]; + const newThresholds: Record = { + ...(existingOverride?.thresholds ?? {}), + }; + + Object.keys(thresholds).forEach((key) => { + if (thresholds[key] !== undefined) { + const value = thresholds[key]; + if (value === defaultThresholds[key]) { + delete newThresholds[key]; + } else { + newThresholds[key] = value as number; + } + } + }); + + const hasStateOnlyOverride = Boolean( + resource.disabled || + resource.disableConnectivity || + resource.poweredOffSeverity !== undefined || + existingOverride?.note !== undefined || + existingOverride?.backup || + existingOverride?.snapshot, + ); + + if (Object.keys(newThresholds).length === 0 && !hasStateOnlyOverride) { + const existingIndex = nextOverrides.findIndex((override) => override.id === id); + if (existingIndex !== -1) nextOverrides.splice(existingIndex, 1); + delete nextRawConfig[id]; + continue; + } + + const override: Override = { + id, + name: resource.name, + type: resource.type as OverrideType, + resourceType: resource.resourceType, + vmid: 'vmid' in resource ? resource.vmid : undefined, + node: 'node' in resource ? resource.node : undefined, + instance: 'instance' in resource ? resource.instance : undefined, + disabled: resource.disabled, + disableConnectivity: resource.disableConnectivity, + poweredOffSeverity: resource.poweredOffSeverity, + note: existingOverride?.note, + backup: existingOverride?.backup, + snapshot: existingOverride?.snapshot, + thresholds: newThresholds, + }; + + const existingIndex = nextOverrides.findIndex((entry) => entry.id === id); + if (existingIndex >= 0) { + nextOverrides[existingIndex] = override; + } else { + nextOverrides.push(override); + } + + let hysteresisThresholds: RawOverrideConfig = {}; + if (previousRaw) { + if (previousRaw.disabled !== undefined) hysteresisThresholds.disabled = previousRaw.disabled; + if (previousRaw.disableConnectivity !== undefined) { + hysteresisThresholds.disableConnectivity = previousRaw.disableConnectivity; + } + if (previousRaw.poweredOffSeverity !== undefined) { + hysteresisThresholds.poweredOffSeverity = previousRaw.poweredOffSeverity; + } + if (previousRaw.note !== undefined) hysteresisThresholds.note = previousRaw.note; + if (previousRaw.backup !== undefined) hysteresisThresholds.backup = previousRaw.backup; + if (previousRaw.snapshot !== undefined) { + hysteresisThresholds.snapshot = previousRaw.snapshot; + } + } + + nextRawConfig[id] = withThresholdEntries(hysteresisThresholds, newThresholds); + } + + props.setOverrides(nextOverrides); + props.setRawOverridesConfig(nextRawConfig); + props.setHasUnsavedChanges(true); + }; + + const toggleBackup = (resourceId: string, forceState?: boolean) => { + const resource = guestLikeResources().find((entry) => entry.id === resourceId); + if (!resource || (resource.type !== 'guest' && resource.type !== 'dockerContainer')) return; + + const existingOverride = props.overrides().find((override) => override.id === resourceId); + const baseConfig = existingOverride?.backup || props.backupDefaults(); + const newBackup = { + ...baseConfig, + enabled: forceState !== undefined ? forceState : !baseConfig.enabled, + }; + + const override: Override = { + ...(existingOverride || { + id: resourceId, + instance: 'instance' in resource ? resource.instance : undefined, + name: resource.name, + node: 'node' in resource ? resource.node : undefined, + thresholds: {}, + type: resource.type as OverrideType, + vmid: 'vmid' in resource ? resource.vmid : undefined, + }), + backup: newBackup, + }; + + props.setOverrides(upsertOverride(props.overrides(), override)); + props.setRawOverridesConfig({ + ...props.rawOverridesConfig(), + [resourceId]: { + ...(props.rawOverridesConfig()[resourceId] || {}), + backup: newBackup, + }, + }); + props.setHasUnsavedChanges(true); + }; + + const toggleSnapshot = (resourceId: string, forceState?: boolean) => { + const resource = guestLikeResources().find((entry) => entry.id === resourceId); + if (!resource || (resource.type !== 'guest' && resource.type !== 'dockerContainer')) return; + + const existingOverride = props.overrides().find((override) => override.id === resourceId); + const baseConfig = existingOverride?.snapshot || props.snapshotDefaults(); + const newSnapshot = { + ...baseConfig, + enabled: forceState !== undefined ? forceState : !baseConfig.enabled, + }; + + const override: Override = { + ...(existingOverride || { + id: resourceId, + instance: 'instance' in resource ? resource.instance : undefined, + name: resource.name, + node: 'node' in resource ? resource.node : undefined, + thresholds: {}, + type: resource.type as OverrideType, + vmid: 'vmid' in resource ? resource.vmid : undefined, + }), + snapshot: newSnapshot, + }; + + props.setOverrides(upsertOverride(props.overrides(), override)); + props.setRawOverridesConfig({ + ...props.rawOverridesConfig(), + [resourceId]: { + ...(props.rawOverridesConfig()[resourceId] || {}), + snapshot: newSnapshot, + }, + }); + props.setHasUnsavedChanges(true); + }; + + const toggleDisabled = (resourceId: string, forceState?: boolean) => { + const resource = [ + ...guestLikeResources(), + ...resources.storageWithOverrides(), + ...resources.pbsServersWithOverrides(), + ...resources.agentsWithOverrides(), + ...resources.agentDisksWithOverrides(), + ].find((entry) => entry.id === resourceId); + + if ( + !resource || + (resource.type !== 'guest' && + resource.type !== 'storage' && + resource.type !== 'pbs' && + resource.type !== 'dockerContainer' && + resource.type !== 'agent' && + resource.type !== 'agentDisk') + ) { + return; + } + + const existingOverride = props.overrides().find((override) => override.id === resourceId); + const currentDisabledState = resource.disabled; + const newDisabledState = forceState !== undefined ? forceState : !currentDisabledState; + const cleanThresholds = stripStateKeys({ ...(existingOverride?.thresholds || {}) }); + + if (!newDisabledState && (!existingOverride || Object.keys(cleanThresholds).length === 0)) { + removeOverride(resourceId); + } else { + const override: Override = { + id: resourceId, + name: resource.name, + type: resource.type as OverrideType, + resourceType: resource.resourceType, + vmid: 'vmid' in resource ? resource.vmid : undefined, + node: 'node' in resource ? resource.node : undefined, + instance: 'instance' in resource ? resource.instance : undefined, + disabled: newDisabledState, + disableConnectivity: existingOverride?.disableConnectivity, + poweredOffSeverity: existingOverride?.poweredOffSeverity, + backup: existingOverride?.backup, + snapshot: existingOverride?.snapshot, + thresholds: cleanThresholds, + }; + + props.setOverrides(upsertOverride(props.overrides(), override)); + + let hysteresisThresholds: RawOverrideConfig = withThresholdEntries({}, override.thresholds); + if (newDisabledState) hysteresisThresholds.disabled = true; + if (override.backup) hysteresisThresholds.backup = override.backup; + if (override.snapshot) hysteresisThresholds.snapshot = override.snapshot; + if (override.disableConnectivity) hysteresisThresholds.disableConnectivity = true; + if (override.poweredOffSeverity) { + hysteresisThresholds.poweredOffSeverity = override.poweredOffSeverity; + } + + const nextRawConfig = { ...props.rawOverridesConfig() }; + if (Object.keys(hysteresisThresholds).length === 0) { + delete nextRawConfig[resourceId]; + } else { + nextRawConfig[resourceId] = hysteresisThresholds; + } + props.setRawOverridesConfig(nextRawConfig); + } + + if (newDisabledState && props.removeAlerts) { + if (resource.type === 'guest') { + props.removeAlerts( + (alert) => alert.resourceId === resourceId && alert.type === 'powered-off', + ); + } else if (resource.type === 'pbs') { + const offlineId = `pbs-offline-${resourceId}`; + props.removeAlerts( + (alert) => + alert.resourceId === resourceId && + (matchesAlertIdentifier(alert, offlineId) || alert.type === 'offline'), + ); + } else if (resource.type === 'dockerContainer') { + props.removeAlerts( + (alert) => + alert.resourceId === resourceId && + (alert.type === 'docker-container-state' || alert.type === 'docker-container-health'), + ); + } + } + + props.setHasUnsavedChanges(true); + }; + + const toggleNodeConnectivity = (resourceId: string, forceState?: boolean) => { + const resource = [ + ...resources.nodesWithOverrides(), + ...resources.pbsServersWithOverrides(), + ...resources.guestsFlat(), + ...resources.agentsWithOverrides(), + ...resources.dockerHostsWithOverrides(), + ].find((entry) => entry.id === resourceId); + + if ( + !resource || + (resource.type !== 'agent' && + resource.type !== 'pbs' && + resource.type !== 'guest' && + resource.type !== 'dockerHost') + ) { + return; + } + + const existingOverride = props.overrides().find((override) => override.id === resourceId); + const currentDisableConnectivity = resource.disableConnectivity; + const newDisableConnectivity = + forceState !== undefined ? forceState : !currentDisableConnectivity; + const cleanThresholds = stripStateKeys({ ...(existingOverride?.thresholds || {}) }); + + if (!newDisableConnectivity && Object.keys(cleanThresholds).length === 0) { + removeOverride(resourceId); + } else { + const override: Override = { + id: resourceId, + name: resource.name, + type: resource.type as OverrideType, + resourceType: resource.resourceType, + disableConnectivity: newDisableConnectivity, + disabled: existingOverride?.disabled, + poweredOffSeverity: existingOverride?.poweredOffSeverity, + backup: existingOverride?.backup, + snapshot: existingOverride?.snapshot, + thresholds: cleanThresholds, + }; + + props.setOverrides(upsertOverride(props.overrides(), override)); + + let hysteresisThresholds: RawOverrideConfig = withThresholdEntries({}, cleanThresholds); + if (newDisableConnectivity) { + hysteresisThresholds.disableConnectivity = true; + } + if (override.backup) hysteresisThresholds.backup = override.backup; + if (override.snapshot) hysteresisThresholds.snapshot = override.snapshot; + if (override.disabled) hysteresisThresholds.disabled = true; + if (override.poweredOffSeverity) { + hysteresisThresholds.poweredOffSeverity = override.poweredOffSeverity; + } + + const nextRawConfig = { ...props.rawOverridesConfig() }; + if (Object.keys(hysteresisThresholds).length === 0) { + delete nextRawConfig[resourceId]; + } else { + nextRawConfig[resourceId] = hysteresisThresholds; + } + props.setRawOverridesConfig(nextRawConfig); + } + + props.setHasUnsavedChanges(true); + + if (props.removeAlerts && resource.type === 'dockerHost') { + const offlineId = `docker-host-offline-${resourceId}`; + const resourceKey = `docker:${resourceId}`; + props.removeAlerts( + (alert) => matchesAlertIdentifier(alert, offlineId) || alert.resourceId === resourceKey, + ); + } + }; + + const setOfflineState = (resourceId: string, state: OfflineState) => { + const resource = guestLikeResources().find((entry) => entry.id === resourceId); + if (!resource) return; + + const isDockerContainer = resource.type === 'dockerContainer'; + const defaultDisabled = isDockerContainer + ? props.dockerDisableConnectivity() + : props.guestDisableConnectivity(); + const defaultSeverity = isDockerContainer + ? props.dockerPoweredOffSeverity() + : props.guestPoweredOffSeverity(); + + const existingOverride = props.overrides().find((override) => override.id === resourceId); + const cleanThresholds = stripStateKeys({ ...(existingOverride?.thresholds || {}) }); + + const newDisableConnectivity = state === 'off'; + const newSeverity: 'warning' | 'critical' | undefined = + state === 'off' ? undefined : state === 'critical' ? 'critical' : 'warning'; + + const overrideDisabled = existingOverride?.disabled || false; + const hasThresholds = Object.keys(cleanThresholds).length > 0; + const differsFromDefaults = + newDisableConnectivity !== defaultDisabled || + (!newDisableConnectivity && newSeverity !== defaultSeverity); + + if ( + !differsFromDefaults && + !hasThresholds && + !overrideDisabled && + !existingOverride?.disableConnectivity + ) { + if (existingOverride) { + removeOverride(resourceId); + } + return; + } + + const override: Override = { + id: resourceId, + name: resource.name, + type: resource.type as OverrideType, + resourceType: resource.resourceType, + vmid: 'vmid' in resource ? resource.vmid : undefined, + node: 'node' in resource ? resource.node : undefined, + instance: 'instance' in resource ? resource.instance : undefined, + disabled: overrideDisabled, + disableConnectivity: newDisableConnectivity, + poweredOffSeverity: newDisableConnectivity ? undefined : newSeverity, + backup: existingOverride?.backup, + snapshot: existingOverride?.snapshot, + thresholds: cleanThresholds, + }; + + props.setOverrides(upsertOverride(props.overrides(), override)); + + let hysteresisThresholds: RawOverrideConfig = withThresholdEntries({}, cleanThresholds); + if (overrideDisabled) hysteresisThresholds.disabled = true; + if (newDisableConnectivity) { + hysteresisThresholds.disableConnectivity = true; + } else { + if (defaultDisabled) hysteresisThresholds.disableConnectivity = false; + if (newSeverity) hysteresisThresholds.poweredOffSeverity = newSeverity; + } + if (override.backup) hysteresisThresholds.backup = override.backup; + if (override.snapshot) hysteresisThresholds.snapshot = override.snapshot; + + const nextRawConfig = { ...props.rawOverridesConfig() }; + if (Object.keys(hysteresisThresholds).length > 0) { + nextRawConfig[resourceId] = hysteresisThresholds; + } else { + delete nextRawConfig[resourceId]; + } + + props.setRawOverridesConfig(nextRawConfig); + props.setHasUnsavedChanges(true); + + if (props.removeAlerts && newDisableConnectivity) { + if (resource.type === 'guest') { + props.removeAlerts( + (alert) => alert.resourceId === resourceId && alert.type === 'powered-off', + ); + } else if (resource.type === 'dockerContainer') { + props.removeAlerts( + (alert) => + alert.resourceId === resourceId && + (alert.type === 'docker-container-state' || alert.type === 'docker-container-health'), + ); + } + } + }; + + return { + handleSaveBulkEdit, + removeOverride, + saveEdit, + setOfflineState, + toggleBackup, + toggleDisabled, + toggleNodeConnectivity, + toggleSnapshot, + } as const; +} diff --git a/frontend-modern/src/features/alerts/thresholds/hooks/useThresholdsTableState.ts b/frontend-modern/src/features/alerts/thresholds/hooks/useThresholdsTableState.ts index 1dcfb5e8e8..8d3dfe7b9c 100644 --- a/frontend-modern/src/features/alerts/thresholds/hooks/useThresholdsTableState.ts +++ b/frontend-modern/src/features/alerts/thresholds/hooks/useThresholdsTableState.ts @@ -1,12 +1,7 @@ import { createEffect, createMemo, createSignal } from 'solid-js'; import { useLocation, useNavigate } from '@solidjs/router'; -import type { - RawOverrideConfig, - PMGThresholdDefaults, - SnapshotAlertConfig, - BackupAlertConfig, -} from '@/types/alerts'; +import type { PMGThresholdDefaults, SnapshotAlertConfig, BackupAlertConfig } from '@/types/alerts'; import { useAlertsActivation } from '@/stores/alertsActivation'; import { logger } from '@/utils/logger'; import { @@ -36,29 +31,16 @@ import { STORAGE_THRESHOLDS_EMPTY_STATE, STORAGE_THRESHOLDS_FILTER_EMPTY_STATE, } from '@/utils/alertThresholdsPresentation'; -import { matchesAlertIdentifier } from '@/features/alerts/identity'; -import { - PMG_THRESHOLD_COLUMNS, - DEFAULT_SNAPSHOT_WARNING, - DEFAULT_SNAPSHOT_CRITICAL, - DEFAULT_SNAPSHOT_WARNING_SIZE, - DEFAULT_SNAPSHOT_CRITICAL_SIZE, - DEFAULT_BACKUP_WARNING, - DEFAULT_BACKUP_CRITICAL, -} from '@/features/alerts/thresholds/constants'; +import { PMG_THRESHOLD_COLUMNS } from '@/features/alerts/thresholds/constants'; import { normalizeDockerIgnoredInput } from '@/features/alerts/thresholds/helpers'; -import type { - Override, - OverrideType, - OfflineState, - ThresholdsTableProps, -} from '@/features/alerts/thresholds/types'; +import type { ThresholdsTableProps } from '@/features/alerts/thresholds/types'; import type { Resource as TableResource, ThresholdsActiveTab, } from '@/features/alerts/thresholds/tableTypes'; import { useCollapsedSections } from '@/components/Alerts/Thresholds/hooks/useCollapsedSections'; import { useThresholdsData } from './useThresholdsData'; +import { useThresholdsOverrideMutations } from './useThresholdsOverrideMutations'; const HELP_BANNER_KEY = 'pulse-thresholds-help-dismissed'; @@ -402,187 +384,35 @@ export function useThresholdsTableState(props: ThresholdsTableProps) { setEditingNote(''); }; - const saveEdit = (resourceId: string) => { - const allResources = [ - ...nodesWithOverrides(), - ...agentsWithOverrides(), - ...agentDisksWithOverrides(), - ...dockerHostsWithOverrides(), - ...guestsFlat(), - ...dockerContainersFlat(), - ...storageWithOverrides(), - ...pbsServersWithOverrides(), - ]; - const resource = allResources.find((entry) => entry.id === resourceId); - if (!resource) return; - - const editedThresholdMap = editingThresholds(); - const trimmedNote = editingNote().trim(); - const noteForOverride = trimmedNote.length > 0 ? trimmedNote : undefined; - - if (resource.editScope === 'backup') { - const currentBackupDefaults = props.backupDefaults(); - updateBackupDefaults({ - criticalDays: - editedThresholdMap['critical days'] ?? - currentBackupDefaults.criticalDays ?? - DEFAULT_BACKUP_CRITICAL, - enabled: currentBackupDefaults.enabled, - warningDays: - editedThresholdMap['warning days'] ?? - currentBackupDefaults.warningDays ?? - DEFAULT_BACKUP_WARNING, - }); - cancelEdit(); - return; - } - - if (resource.editScope === 'snapshot') { - const currentSnapshotDefaults = props.snapshotDefaults(); - updateSnapshotDefaults({ - criticalDays: - editedThresholdMap['critical days'] ?? - currentSnapshotDefaults.criticalDays ?? - DEFAULT_SNAPSHOT_CRITICAL, - criticalSizeGiB: - editedThresholdMap['critical size (gib)'] ?? - currentSnapshotDefaults.criticalSizeGiB ?? - DEFAULT_SNAPSHOT_CRITICAL_SIZE, - enabled: currentSnapshotDefaults.enabled, - warningDays: - editedThresholdMap['warning days'] ?? - currentSnapshotDefaults.warningDays ?? - DEFAULT_SNAPSHOT_WARNING, - warningSizeGiB: - editedThresholdMap['warning size (gib)'] ?? - currentSnapshotDefaults.warningSizeGiB ?? - DEFAULT_SNAPSHOT_WARNING_SIZE, - }); - cancelEdit(); - return; - } - - const defaultThresholds = (resource.defaults ?? {}) as Record; - const overrideThresholds: Record = {}; - - Object.keys(editedThresholdMap).forEach((key) => { - const editedValue = editedThresholdMap[key]; - const defaultValue = defaultThresholds[key]; - if (editedValue !== undefined && editedValue !== defaultValue) { - overrideThresholds[key] = editedValue; - } - }); - - const existingOverride = props.overrides().find((override) => override.id === resourceId); - const hasStateOnlyOverride = Boolean( - resource.disabled || - resource.disableConnectivity || - resource.poweredOffSeverity !== undefined || - noteForOverride !== undefined || - existingOverride?.backup || - existingOverride?.snapshot, - ); - - if (Object.keys(overrideThresholds).length === 0 && !hasStateOnlyOverride) { - if (resource.hasOverride) { - props.setOverrides(props.overrides().filter((override) => override.id !== resourceId)); - const newRawConfig = { ...props.rawOverridesConfig() }; - delete newRawConfig[resourceId]; - props.setRawOverridesConfig(newRawConfig); - props.setHasUnsavedChanges(true); - } - cancelEdit(); - return; - } - - const override: Override = { - id: resourceId, - name: resource.name, - type: resource.type as OverrideType, - resourceType: resource.resourceType, - vmid: 'vmid' in resource ? resource.vmid : undefined, - node: 'node' in resource ? resource.node : undefined, - instance: 'instance' in resource ? resource.instance : undefined, - disabled: resource.disabled, - disableConnectivity: resource.disableConnectivity, - poweredOffSeverity: resource.poweredOffSeverity, - note: noteForOverride, - backup: existingOverride?.backup, - snapshot: existingOverride?.snapshot, - thresholds: overrideThresholds, - }; - - const existingIndex = props.overrides().findIndex((entry) => entry.id === resourceId); - if (existingIndex >= 0) { - const nextOverrides = [...props.overrides()]; - nextOverrides[existingIndex] = override; - props.setOverrides(nextOverrides); - } else { - props.setOverrides([...props.overrides(), override]); - } - - const newRawConfig: Record = { ...props.rawOverridesConfig() }; - const previousRaw = props.rawOverridesConfig()[resourceId]; - const hysteresisThresholds: RawOverrideConfig = {}; - - if (previousRaw) { - if (previousRaw.disabled !== undefined) hysteresisThresholds.disabled = previousRaw.disabled; - if (previousRaw.disableConnectivity !== undefined) { - hysteresisThresholds.disableConnectivity = previousRaw.disableConnectivity; - } - if (previousRaw.poweredOffSeverity) { - hysteresisThresholds.poweredOffSeverity = previousRaw.poweredOffSeverity; - } - } - - Object.entries(overrideThresholds).forEach(([metric, value]) => { - if (value !== undefined && value !== null) { - hysteresisThresholds[metric] = { - clear: Math.max(0, value - 5), - trigger: value, - }; - } - }); - - if (resource.disabled) { - hysteresisThresholds.disabled = true; - } else { - delete hysteresisThresholds.disabled; - } - - if (resource.disableConnectivity) { - hysteresisThresholds.disableConnectivity = true; - delete hysteresisThresholds.poweredOffSeverity; - } else { - if ( - (resource.type === 'guest' || resource.type === 'dockerContainer') && - props.guestDisableConnectivity() - ) { - hysteresisThresholds.disableConnectivity = false; - } else { - delete hysteresisThresholds.disableConnectivity; - } - if (resource.poweredOffSeverity) { - hysteresisThresholds.poweredOffSeverity = resource.poweredOffSeverity; - } else { - delete hysteresisThresholds.poweredOffSeverity; - } - } - - if (noteForOverride) { - hysteresisThresholds.note = noteForOverride; - } else { - delete hysteresisThresholds.note; - } - - if (previousRaw?.backup) hysteresisThresholds.backup = previousRaw.backup; - if (previousRaw?.snapshot) hysteresisThresholds.snapshot = previousRaw.snapshot; - - newRawConfig[resourceId] = hysteresisThresholds; - props.setRawOverridesConfig(newRawConfig); - props.setHasUnsavedChanges(true); - cancelEdit(); - }; + const { + handleSaveBulkEdit: persistBulkEdit, + removeOverride, + saveEdit, + setOfflineState, + toggleBackup, + toggleDisabled, + toggleNodeConnectivity, + toggleSnapshot, + } = useThresholdsOverrideMutations({ + props, + resources: { + nodesWithOverrides, + agentsWithOverrides, + agentDisksWithOverrides, + dockerHostsWithOverrides, + guestsFlat, + dockerContainersFlat, + pbsServersWithOverrides, + pmgServersWithOverrides, + storageWithOverrides, + }, + editingThresholds, + editingNote, + bulkEditIds, + cancelEdit, + updateBackupDefaults, + updateSnapshotDefaults, + }); const handleBulkEdit = (ids: string[], columns: string[]) => { setBulkEditIds(ids); @@ -592,114 +422,7 @@ export function useThresholdsTableState(props: ThresholdsTableProps) { const handleSaveBulkEdit = (thresholds: Record) => { setIsBulkEditDialogOpen(false); - - const nextOverrides = [...props.overrides()]; - const nextRawConfig = { ...props.rawOverridesConfig() }; - const allResources = [ - ...nodesWithOverrides(), - ...agentsWithOverrides(), - ...agentDisksWithOverrides(), - ...dockerHostsWithOverrides(), - ...pbsServersWithOverrides(), - ...pmgServersWithOverrides(), - ...storageWithOverrides(), - ]; - - for (const id of bulkEditIds()) { - const resource = allResources.find((entry) => entry.id === id); - if (!resource) continue; - - const defaultThresholds = (resource.defaults ?? {}) as Record; - const existingOverride = nextOverrides.find((override) => override.id === id); - const previousRaw = nextRawConfig[id]; - const newThresholds: Record = { - ...(existingOverride?.thresholds ?? {}), - }; - - Object.keys(thresholds).forEach((key) => { - if (thresholds[key] !== undefined) { - const value = thresholds[key]; - if (value === defaultThresholds[key]) { - delete newThresholds[key]; - } else { - newThresholds[key] = value as number; - } - } - }); - - const hasStateOnlyOverride = Boolean( - resource.disabled || - resource.disableConnectivity || - resource.poweredOffSeverity !== undefined || - existingOverride?.note !== undefined || - existingOverride?.backup || - existingOverride?.snapshot, - ); - - if (Object.keys(newThresholds).length === 0 && !hasStateOnlyOverride) { - if (resource.hasOverride) { - const existingIndex = nextOverrides.findIndex((override) => override.id === id); - if (existingIndex !== -1) nextOverrides.splice(existingIndex, 1); - delete nextRawConfig[id]; - } - continue; - } - - const override: Override = { - id, - name: resource.name, - type: resource.type as OverrideType, - resourceType: resource.resourceType, - vmid: 'vmid' in resource ? resource.vmid : undefined, - node: 'node' in resource ? resource.node : undefined, - instance: 'instance' in resource ? resource.instance : undefined, - disabled: resource.disabled, - disableConnectivity: resource.disableConnectivity, - poweredOffSeverity: resource.poweredOffSeverity, - note: existingOverride?.note, - backup: existingOverride?.backup, - snapshot: existingOverride?.snapshot, - thresholds: newThresholds, - }; - - const existingIndex = nextOverrides.findIndex((entry) => entry.id === id); - if (existingIndex >= 0) { - nextOverrides[existingIndex] = override; - } else { - nextOverrides.push(override); - } - - const hysteresisThresholds: RawOverrideConfig = {}; - if (previousRaw) { - if (previousRaw.disabled !== undefined) hysteresisThresholds.disabled = previousRaw.disabled; - if (previousRaw.disableConnectivity !== undefined) { - hysteresisThresholds.disableConnectivity = previousRaw.disableConnectivity; - } - if (previousRaw.poweredOffSeverity !== undefined) { - hysteresisThresholds.poweredOffSeverity = previousRaw.poweredOffSeverity; - } - if (previousRaw.note !== undefined) hysteresisThresholds.note = previousRaw.note; - if (previousRaw.backup !== undefined) hysteresisThresholds.backup = previousRaw.backup; - if (previousRaw.snapshot !== undefined) { - hysteresisThresholds.snapshot = previousRaw.snapshot; - } - } - - Object.entries(newThresholds).forEach(([metric, value]) => { - if (value !== undefined && value !== null) { - hysteresisThresholds[metric] = { - clear: Math.max(0, value - 5), - trigger: value, - }; - } - }); - - nextRawConfig[id] = hysteresisThresholds; - } - - props.setOverrides(nextOverrides); - props.setRawOverridesConfig(nextRawConfig); - props.setHasUnsavedChanges(true); + persistBulkEdit(thresholds); setBulkEditIds([]); setBulkEditColumns([]); }; @@ -751,420 +474,6 @@ export function useThresholdsTableState(props: ThresholdsTableProps) { } }; - const removeOverride = (resourceId: string) => { - props.setOverrides(props.overrides().filter((override) => override.id !== resourceId)); - const newRawConfig = { ...props.rawOverridesConfig() }; - delete newRawConfig[resourceId]; - props.setRawOverridesConfig(newRawConfig); - props.setHasUnsavedChanges(true); - }; - - const toggleBackup = (resourceId: string, forceState?: boolean) => { - const resource = [...guestsFlat(), ...dockerContainersFlat()].find((entry) => entry.id === resourceId); - if (!resource || (resource.type !== 'guest' && resource.type !== 'dockerContainer')) return; - - const existingOverride = props.overrides().find((override) => override.id === resourceId); - const baseConfig = existingOverride?.backup || props.backupDefaults(); - const newBackup = { - ...baseConfig, - enabled: forceState !== undefined ? forceState : !baseConfig.enabled, - }; - - const override: Override = { - ...(existingOverride || { - id: resourceId, - instance: 'instance' in resource ? resource.instance : undefined, - name: resource.name, - node: 'node' in resource ? resource.node : undefined, - thresholds: {}, - type: resource.type as OverrideType, - vmid: 'vmid' in resource ? resource.vmid : undefined, - }), - backup: newBackup, - }; - - const existingIndex = props.overrides().findIndex((entry) => entry.id === resourceId); - if (existingIndex >= 0) { - const nextOverrides = [...props.overrides()]; - nextOverrides[existingIndex] = override; - props.setOverrides(nextOverrides); - } else { - props.setOverrides([...props.overrides(), override]); - } - - const newRawConfig = { ...props.rawOverridesConfig() }; - newRawConfig[resourceId] = { - ...(newRawConfig[resourceId] || {}), - backup: newBackup, - }; - props.setRawOverridesConfig(newRawConfig); - props.setHasUnsavedChanges(true); - }; - - const toggleSnapshot = (resourceId: string, forceState?: boolean) => { - const resource = [...guestsFlat(), ...dockerContainersFlat()].find((entry) => entry.id === resourceId); - if (!resource || (resource.type !== 'guest' && resource.type !== 'dockerContainer')) return; - - const existingOverride = props.overrides().find((override) => override.id === resourceId); - const baseConfig = existingOverride?.snapshot || props.snapshotDefaults(); - const newSnapshot = { - ...baseConfig, - enabled: forceState !== undefined ? forceState : !baseConfig.enabled, - }; - - const override: Override = { - ...(existingOverride || { - id: resourceId, - instance: 'instance' in resource ? resource.instance : undefined, - name: resource.name, - node: 'node' in resource ? resource.node : undefined, - thresholds: {}, - type: resource.type as OverrideType, - vmid: 'vmid' in resource ? resource.vmid : undefined, - }), - snapshot: newSnapshot, - }; - - const existingIndex = props.overrides().findIndex((entry) => entry.id === resourceId); - if (existingIndex >= 0) { - const nextOverrides = [...props.overrides()]; - nextOverrides[existingIndex] = override; - props.setOverrides(nextOverrides); - } else { - props.setOverrides([...props.overrides(), override]); - } - - const newRawConfig = { ...props.rawOverridesConfig() }; - newRawConfig[resourceId] = { - ...(newRawConfig[resourceId] || {}), - snapshot: newSnapshot, - }; - props.setRawOverridesConfig(newRawConfig); - props.setHasUnsavedChanges(true); - }; - - const toggleDisabled = (resourceId: string, forceState?: boolean) => { - const resource = [ - ...guestsFlat(), - ...dockerContainersFlat(), - ...storageWithOverrides(), - ...pbsServersWithOverrides(), - ...agentsWithOverrides(), - ...agentDisksWithOverrides(), - ].find((entry) => entry.id === resourceId); - - if ( - !resource || - (resource.type !== 'guest' && - resource.type !== 'storage' && - resource.type !== 'pbs' && - resource.type !== 'dockerContainer' && - resource.type !== 'agent' && - resource.type !== 'agentDisk') - ) { - return; - } - - const existingOverride = props.overrides().find((override) => override.id === resourceId); - const currentDisabledState = resource.disabled; - const newDisabledState = forceState !== undefined ? forceState : !currentDisabledState; - const cleanThresholds: Record = { ...(existingOverride?.thresholds || {}) }; - delete (cleanThresholds as Record).disabled; - - if (!newDisabledState && (!existingOverride || Object.keys(cleanThresholds).length === 0)) { - props.setOverrides(props.overrides().filter((override) => override.id !== resourceId)); - const newRawConfig = { ...props.rawOverridesConfig() }; - delete newRawConfig[resourceId]; - props.setRawOverridesConfig(newRawConfig); - } else { - const override: Override = { - id: resourceId, - name: resource.name, - type: resource.type as OverrideType, - resourceType: resource.resourceType, - vmid: 'vmid' in resource ? resource.vmid : undefined, - node: 'node' in resource ? resource.node : undefined, - instance: 'instance' in resource ? resource.instance : undefined, - disabled: newDisabledState, - disableConnectivity: existingOverride?.disableConnectivity, - poweredOffSeverity: existingOverride?.poweredOffSeverity, - backup: existingOverride?.backup, - snapshot: existingOverride?.snapshot, - thresholds: cleanThresholds, - }; - - const existingIndex = props.overrides().findIndex((entry) => entry.id === resourceId); - if (existingIndex >= 0) { - const nextOverrides = [...props.overrides()]; - nextOverrides[existingIndex] = override; - props.setOverrides(nextOverrides); - } else { - props.setOverrides([...props.overrides(), override]); - } - - const newRawConfig: Record = { ...props.rawOverridesConfig() }; - const hysteresisThresholds: RawOverrideConfig = {}; - - Object.entries(override.thresholds).forEach(([metric, value]) => { - if (typeof value === 'number') { - hysteresisThresholds[metric] = { - clear: Math.max(0, value - 5), - trigger: value, - }; - } - }); - - if (newDisabledState) hysteresisThresholds.disabled = true; - if (override.backup) hysteresisThresholds.backup = override.backup; - if (override.snapshot) hysteresisThresholds.snapshot = override.snapshot; - if (override.disableConnectivity) hysteresisThresholds.disableConnectivity = true; - if (override.poweredOffSeverity) { - hysteresisThresholds.poweredOffSeverity = override.poweredOffSeverity; - } - - if (Object.keys(hysteresisThresholds).length === 0) { - delete newRawConfig[resourceId]; - } else { - newRawConfig[resourceId] = hysteresisThresholds; - } - props.setRawOverridesConfig(newRawConfig); - } - - if (newDisabledState && props.removeAlerts) { - if (resource.type === 'guest') { - props.removeAlerts( - (alert) => alert.resourceId === resourceId && alert.type === 'powered-off', - ); - } else if (resource.type === 'pbs') { - const offlineId = `pbs-offline-${resourceId}`; - props.removeAlerts( - (alert) => - alert.resourceId === resourceId && - (matchesAlertIdentifier(alert, offlineId) || alert.type === 'offline'), - ); - } else if (resource.type === 'dockerContainer') { - props.removeAlerts( - (alert) => - alert.resourceId === resourceId && - (alert.type === 'docker-container-state' || alert.type === 'docker-container-health'), - ); - } - } - - props.setHasUnsavedChanges(true); - }; - - const toggleNodeConnectivity = (resourceId: string, forceState?: boolean) => { - const resource = [ - ...nodesWithOverrides(), - ...pbsServersWithOverrides(), - ...guestsFlat(), - ...agentsWithOverrides(), - ...dockerHostsWithOverrides(), - ].find((entry) => entry.id === resourceId); - - if ( - !resource || - (resource.type !== 'agent' && - resource.type !== 'pbs' && - resource.type !== 'guest' && - resource.type !== 'dockerHost') - ) { - return; - } - - const existingOverride = props.overrides().find((override) => override.id === resourceId); - const currentDisableConnectivity = resource.disableConnectivity; - const newDisableConnectivity = - forceState !== undefined ? forceState : !currentDisableConnectivity; - const cleanThresholds: Record = { ...(existingOverride?.thresholds || {}) }; - delete (cleanThresholds as Record).disabled; - delete (cleanThresholds as Record).disableConnectivity; - - if (!newDisableConnectivity && Object.keys(cleanThresholds).length === 0) { - props.setOverrides(props.overrides().filter((override) => override.id !== resourceId)); - const newRawConfig = { ...props.rawOverridesConfig() }; - delete newRawConfig[resourceId]; - props.setRawOverridesConfig(newRawConfig); - } else { - const override: Override = { - id: resourceId, - name: resource.name, - type: resource.type as OverrideType, - resourceType: resource.resourceType, - disableConnectivity: newDisableConnectivity, - disabled: existingOverride?.disabled, - poweredOffSeverity: existingOverride?.poweredOffSeverity, - backup: existingOverride?.backup, - snapshot: existingOverride?.snapshot, - thresholds: cleanThresholds, - }; - - const existingIndex = props.overrides().findIndex((entry) => entry.id === resourceId); - if (existingIndex >= 0) { - const nextOverrides = [...props.overrides()]; - nextOverrides[existingIndex] = override; - props.setOverrides(nextOverrides); - } else { - props.setOverrides([...props.overrides(), override]); - } - - const newRawConfig = { ...props.rawOverridesConfig() }; - const hysteresisThresholds: Record = {}; - - Object.entries(cleanThresholds).forEach(([metric, value]) => { - if (value !== undefined && value !== null) { - hysteresisThresholds[metric] = { - clear: Math.max(0, value - 5), - trigger: value, - }; - } - }); - - if (newDisableConnectivity) { - hysteresisThresholds.disableConnectivity = true; - } - if (override.backup) hysteresisThresholds.backup = override.backup; - if (override.snapshot) hysteresisThresholds.snapshot = override.snapshot; - if (override.disabled) hysteresisThresholds.disabled = true; - if (override.poweredOffSeverity) { - hysteresisThresholds.poweredOffSeverity = override.poweredOffSeverity; - } - - if (Object.keys(hysteresisThresholds).length === 0) { - delete newRawConfig[resourceId]; - } else { - newRawConfig[resourceId] = hysteresisThresholds as RawOverrideConfig; - } - props.setRawOverridesConfig(newRawConfig); - } - - props.setHasUnsavedChanges(true); - - if (props.removeAlerts && resource.type === 'dockerHost') { - const offlineId = `docker-host-offline-${resourceId}`; - const resourceKey = `docker:${resourceId}`; - props.removeAlerts( - (alert) => matchesAlertIdentifier(alert, offlineId) || alert.resourceId === resourceKey, - ); - } - }; - - const setOfflineState = (resourceId: string, state: OfflineState) => { - const resource = [...guestsFlat(), ...dockerContainersFlat()].find((entry) => entry.id === resourceId); - if (!resource) return; - - const isDockerContainer = resource.type === 'dockerContainer'; - const defaultDisabled = isDockerContainer - ? props.dockerDisableConnectivity() - : props.guestDisableConnectivity(); - const defaultSeverity = isDockerContainer - ? props.dockerPoweredOffSeverity() - : props.guestPoweredOffSeverity(); - - const existingOverride = props.overrides().find((override) => override.id === resourceId); - const cleanThresholds: Record = { ...(existingOverride?.thresholds || {}) }; - delete (cleanThresholds as Record).disabled; - delete (cleanThresholds as Record).disableConnectivity; - delete (cleanThresholds as Record).poweredOffSeverity; - - const newDisableConnectivity = state === 'off'; - const newSeverity: 'warning' | 'critical' | undefined = - state === 'off' ? undefined : state === 'critical' ? 'critical' : 'warning'; - - const overrideDisabled = existingOverride?.disabled || false; - const hasThresholds = Object.keys(cleanThresholds).length > 0; - const differsFromDefaults = - newDisableConnectivity !== defaultDisabled || - (!newDisableConnectivity && newSeverity !== defaultSeverity); - - if ( - !differsFromDefaults && - !hasThresholds && - !overrideDisabled && - !existingOverride?.disableConnectivity - ) { - if (existingOverride) { - props.setOverrides(props.overrides().filter((override) => override.id !== resourceId)); - const newRawConfig = { ...props.rawOverridesConfig() }; - delete newRawConfig[resourceId]; - props.setRawOverridesConfig(newRawConfig); - props.setHasUnsavedChanges(true); - } - return; - } - - const override: Override = { - id: resourceId, - name: resource.name, - type: resource.type as OverrideType, - resourceType: resource.resourceType, - vmid: 'vmid' in resource ? resource.vmid : undefined, - node: 'node' in resource ? resource.node : undefined, - instance: 'instance' in resource ? resource.instance : undefined, - disabled: overrideDisabled, - disableConnectivity: newDisableConnectivity, - poweredOffSeverity: newDisableConnectivity ? undefined : newSeverity, - backup: existingOverride?.backup, - snapshot: existingOverride?.snapshot, - thresholds: cleanThresholds, - }; - - const existingIndex = props.overrides().findIndex((entry) => entry.id === resourceId); - if (existingIndex >= 0) { - const nextOverrides = [...props.overrides()]; - nextOverrides[existingIndex] = override; - props.setOverrides(nextOverrides); - } else { - props.setOverrides([...props.overrides(), override]); - } - - const newRawConfig: Record = { ...props.rawOverridesConfig() }; - const hysteresisThresholds: RawOverrideConfig = {}; - - Object.entries(cleanThresholds).forEach(([metric, value]) => { - if (value !== undefined && value !== null) { - hysteresisThresholds[metric] = { - clear: Math.max(0, value - 5), - trigger: value, - }; - } - }); - - if (overrideDisabled) hysteresisThresholds.disabled = true; - if (newDisableConnectivity) { - hysteresisThresholds.disableConnectivity = true; - } else { - if (defaultDisabled) hysteresisThresholds.disableConnectivity = false; - if (newSeverity) hysteresisThresholds.poweredOffSeverity = newSeverity; - } - if (override.backup) hysteresisThresholds.backup = override.backup; - if (override.snapshot) hysteresisThresholds.snapshot = override.snapshot; - - if (Object.keys(hysteresisThresholds).length > 0) { - newRawConfig[resourceId] = hysteresisThresholds; - } else { - delete newRawConfig[resourceId]; - } - - props.setRawOverridesConfig(newRawConfig); - props.setHasUnsavedChanges(true); - - if (props.removeAlerts && newDisableConnectivity) { - if (resource.type === 'guest') { - props.removeAlerts( - (alert) => alert.resourceId === resourceId && alert.type === 'powered-off', - ); - } else if (resource.type === 'dockerContainer') { - props.removeAlerts( - (alert) => - alert.resourceId === resourceId && - (alert.type === 'docker-container-state' || alert.type === 'docker-container-health'), - ); - } - } - }; - return { activeTab, agentDisksGroupedByAgent, diff --git a/frontend-modern/src/pages/__tests__/Alerts.helpers.test.ts b/frontend-modern/src/pages/__tests__/Alerts.helpers.test.ts index cb047d6519..2c877357d7 100644 --- a/frontend-modern/src/pages/__tests__/Alerts.helpers.test.ts +++ b/frontend-modern/src/pages/__tests__/Alerts.helpers.test.ts @@ -18,6 +18,7 @@ import recentAlertsPanelSource from '@/components/Alerts/RecentAlertsPanel.tsx?r import thresholdsTableSource from '@/components/Alerts/ThresholdsTable.tsx?raw'; import thresholdsDataHookSource from '@/features/alerts/thresholds/hooks/useThresholdsData.ts?raw'; import thresholdsTableStateHookSource from '@/features/alerts/thresholds/hooks/useThresholdsTableState.ts?raw'; +import thresholdsOverrideMutationsHookSource from '@/features/alerts/thresholds/hooks/useThresholdsOverrideMutations.ts?raw'; import { ALERT_TAB_SEGMENTS, @@ -275,6 +276,15 @@ describe('tab path helpers', () => { expect(thresholdsDataHookSource).toContain('export function useThresholdsData'); expect(thresholdsTableStateHookSource).toContain('export function useThresholdsTableState'); expect(thresholdsTableStateHookSource).toContain('useThresholdsData(props, editingId, searchTerm)'); + expect(thresholdsTableStateHookSource).toContain('useThresholdsOverrideMutations'); + expect(thresholdsTableStateHookSource).not.toContain('const saveEdit = (resourceId: string) => {'); + expect(thresholdsTableStateHookSource).not.toContain( + 'const toggleNodeConnectivity = (resourceId: string, forceState?: boolean) => {', + ); + expect(thresholdsOverrideMutationsHookSource).toContain( + 'export function useThresholdsOverrideMutations', + ); + expect(thresholdsOverrideMutationsHookSource).toContain('matchesAlertIdentifier'); }); }); diff --git a/frontend-modern/src/utils/__tests__/frontendResourceTypeBoundaries.test.ts b/frontend-modern/src/utils/__tests__/frontendResourceTypeBoundaries.test.ts index 38a7fac82c..d6a1feb4e5 100644 --- a/frontend-modern/src/utils/__tests__/frontendResourceTypeBoundaries.test.ts +++ b/frontend-modern/src/utils/__tests__/frontendResourceTypeBoundaries.test.ts @@ -340,6 +340,7 @@ import alertThresholdsTabSource from '@/features/alerts/tabs/ThresholdsTab.tsx?r import thresholdsTableSource from '@/components/Alerts/ThresholdsTable.tsx?raw'; import thresholdsDataHookSource from '@/features/alerts/thresholds/hooks/useThresholdsData.ts?raw'; import thresholdsTableStateHookSource from '@/features/alerts/thresholds/hooks/useThresholdsTableState.ts?raw'; +import thresholdsOverrideMutationsHookSource from '@/features/alerts/thresholds/hooks/useThresholdsOverrideMutations.ts?raw'; import alertIncidentPresentationSource from '@/utils/alertIncidentPresentation.ts?raw'; import alertHistoryPresentationSource from '@/utils/alertHistoryPresentation.ts?raw'; import bulkEditDialogSource from '@/components/Alerts/BulkEditDialog.tsx?raw'; @@ -2887,6 +2888,15 @@ describe('frontend resource type boundaries', () => { expect(thresholdsTableStateHookSource).toContain('export function useThresholdsTableState'); expect(thresholdsTableStateHookSource).toContain('useCollapsedSections()'); expect(thresholdsTableStateHookSource).toContain('useThresholdsData(props, editingId, searchTerm)'); + expect(thresholdsTableStateHookSource).toContain('useThresholdsOverrideMutations'); + expect(thresholdsTableStateHookSource).not.toContain('const saveEdit = (resourceId: string) => {'); + expect(thresholdsTableStateHookSource).not.toContain( + 'const toggleNodeConnectivity = (resourceId: string, forceState?: boolean) => {', + ); + expect(thresholdsOverrideMutationsHookSource).toContain( + 'export function useThresholdsOverrideMutations', + ); + expect(thresholdsOverrideMutationsHookSource).toContain('matchesAlertIdentifier'); expect(alertScheduleTabSource).toContain('getAlertGroupingCardClass'); expect(alertScheduleTabSource).toContain('getAlertGroupingCheckboxClass'); expect(alertScheduleTabSource).toContain('getAlertQuietDayButtonClass');