From 111d725c533f6432f17c426b239c4d62c71652d2 Mon Sep 17 00:00:00 2001 From: rcourtman Date: Sun, 22 Mar 2026 16:41:57 +0000 Subject: [PATCH] Extract alerts configuration model owner --- .../v6/internal/subsystems/alerts.md | 10 +- .../subsystems/frontend-primitives.md | 11 +- .../alertsConfigurationModel.test.ts | 164 ++++ .../alerts/alertsConfigurationModel.ts | 680 ++++++++++++++ .../alerts/useAlertsConfigurationState.ts | 843 +++++------------- .../pages/__tests__/Alerts.helpers.test.ts | 17 + .../frontendResourceTypeBoundaries.test.ts | 18 + 7 files changed, 1111 insertions(+), 632 deletions(-) create mode 100644 frontend-modern/src/features/alerts/__tests__/alertsConfigurationModel.test.ts create mode 100644 frontend-modern/src/features/alerts/alertsConfigurationModel.ts diff --git a/docs/release-control/v6/internal/subsystems/alerts.md b/docs/release-control/v6/internal/subsystems/alerts.md index b2327502aa..0525a9e073 100644 --- a/docs/release-control/v6/internal/subsystems/alerts.md +++ b/docs/release-control/v6/internal/subsystems/alerts.md @@ -243,6 +243,9 @@ surface is now a shell that composes the destinations, schedule, and thresholds tabs. The canonical alert-policy runtime owner is now `frontend-modern/src/features/alerts/useAlertsConfigurationState.ts` for config transport, defaults, and save/load orchestration, +`frontend-modern/src/features/alerts/alertsConfigurationModel.ts` for backend +config normalization, factory defaults, docker-gap validation, and save-payload +serialization, `frontend-modern/src/features/alerts/useAlertOverridesState.ts` for raw override normalization plus resource-backed override projection, and `frontend-modern/src/features/alerts/useAlertDestinationsState.ts` for @@ -251,9 +254,10 @@ notification destination reload and persistence. webhook load/mutate/test flow plus destination test actions, while `frontend-modern/src/features/alerts/tabs/DestinationsTab.tsx` stays the destinations render shell. Future config cleanup should extend the config -transport hook, the override-projection hook, or the destinations runtime hook -based on which subsystem actually owns the behavior instead of letting the -broader configuration hook absorb all three concerns again. +transport hook, the config model, the override-projection hook, or the +destinations runtime hook based on which subsystem actually owns the behavior +instead of letting the broader configuration hook absorb all three concerns +again. Alert filter metadata and grouped header consumers must also preserve the canonical `agent` and `node` header boundary when reusing shared filter diff --git a/docs/release-control/v6/internal/subsystems/frontend-primitives.md b/docs/release-control/v6/internal/subsystems/frontend-primitives.md index 9b75245d83..5185339501 100644 --- a/docs/release-control/v6/internal/subsystems/frontend-primitives.md +++ b/docs/release-control/v6/internal/subsystems/frontend-primitives.md @@ -444,16 +444,19 @@ configuration orchestration. `frontend-modern/src/pages/Alerts.tsx` is the page shell, while `frontend-modern/src/features/alerts/AlertsConfigurationSurface.tsx` is the feature shell. The canonical runtime owner is now `frontend-modern/src/features/alerts/useAlertsConfigurationState.ts` for alert -config transport, `frontend-modern/src/features/alerts/useAlertOverridesState.ts` +config transport, +`frontend-modern/src/features/alerts/alertsConfigurationModel.ts` for config +normalization, factory defaults, docker-gap validation, and payload +serialization, `frontend-modern/src/features/alerts/useAlertOverridesState.ts` for override projection and thresholds-facing resource selectors, and `frontend-modern/src/features/alerts/useAlertDestinationsState.ts` for notification destination reload and persistence. `frontend-modern/src/features/alerts/useAlertDestinationsTabState.ts` now owns webhook runtime and destination test actions while `frontend-modern/src/features/alerts/tabs/DestinationsTab.tsx` stays the -render shell. Future cleanup should extend the transport hook, override hook, -or destinations runtime hook based on the true owner, not move config control -flow back into the top-level page shell. +render shell. Future cleanup should extend the transport hook, config model, +override hook, or destinations runtime hook based on the true owner, not move +config control flow back into the top-level page shell. The same rule now also covers cross-tab incident timelines: the shared runtime owner is `frontend-modern/src/features/alerts/useAlertIncidentTimelineState.ts`, while `frontend-modern/src/features/alerts/OverviewTab.tsx` and diff --git a/frontend-modern/src/features/alerts/__tests__/alertsConfigurationModel.test.ts b/frontend-modern/src/features/alerts/__tests__/alertsConfigurationModel.test.ts new file mode 100644 index 0000000000..62cc942503 --- /dev/null +++ b/frontend-modern/src/features/alerts/__tests__/alertsConfigurationModel.test.ts @@ -0,0 +1,164 @@ +import { describe, expect, it } from 'vitest'; + +import type { AlertConfig } from '@/types/alerts'; + +import { + ALERT_DOCKER_GAP_VALIDATION_ERROR, + buildAlertsConfigurationPayload, + createDefaultAlertsConfigurationSnapshot, + readAlertsConfigurationSnapshot, +} from '../alertsConfigurationModel'; + +describe('alertsConfigurationModel', () => { + it('creates the canonical default snapshot', () => { + const snapshot = createDefaultAlertsConfigurationSnapshot(); + + expect(snapshot.guestDefaults.cpu).toBe(80); + expect(snapshot.dockerDefaults.serviceWarnGapPercent).toBe(10); + expect(snapshot.timeThresholds.guest).toBe(5); + expect(snapshot.scheduleCooldown.enabled).toBe(true); + expect(snapshot.backupDefaults.ignoreVMIDs).toEqual([]); + }); + + it('normalizes alert config into the runtime snapshot', () => { + const config = { + enabled: true, + guestDefaults: { + cpu: { trigger: 91, clear: 86 }, + disableConnectivity: true, + poweredOffSeverity: 'critical', + }, + nodeDefaults: {}, + agentDefaults: {}, + storageDefault: { trigger: 88, clear: 83 }, + overrides: {}, + dockerDefaults: { + serviceWarnGapPercent: 40, + serviceCriticalGapPercent: 10, + statePoweredOffSeverity: 'critical', + }, + backupDefaults: { + enabled: true, + warningDays: 30, + criticalDays: 10, + ignoreVMIDs: [' 101 ', '101', ''], + }, + snapshotDefaults: { + enabled: true, + warningDays: 25, + criticalDays: 5, + }, + schedule: { + quietHours: { + enabled: true, + start: '21:00', + end: '06:00', + days: [1, 3, 5], + }, + cooldown: 45, + maxAlertsHour: 7, + }, + } as AlertConfig; + + const snapshot = readAlertsConfigurationSnapshot(config); + + expect(snapshot.guestDefaults.cpu).toBe(91); + expect(snapshot.guestDisableConnectivity).toBe(true); + expect(snapshot.guestPoweredOffSeverity).toBe('critical'); + expect(snapshot.dockerDefaults.serviceWarnGapPercent).toBe(40); + expect(snapshot.dockerDefaults.serviceCriticalGapPercent).toBe(40); + expect(snapshot.backupDefaults.warningDays).toBe(10); + expect(snapshot.backupDefaults.criticalDays).toBe(10); + expect(snapshot.backupDefaults.ignoreVMIDs).toEqual(['101']); + expect(snapshot.snapshotDefaults.warningDays).toBe(5); + expect(snapshot.snapshotDefaults.criticalDays).toBe(5); + expect(snapshot.scheduleQuietHours.days).toEqual({ + sunday: false, + monday: true, + tuesday: false, + wednesday: true, + thursday: false, + friday: true, + saturday: false, + }); + expect(snapshot.scheduleCooldown.maxAlerts).toBe(7); + }); + + it('builds the canonical save payload from the runtime snapshot', () => { + const snapshot = createDefaultAlertsConfigurationSnapshot(); + snapshot.guestDefaults.cpu = 92; + snapshot.dockerIgnoredPrefixes = [' web ', '']; + snapshot.guestTagWhitelist = [' prod ', '']; + snapshot.metricTimeThresholds = { + Guest: { CPU: 17 }, + } as Record>; + snapshot.scheduleCooldown = { + enabled: true, + minutes: 47, + maxAlerts: 99, + }; + snapshot.scheduleGrouping = { + enabled: true, + window: 2, + byNode: true, + byGuest: false, + }; + snapshot.backupDefaults = { + enabled: true, + warningDays: 30, + criticalDays: 10, + freshHours: 12, + staleHours: 48, + alertOrphaned: true, + ignoreVMIDs: [' 101 ', ''], + }; + + const result = buildAlertsConfigurationPayload({ + snapshot, + rawOverridesConfig: { 'guest-1': { disabled: true } }, + alertsActivationState: 'active', + alertsActivationConfig: { + enabled: true, + activationTime: '2026-03-22T10:00:00Z', + observationWindowHours: 24, + }, + }); + + expect(result.dockerValidationError).toBeUndefined(); + expect(result.alertConfig).toBeDefined(); + expect(result.alertConfig?.guestDefaults.cpu).toEqual({ trigger: 92, clear: 87 }); + expect(result.alertConfig?.dockerIgnoredContainerPrefixes).toEqual(['web']); + expect(result.alertConfig?.guestTagWhitelist).toEqual(['prod']); + expect(result.alertConfig?.metricTimeThresholds).toEqual({ guest: { cpu: 17 } }); + expect(result.alertConfig?.schedule?.cooldown).toBe(47); + expect(result.alertConfig?.schedule?.maxAlertsHour).toBe(10); + expect(result.alertConfig?.schedule?.grouping).toEqual({ + enabled: true, + window: 120, + byNode: true, + byGuest: false, + }); + expect(result.alertConfig?.backupDefaults).toMatchObject({ + warningDays: 10, + criticalDays: 10, + ignoreVMIDs: ['101'], + }); + expect(result.alertConfig?.overrides).toEqual({ 'guest-1': { disabled: true } }); + }); + + it('returns the canonical docker-gap validation error before save', () => { + const snapshot = createDefaultAlertsConfigurationSnapshot(); + snapshot.dockerDefaults.serviceWarnGapPercent = 60; + snapshot.dockerDefaults.serviceCriticalGapPercent = 40; + + const result = buildAlertsConfigurationPayload({ + snapshot, + rawOverridesConfig: {}, + alertsActivationState: null, + alertsActivationConfig: null, + }); + + expect(result.alertConfig).toBeUndefined(); + expect(result.dockerValidationError).toBe(ALERT_DOCKER_GAP_VALIDATION_ERROR); + }); +}); diff --git a/frontend-modern/src/features/alerts/alertsConfigurationModel.ts b/frontend-modern/src/features/alerts/alertsConfigurationModel.ts new file mode 100644 index 0000000000..8c8974c359 --- /dev/null +++ b/frontend-modern/src/features/alerts/alertsConfigurationModel.ts @@ -0,0 +1,680 @@ +import type { AlertConfig, ActivationState, BackupAlertConfig, SnapshotAlertConfig } from '@/types/alerts'; + +import { + clampMaxAlertsPerHour, + createDefaultCooldown, + createDefaultEscalation, + createDefaultGrouping, + createDefaultQuietHours, + createDefaultResolveNotifications, + DEFAULT_DELAY_SECONDS, + fallbackMaxAlertsPerHour, + getTriggerValue, + normalizeMetricDelayMap, +} from './helpers'; +import type { + CooldownConfig, + EscalationConfig, + EscalationNotifyTarget, + GroupingConfig, + QuietHoursConfig, +} from './types'; +import { GROUPING_WINDOW_DEFAULT_SECONDS, clampCooldownMinutes } from './types'; + +export const FACTORY_GUEST_DEFAULTS = { + cpu: 80, + memory: 85, + disk: 90, + diskRead: -1, + diskWrite: -1, + networkIn: -1, + networkOut: -1, +}; + +export const FACTORY_NODE_DEFAULTS = { + cpu: 80, + memory: 85, + disk: 90, + temperature: 80, +}; + +export const FACTORY_PBS_DEFAULTS = { + cpu: 80, + memory: 85, +}; + +export const FACTORY_AGENT_DEFAULTS = { + cpu: 80, + memory: 85, + disk: 90, + diskTemperature: 55, +}; + +export const FACTORY_DOCKER_DEFAULTS = { + cpu: 80, + memory: 85, + disk: 85, + restartCount: 3, + restartWindow: 300, + memoryWarnPct: 90, + memoryCriticalPct: 95, + serviceWarnGapPercent: 10, + serviceCriticalGapPercent: 50, +}; + +export const FACTORY_DOCKER_STATE_DISABLE_CONNECTIVITY = false; +export const FACTORY_DOCKER_STATE_SEVERITY: 'warning' | 'critical' = 'warning'; +export const FACTORY_STORAGE_DEFAULT = 85; +export const FACTORY_SNAPSHOT_DEFAULTS: SnapshotAlertConfig = { + enabled: false, + warningDays: 30, + criticalDays: 45, +}; +export const FACTORY_BACKUP_DEFAULTS: BackupAlertConfig = { + enabled: false, + warningDays: 7, + criticalDays: 14, + freshHours: 24, + staleHours: 72, + alertOrphaned: true, + ignoreVMIDs: [], +}; + +export interface AlertsConfigurationSnapshot { + scheduleQuietHours: QuietHoursConfig; + scheduleCooldown: CooldownConfig; + scheduleGrouping: GroupingConfig; + scheduleEscalation: EscalationConfig; + notifyOnResolve: boolean; + guestDefaults: Record; + guestDisableConnectivity: boolean; + guestPoweredOffSeverity: 'warning' | 'critical'; + nodeDefaults: Record; + pbsDefaults: Record; + agentDefaults: Record; + dockerDefaults: typeof FACTORY_DOCKER_DEFAULTS; + dockerDisableConnectivity: boolean; + dockerPoweredOffSeverity: 'warning' | 'critical'; + dockerIgnoredPrefixes: string[]; + ignoredGuestPrefixes: string[]; + guestTagWhitelist: string[]; + guestTagBlacklist: string[]; + storageDefault: number; + backupDefaults: BackupAlertConfig; + timeThresholds: { + guest: number; + node: number; + storage: number; + pbs: number; + agent: number; + }; + metricTimeThresholds: Record>; + snapshotDefaults: SnapshotAlertConfig; + pmgThresholds: { + queueTotalWarning: number; + queueTotalCritical: number; + oldestMessageWarnMins: number; + oldestMessageCritMins: number; + deferredQueueWarn: number; + deferredQueueCritical: number; + holdQueueWarn: number; + holdQueueCritical: number; + quarantineSpamWarn: number; + quarantineSpamCritical: number; + quarantineVirusWarn: number; + quarantineVirusCritical: number; + quarantineGrowthWarnPct: number; + quarantineGrowthWarnMin: number; + quarantineGrowthCritPct: number; + quarantineGrowthCritMin: number; + }; + disableAllNodes: boolean; + disableAllGuests: boolean; + disableAllAgents: boolean; + disableAllStorage: boolean; + disableAllPBS: boolean; + disableAllPMG: boolean; + disableAllDockerHosts: boolean; + disableAllDockerServices: boolean; + disableAllDockerContainers: boolean; + disableAllNodesOffline: boolean; + disableAllGuestsOffline: boolean; + disableAllAgentsOffline: boolean; + disableAllPBSOffline: boolean; + disableAllPMGOffline: boolean; + disableAllDockerHostsOffline: boolean; +} + +export interface BuildAlertsConfigurationPayloadArgs { + snapshot: AlertsConfigurationSnapshot; + rawOverridesConfig: AlertConfig['overrides']; + alertsActivationState: ActivationState | null; + alertsActivationConfig: { + enabled?: boolean; + activationTime?: string | null; + observationWindowHours?: number | null; + } | null; +} + +export const ALERT_DOCKER_GAP_VALIDATION_ERROR = + 'Swarm service critical gap must be greater than or equal to the warning gap when enabled.'; + +const cloneDays = (days: QuietHoursConfig['days']): QuietHoursConfig['days'] => ({ ...days }); + +const cloneBackupDefaults = (backupDefaults: BackupAlertConfig): BackupAlertConfig => ({ + ...backupDefaults, + ignoreVMIDs: [...(backupDefaults.ignoreVMIDs ?? [])], +}); + +const createHysteresisThreshold = (trigger: number | undefined, clearMargin = 5) => { + const normalized = typeof trigger === 'number' ? trigger : 0; + return { + trigger: normalized, + clear: Math.max(0, normalized - clearMargin), + }; +}; + +const normalizeGap = (value: unknown, fallback: number) => { + const numeric = Number(value); + if (!Number.isFinite(numeric)) { + return fallback; + } + return Math.max(0, Math.min(100, numeric)); +}; + +const normalizeWarningCriticalPair = ( + warning: number | undefined, + critical: number | undefined, +): { warning: number; critical: number } => { + const normalizedWarning = Math.max(0, warning ?? 0); + const safeCritical = Math.max(0, critical ?? 0); + const finalWarning = + safeCritical > 0 && normalizedWarning > safeCritical ? safeCritical : normalizedWarning; + const finalCritical = safeCritical > 0 ? Math.max(safeCritical, finalWarning) : finalWarning; + return { + warning: finalWarning, + critical: finalCritical, + }; +}; + +const normalizeStringList = (values: string[] | undefined): string[] => + (values ?? []).map((value) => value.trim()).filter((value) => value.length > 0); + +export function createDefaultAlertsConfigurationSnapshot(): AlertsConfigurationSnapshot { + return { + scheduleQuietHours: createDefaultQuietHours(), + scheduleCooldown: createDefaultCooldown(), + scheduleGrouping: createDefaultGrouping(), + scheduleEscalation: createDefaultEscalation(), + notifyOnResolve: createDefaultResolveNotifications(), + guestDefaults: { ...FACTORY_GUEST_DEFAULTS }, + guestDisableConnectivity: false, + guestPoweredOffSeverity: 'warning', + nodeDefaults: { ...FACTORY_NODE_DEFAULTS }, + pbsDefaults: { ...FACTORY_PBS_DEFAULTS }, + agentDefaults: { ...FACTORY_AGENT_DEFAULTS }, + dockerDefaults: { ...FACTORY_DOCKER_DEFAULTS }, + dockerDisableConnectivity: FACTORY_DOCKER_STATE_DISABLE_CONNECTIVITY, + dockerPoweredOffSeverity: FACTORY_DOCKER_STATE_SEVERITY, + dockerIgnoredPrefixes: [], + ignoredGuestPrefixes: [], + guestTagWhitelist: [], + guestTagBlacklist: [], + storageDefault: FACTORY_STORAGE_DEFAULT, + backupDefaults: cloneBackupDefaults(FACTORY_BACKUP_DEFAULTS), + timeThresholds: { + guest: DEFAULT_DELAY_SECONDS, + node: DEFAULT_DELAY_SECONDS, + storage: DEFAULT_DELAY_SECONDS, + pbs: DEFAULT_DELAY_SECONDS, + agent: DEFAULT_DELAY_SECONDS, + }, + metricTimeThresholds: {}, + snapshotDefaults: { ...FACTORY_SNAPSHOT_DEFAULTS }, + pmgThresholds: { + queueTotalWarning: 500, + queueTotalCritical: 1000, + oldestMessageWarnMins: 30, + oldestMessageCritMins: 60, + deferredQueueWarn: 200, + deferredQueueCritical: 500, + holdQueueWarn: 100, + holdQueueCritical: 300, + quarantineSpamWarn: 2000, + quarantineSpamCritical: 5000, + quarantineVirusWarn: 2000, + quarantineVirusCritical: 5000, + quarantineGrowthWarnPct: 25, + quarantineGrowthWarnMin: 250, + quarantineGrowthCritPct: 50, + quarantineGrowthCritMin: 500, + }, + disableAllNodes: false, + disableAllGuests: false, + disableAllAgents: false, + disableAllStorage: false, + disableAllPBS: false, + disableAllPMG: false, + disableAllDockerHosts: false, + disableAllDockerServices: false, + disableAllDockerContainers: false, + disableAllNodesOffline: false, + disableAllGuestsOffline: false, + disableAllAgentsOffline: false, + disableAllPBSOffline: false, + disableAllPMGOffline: false, + disableAllDockerHostsOffline: false, + }; +} + +export function readAlertsConfigurationSnapshot(config: AlertConfig): AlertsConfigurationSnapshot { + const snapshot = createDefaultAlertsConfigurationSnapshot(); + + if (config.guestDefaults) { + snapshot.guestDefaults = { + cpu: getTriggerValue(config.guestDefaults.cpu) ?? FACTORY_GUEST_DEFAULTS.cpu, + memory: getTriggerValue(config.guestDefaults.memory) ?? FACTORY_GUEST_DEFAULTS.memory, + disk: getTriggerValue(config.guestDefaults.disk) ?? FACTORY_GUEST_DEFAULTS.disk, + diskRead: getTriggerValue(config.guestDefaults.diskRead) ?? FACTORY_GUEST_DEFAULTS.diskRead, + diskWrite: + getTriggerValue(config.guestDefaults.diskWrite) ?? FACTORY_GUEST_DEFAULTS.diskWrite, + networkIn: + getTriggerValue(config.guestDefaults.networkIn) ?? FACTORY_GUEST_DEFAULTS.networkIn, + networkOut: + getTriggerValue(config.guestDefaults.networkOut) ?? FACTORY_GUEST_DEFAULTS.networkOut, + }; + snapshot.guestDisableConnectivity = Boolean(config.guestDefaults.disableConnectivity); + snapshot.guestPoweredOffSeverity = + config.guestDefaults.poweredOffSeverity === 'critical' ? 'critical' : 'warning'; + } + + if (config.nodeDefaults) { + snapshot.nodeDefaults = { + cpu: getTriggerValue(config.nodeDefaults.cpu) ?? FACTORY_NODE_DEFAULTS.cpu, + memory: getTriggerValue(config.nodeDefaults.memory) ?? FACTORY_NODE_DEFAULTS.memory, + disk: getTriggerValue(config.nodeDefaults.disk) ?? FACTORY_NODE_DEFAULTS.disk, + temperature: + getTriggerValue(config.nodeDefaults.temperature) ?? FACTORY_NODE_DEFAULTS.temperature, + }; + } + + if (config.pbsDefaults) { + snapshot.pbsDefaults = { + cpu: getTriggerValue(config.pbsDefaults.cpu) ?? FACTORY_PBS_DEFAULTS.cpu, + memory: getTriggerValue(config.pbsDefaults.memory) ?? FACTORY_PBS_DEFAULTS.memory, + }; + } + + if (config.agentDefaults) { + snapshot.agentDefaults = { + cpu: getTriggerValue(config.agentDefaults.cpu) ?? FACTORY_AGENT_DEFAULTS.cpu, + memory: getTriggerValue(config.agentDefaults.memory) ?? FACTORY_AGENT_DEFAULTS.memory, + disk: getTriggerValue(config.agentDefaults.disk) ?? FACTORY_AGENT_DEFAULTS.disk, + diskTemperature: + getTriggerValue(config.agentDefaults.diskTemperature) ?? + FACTORY_AGENT_DEFAULTS.diskTemperature, + }; + } + + if (config.dockerDefaults) { + const serviceWarnGap = normalizeGap( + config.dockerDefaults.serviceWarnGapPercent, + FACTORY_DOCKER_DEFAULTS.serviceWarnGapPercent, + ); + let serviceCriticalGap = normalizeGap( + config.dockerDefaults.serviceCriticalGapPercent, + FACTORY_DOCKER_DEFAULTS.serviceCriticalGapPercent, + ); + if (serviceCriticalGap > 0 && serviceWarnGap > serviceCriticalGap) { + serviceCriticalGap = serviceWarnGap; + } + + snapshot.dockerDefaults = { + cpu: getTriggerValue(config.dockerDefaults.cpu) ?? FACTORY_DOCKER_DEFAULTS.cpu, + memory: getTriggerValue(config.dockerDefaults.memory) ?? FACTORY_DOCKER_DEFAULTS.memory, + disk: getTriggerValue(config.dockerDefaults.disk) ?? FACTORY_DOCKER_DEFAULTS.disk, + restartCount: config.dockerDefaults.restartCount ?? FACTORY_DOCKER_DEFAULTS.restartCount, + restartWindow: config.dockerDefaults.restartWindow ?? FACTORY_DOCKER_DEFAULTS.restartWindow, + memoryWarnPct: config.dockerDefaults.memoryWarnPct ?? FACTORY_DOCKER_DEFAULTS.memoryWarnPct, + memoryCriticalPct: + config.dockerDefaults.memoryCriticalPct ?? FACTORY_DOCKER_DEFAULTS.memoryCriticalPct, + serviceWarnGapPercent: serviceWarnGap, + serviceCriticalGapPercent: serviceCriticalGap, + }; + snapshot.dockerDisableConnectivity = Boolean(config.dockerDefaults.stateDisableConnectivity); + snapshot.dockerPoweredOffSeverity = + config.dockerDefaults.statePoweredOffSeverity === 'critical' ? 'critical' : 'warning'; + } + + snapshot.dockerIgnoredPrefixes = [...(config.dockerIgnoredContainerPrefixes ?? [])]; + snapshot.ignoredGuestPrefixes = [...(config.ignoredGuestPrefixes ?? [])]; + snapshot.guestTagWhitelist = [...(config.guestTagWhitelist ?? [])]; + snapshot.guestTagBlacklist = [...(config.guestTagBlacklist ?? [])]; + + if (config.storageDefault) { + snapshot.storageDefault = getTriggerValue(config.storageDefault) ?? FACTORY_STORAGE_DEFAULT; + } + if (config.timeThresholds) { + snapshot.timeThresholds = { + guest: config.timeThresholds.guest ?? DEFAULT_DELAY_SECONDS, + node: config.timeThresholds.node ?? DEFAULT_DELAY_SECONDS, + storage: config.timeThresholds.storage ?? DEFAULT_DELAY_SECONDS, + pbs: config.timeThresholds.pbs ?? DEFAULT_DELAY_SECONDS, + agent: config.timeThresholds.agent ?? DEFAULT_DELAY_SECONDS, + }; + } + if (config.metricTimeThresholds) { + snapshot.metricTimeThresholds = normalizeMetricDelayMap(config.metricTimeThresholds); + } + + if (config.backupDefaults) { + const normalizedPair = normalizeWarningCriticalPair( + config.backupDefaults.warningDays, + config.backupDefaults.criticalDays, + ); + snapshot.backupDefaults = { + enabled: Boolean(config.backupDefaults.enabled), + warningDays: normalizedPair.warning, + criticalDays: normalizedPair.critical, + freshHours: config.backupDefaults.freshHours ?? FACTORY_BACKUP_DEFAULTS.freshHours, + staleHours: config.backupDefaults.staleHours ?? FACTORY_BACKUP_DEFAULTS.staleHours, + alertOrphaned: + config.backupDefaults.alertOrphaned ?? FACTORY_BACKUP_DEFAULTS.alertOrphaned ?? true, + ignoreVMIDs: Array.from( + new Set( + normalizeStringList( + config.backupDefaults.ignoreVMIDs ?? FACTORY_BACKUP_DEFAULTS.ignoreVMIDs, + ), + ), + ), + }; + } + + if (config.snapshotDefaults) { + const normalizedPair = normalizeWarningCriticalPair( + config.snapshotDefaults.warningDays, + config.snapshotDefaults.criticalDays, + ); + snapshot.snapshotDefaults = { + enabled: Boolean(config.snapshotDefaults.enabled), + warningDays: normalizedPair.warning, + criticalDays: normalizedPair.critical, + }; + } + + if (config.pmgDefaults) { + snapshot.pmgThresholds = { + queueTotalWarning: config.pmgDefaults.queueTotalWarning ?? 500, + queueTotalCritical: config.pmgDefaults.queueTotalCritical ?? 1000, + oldestMessageWarnMins: config.pmgDefaults.oldestMessageWarnMins ?? 30, + oldestMessageCritMins: config.pmgDefaults.oldestMessageCritMins ?? 60, + deferredQueueWarn: config.pmgDefaults.deferredQueueWarn ?? 200, + deferredQueueCritical: config.pmgDefaults.deferredQueueCritical ?? 500, + holdQueueWarn: config.pmgDefaults.holdQueueWarn ?? 100, + holdQueueCritical: config.pmgDefaults.holdQueueCritical ?? 300, + quarantineSpamWarn: config.pmgDefaults.quarantineSpamWarn ?? 2000, + quarantineSpamCritical: config.pmgDefaults.quarantineSpamCritical ?? 5000, + quarantineVirusWarn: config.pmgDefaults.quarantineVirusWarn ?? 2000, + quarantineVirusCritical: config.pmgDefaults.quarantineVirusCritical ?? 5000, + quarantineGrowthWarnPct: config.pmgDefaults.quarantineGrowthWarnPct ?? 25, + quarantineGrowthWarnMin: config.pmgDefaults.quarantineGrowthWarnMin ?? 250, + quarantineGrowthCritPct: config.pmgDefaults.quarantineGrowthCritPct ?? 50, + quarantineGrowthCritMin: config.pmgDefaults.quarantineGrowthCritMin ?? 500, + }; + } + + snapshot.disableAllNodes = config.disableAllNodes ?? false; + snapshot.disableAllGuests = config.disableAllGuests ?? false; + snapshot.disableAllAgents = config.disableAllAgents ?? false; + snapshot.disableAllStorage = config.disableAllStorage ?? false; + snapshot.disableAllPBS = config.disableAllPBS ?? false; + snapshot.disableAllPMG = config.disableAllPMG ?? false; + snapshot.disableAllDockerHosts = config.disableAllDockerHosts ?? false; + snapshot.disableAllDockerServices = config.disableAllDockerServices ?? false; + snapshot.disableAllDockerContainers = config.disableAllDockerContainers ?? false; + snapshot.disableAllNodesOffline = config.disableAllNodesOffline ?? false; + snapshot.disableAllGuestsOffline = config.disableAllGuestsOffline ?? false; + snapshot.disableAllAgentsOffline = config.disableAllAgentsOffline ?? false; + snapshot.disableAllPBSOffline = config.disableAllPBSOffline ?? false; + snapshot.disableAllPMGOffline = config.disableAllPMGOffline ?? false; + snapshot.disableAllDockerHostsOffline = config.disableAllDockerHostsOffline ?? false; + + if (config.schedule) { + if (config.schedule.quietHours) { + const quietHours = config.schedule.quietHours; + const days = Array.isArray(quietHours.days) + ? { + sunday: quietHours.days.includes(0), + monday: quietHours.days.includes(1), + tuesday: quietHours.days.includes(2), + wednesday: quietHours.days.includes(3), + thursday: quietHours.days.includes(4), + friday: quietHours.days.includes(5), + saturday: quietHours.days.includes(6), + } + : ((quietHours.days as Record) || createDefaultQuietHours().days); + snapshot.scheduleQuietHours = { + enabled: quietHours.enabled || false, + start: quietHours.start || '22:00', + end: quietHours.end || '08:00', + timezone: + quietHours.timezone || Intl.DateTimeFormat().resolvedOptions().timeZone || 'UTC', + days, + suppress: { + performance: quietHours.suppress?.performance ?? false, + storage: quietHours.suppress?.storage ?? false, + offline: quietHours.suppress?.offline ?? false, + }, + }; + } + + if (config.schedule.cooldown !== undefined) { + const rawCooldown = config.schedule.cooldown; + const cooldownEnabled = rawCooldown > 0; + snapshot.scheduleCooldown = { + enabled: cooldownEnabled, + minutes: cooldownEnabled ? clampCooldownMinutes(rawCooldown) : 0, + maxAlerts: fallbackMaxAlertsPerHour(config.schedule.maxAlertsHour), + }; + } + + if (config.schedule.grouping) { + const groupingConfig = config.schedule.grouping; + const rawGroupingWindowSeconds = + typeof groupingConfig.window === 'number' + ? groupingConfig.window + : GROUPING_WINDOW_DEFAULT_SECONDS; + const normalizedGroupingWindowSeconds = Math.max(0, rawGroupingWindowSeconds); + snapshot.scheduleGrouping = { + enabled: + groupingConfig.enabled !== undefined + ? Boolean(groupingConfig.enabled) + : normalizedGroupingWindowSeconds > 0, + window: Math.round(normalizedGroupingWindowSeconds / 60), + byNode: groupingConfig.byNode !== undefined ? groupingConfig.byNode : true, + byGuest: groupingConfig.byGuest !== undefined ? groupingConfig.byGuest : false, + }; + } + + if (config.schedule.notifyOnResolve !== undefined) { + snapshot.notifyOnResolve = Boolean(config.schedule.notifyOnResolve); + } + + if (config.schedule.escalation) { + snapshot.scheduleEscalation = { + enabled: Boolean(config.schedule.escalation.enabled), + levels: (config.schedule.escalation.levels || []).map((level) => ({ + after: typeof level.after === 'number' ? level.after : 15, + notify: (level.notify as EscalationNotifyTarget) || 'all', + })), + }; + } + } + + return snapshot; +} + +export function buildAlertsConfigurationPayload({ + snapshot, + rawOverridesConfig, + alertsActivationState, + alertsActivationConfig, +}: BuildAlertsConfigurationPayloadArgs): { + alertConfig?: AlertConfig; + dockerValidationError?: string; +} { + if ( + snapshot.dockerDefaults.serviceCriticalGapPercent > 0 && + snapshot.dockerDefaults.serviceWarnGapPercent > snapshot.dockerDefaults.serviceCriticalGapPercent + ) { + return { + dockerValidationError: ALERT_DOCKER_GAP_VALIDATION_ERROR, + }; + } + + const normalizedSnapshotPair = normalizeWarningCriticalPair( + snapshot.snapshotDefaults.warningDays, + snapshot.snapshotDefaults.criticalDays, + ); + const normalizedBackupPair = normalizeWarningCriticalPair( + snapshot.backupDefaults.warningDays, + snapshot.backupDefaults.criticalDays, + ); + const normalizedCooldownMinutes = snapshot.scheduleCooldown.enabled + ? clampCooldownMinutes(snapshot.scheduleCooldown.minutes) + : 0; + const normalizedMaxAlertsHour = clampMaxAlertsPerHour(snapshot.scheduleCooldown.maxAlerts); + const groupingWindowSeconds = + snapshot.scheduleGrouping.enabled && snapshot.scheduleGrouping.window >= 0 + ? snapshot.scheduleGrouping.window * 60 + : 0; + const groupingEnabled = snapshot.scheduleGrouping.enabled && groupingWindowSeconds > 0; + + return { + alertConfig: { + enabled: alertsActivationConfig?.enabled ?? true, + activationState: alertsActivationState ?? undefined, + activationTime: alertsActivationConfig?.activationTime ?? undefined, + observationWindowHours: alertsActivationConfig?.observationWindowHours ?? undefined, + disableAllNodes: snapshot.disableAllNodes, + disableAllGuests: snapshot.disableAllGuests, + disableAllAgents: snapshot.disableAllAgents, + disableAllStorage: snapshot.disableAllStorage, + disableAllPBS: snapshot.disableAllPBS, + disableAllPMG: snapshot.disableAllPMG, + disableAllDockerHosts: snapshot.disableAllDockerHosts, + disableAllDockerContainers: snapshot.disableAllDockerContainers, + disableAllDockerServices: snapshot.disableAllDockerServices, + disableAllNodesOffline: snapshot.disableAllNodesOffline, + disableAllGuestsOffline: snapshot.disableAllGuestsOffline, + disableAllPBSOffline: snapshot.disableAllPBSOffline, + disableAllAgentsOffline: snapshot.disableAllAgentsOffline, + disableAllPMGOffline: snapshot.disableAllPMGOffline, + disableAllDockerHostsOffline: snapshot.disableAllDockerHostsOffline, + guestDefaults: { + cpu: createHysteresisThreshold(snapshot.guestDefaults.cpu), + memory: createHysteresisThreshold(snapshot.guestDefaults.memory), + disk: createHysteresisThreshold(snapshot.guestDefaults.disk), + diskRead: createHysteresisThreshold(snapshot.guestDefaults.diskRead), + diskWrite: createHysteresisThreshold(snapshot.guestDefaults.diskWrite), + networkIn: createHysteresisThreshold(snapshot.guestDefaults.networkIn), + networkOut: createHysteresisThreshold(snapshot.guestDefaults.networkOut), + disableConnectivity: snapshot.guestDisableConnectivity, + poweredOffSeverity: snapshot.guestPoweredOffSeverity, + }, + nodeDefaults: { + cpu: createHysteresisThreshold(snapshot.nodeDefaults.cpu), + memory: createHysteresisThreshold(snapshot.nodeDefaults.memory), + disk: createHysteresisThreshold(snapshot.nodeDefaults.disk), + temperature: createHysteresisThreshold(snapshot.nodeDefaults.temperature), + }, + agentDefaults: { + cpu: createHysteresisThreshold(snapshot.agentDefaults.cpu), + memory: createHysteresisThreshold(snapshot.agentDefaults.memory), + disk: createHysteresisThreshold(snapshot.agentDefaults.disk), + diskTemperature: createHysteresisThreshold(snapshot.agentDefaults.diskTemperature), + }, + pbsDefaults: { + cpu: createHysteresisThreshold(snapshot.pbsDefaults.cpu), + memory: createHysteresisThreshold(snapshot.pbsDefaults.memory), + }, + dockerDefaults: { + cpu: createHysteresisThreshold(snapshot.dockerDefaults.cpu), + memory: createHysteresisThreshold(snapshot.dockerDefaults.memory), + disk: createHysteresisThreshold(snapshot.dockerDefaults.disk), + restartCount: snapshot.dockerDefaults.restartCount, + restartWindow: snapshot.dockerDefaults.restartWindow, + memoryWarnPct: snapshot.dockerDefaults.memoryWarnPct, + memoryCriticalPct: snapshot.dockerDefaults.memoryCriticalPct, + serviceWarnGapPercent: snapshot.dockerDefaults.serviceWarnGapPercent, + serviceCriticalGapPercent: snapshot.dockerDefaults.serviceCriticalGapPercent, + stateDisableConnectivity: snapshot.dockerDisableConnectivity, + statePoweredOffSeverity: snapshot.dockerPoweredOffSeverity, + }, + dockerIgnoredContainerPrefixes: normalizeStringList(snapshot.dockerIgnoredPrefixes), + ignoredGuestPrefixes: normalizeStringList(snapshot.ignoredGuestPrefixes), + guestTagWhitelist: normalizeStringList(snapshot.guestTagWhitelist), + guestTagBlacklist: normalizeStringList(snapshot.guestTagBlacklist), + storageDefault: createHysteresisThreshold(snapshot.storageDefault), + minimumDelta: 2.0, + suppressionWindow: 5, + hysteresisMargin: 5.0, + timeThresholds: { ...snapshot.timeThresholds }, + metricTimeThresholds: normalizeMetricDelayMap(snapshot.metricTimeThresholds), + snapshotDefaults: { + enabled: snapshot.snapshotDefaults.enabled, + warningDays: normalizedSnapshotPair.warning, + criticalDays: normalizedSnapshotPair.critical, + }, + backupDefaults: { + enabled: snapshot.backupDefaults.enabled, + warningDays: normalizedBackupPair.warning, + criticalDays: normalizedBackupPair.critical, + freshHours: snapshot.backupDefaults.freshHours ?? 24, + staleHours: snapshot.backupDefaults.staleHours ?? 72, + alertOrphaned: snapshot.backupDefaults.alertOrphaned ?? true, + ignoreVMIDs: normalizeStringList(snapshot.backupDefaults.ignoreVMIDs), + }, + pmgDefaults: { ...snapshot.pmgThresholds }, + overrides: rawOverridesConfig, + schedule: { + quietHours: { + ...snapshot.scheduleQuietHours, + days: cloneDays(snapshot.scheduleQuietHours.days), + }, + cooldown: normalizedCooldownMinutes, + notifyOnResolve: snapshot.notifyOnResolve, + maxAlertsHour: normalizedMaxAlertsHour, + escalation: { + enabled: snapshot.scheduleEscalation.enabled, + levels: snapshot.scheduleEscalation.levels.map((level) => ({ ...level })), + }, + grouping: { + enabled: groupingEnabled, + window: groupingWindowSeconds, + byNode: snapshot.scheduleGrouping.byNode, + byGuest: snapshot.scheduleGrouping.byGuest, + }, + }, + aggregation: { + enabled: true, + timeWindow: 10, + countThreshold: 3, + similarityWindow: 5.0, + }, + flapping: { + enabled: true, + threshold: 5, + window: 10, + suppressionTime: 30, + minStability: 0.8, + }, + ioNormalization: { + enabled: true, + vmDiskMax: 500.0, + containerDiskMax: 300.0, + networkMax: 1000.0, + }, + }, + }; +} diff --git a/frontend-modern/src/features/alerts/useAlertsConfigurationState.ts b/frontend-modern/src/features/alerts/useAlertsConfigurationState.ts index 786731a35c..31aeeca8c3 100644 --- a/frontend-modern/src/features/alerts/useAlertsConfigurationState.ts +++ b/frontend-modern/src/features/alerts/useAlertsConfigurationState.ts @@ -15,29 +15,32 @@ import { import { logger } from '@/utils/logger'; import { - clampMaxAlertsPerHour, - createDefaultCooldown, - createDefaultEscalation, - createDefaultGrouping, - createDefaultQuietHours, - createDefaultResolveNotifications, - DEFAULT_DELAY_SECONDS, - fallbackMaxAlertsPerHour, - getTriggerValue, - normalizeMetricDelayMap, -} from './helpers'; + ALERT_DOCKER_GAP_VALIDATION_ERROR, + createDefaultAlertsConfigurationSnapshot, + buildAlertsConfigurationPayload, + FACTORY_AGENT_DEFAULTS, + FACTORY_BACKUP_DEFAULTS, + FACTORY_DOCKER_DEFAULTS, + FACTORY_DOCKER_STATE_DISABLE_CONNECTIVITY, + FACTORY_DOCKER_STATE_SEVERITY, + FACTORY_GUEST_DEFAULTS, + FACTORY_NODE_DEFAULTS, + FACTORY_PBS_DEFAULTS, + FACTORY_SNAPSHOT_DEFAULTS, + FACTORY_STORAGE_DEFAULT, + readAlertsConfigurationSnapshot, + type AlertsConfigurationSnapshot, +} from './alertsConfigurationModel'; import { useAlertDestinationsState } from './useAlertDestinationsState'; import { useAlertOverridesState } from './useAlertOverridesState'; import type { AlertTab, CooldownConfig, EscalationConfig, - EscalationNotifyTarget, GroupingConfig, Override, QuietHoursConfig, } from './types'; -import { GROUPING_WINDOW_DEFAULT_SECONDS, clampCooldownMinutes } from './types'; export interface AlertsConfigurationSurfaceProps { activeTab: Accessor; @@ -57,155 +60,104 @@ export interface AlertsConfigurationSurfaceProps { } | null; } -export const FACTORY_GUEST_DEFAULTS = { - cpu: 80, - memory: 85, - disk: 90, - diskRead: -1, - diskWrite: -1, - networkIn: -1, - networkOut: -1, -}; - -export const FACTORY_NODE_DEFAULTS = { - cpu: 80, - memory: 85, - disk: 90, - temperature: 80, -}; - -export const FACTORY_PBS_DEFAULTS = { - cpu: 80, - memory: 85, -}; - -export const FACTORY_AGENT_DEFAULTS = { - cpu: 80, - memory: 85, - disk: 90, - diskTemperature: 55, -}; - -export const FACTORY_DOCKER_DEFAULTS = { - cpu: 80, - memory: 85, - disk: 85, - restartCount: 3, - restartWindow: 300, - memoryWarnPct: 90, - memoryCriticalPct: 95, - serviceWarnGapPercent: 10, - serviceCriticalGapPercent: 50, -}; - -export const FACTORY_DOCKER_STATE_DISABLE_CONNECTIVITY = false; -export const FACTORY_DOCKER_STATE_SEVERITY: 'warning' | 'critical' = 'warning'; -export const FACTORY_STORAGE_DEFAULT = 85; -export const FACTORY_SNAPSHOT_DEFAULTS: SnapshotAlertConfig = { - enabled: false, - warningDays: 30, - criticalDays: 45, -}; -export const FACTORY_BACKUP_DEFAULTS: BackupAlertConfig = { - enabled: false, - warningDays: 7, - criticalDays: 14, - freshHours: 24, - staleHours: 72, - alertOrphaned: true, - ignoreVMIDs: [], -}; - export function useAlertsConfigurationState(props: AlertsConfigurationSurfaceProps) { + const defaultSnapshot = createDefaultAlertsConfigurationSnapshot(); const [isReloadingConfig, setIsReloadingConfig] = createSignal(false); - const [scheduleQuietHours, setScheduleQuietHours] = - createSignal(createDefaultQuietHours()); - const [scheduleCooldown, setScheduleCooldown] = - createSignal(createDefaultCooldown()); - const [scheduleGrouping, setScheduleGrouping] = - createSignal(createDefaultGrouping()); - const [scheduleEscalation, setScheduleEscalation] = - createSignal(createDefaultEscalation()); - const [notifyOnResolve, setNotifyOnResolve] = createSignal( - createDefaultResolveNotifications(), + const [scheduleQuietHours, setScheduleQuietHours] = createSignal( + defaultSnapshot.scheduleQuietHours, + ); + const [scheduleCooldown, setScheduleCooldown] = createSignal( + defaultSnapshot.scheduleCooldown, + ); + const [scheduleGrouping, setScheduleGrouping] = createSignal( + defaultSnapshot.scheduleGrouping, + ); + const [scheduleEscalation, setScheduleEscalation] = createSignal( + defaultSnapshot.scheduleEscalation, + ); + const [notifyOnResolve, setNotifyOnResolve] = createSignal(defaultSnapshot.notifyOnResolve); + const [guestDefaults, setGuestDefaults] = createSignal>( + defaultSnapshot.guestDefaults, + ); + const [guestDisableConnectivity, setGuestDisableConnectivity] = createSignal( + defaultSnapshot.guestDisableConnectivity, ); - const [guestDefaults, setGuestDefaults] = createSignal>({ - ...FACTORY_GUEST_DEFAULTS, - }); - const [guestDisableConnectivity, setGuestDisableConnectivity] = createSignal(false); const [guestPoweredOffSeverity, setGuestPoweredOffSeverity] = createSignal< 'warning' | 'critical' - >('warning'); - const [nodeDefaults, setNodeDefaults] = createSignal>({ - ...FACTORY_NODE_DEFAULTS, - }); - const [pbsDefaults, setPBSDefaults] = createSignal>({ - ...FACTORY_PBS_DEFAULTS, - }); - const [agentDefaults, setAgentDefaults] = createSignal>({ - ...FACTORY_AGENT_DEFAULTS, - }); - const [dockerDefaults, setDockerDefaults] = createSignal({ ...FACTORY_DOCKER_DEFAULTS }); + >(defaultSnapshot.guestPoweredOffSeverity); + const [nodeDefaults, setNodeDefaults] = createSignal>( + defaultSnapshot.nodeDefaults, + ); + const [pbsDefaults, setPBSDefaults] = createSignal>( + defaultSnapshot.pbsDefaults, + ); + const [agentDefaults, setAgentDefaults] = createSignal>( + defaultSnapshot.agentDefaults, + ); + const [dockerDefaults, setDockerDefaults] = createSignal(defaultSnapshot.dockerDefaults); const [dockerDisableConnectivity, setDockerDisableConnectivity] = createSignal( - FACTORY_DOCKER_STATE_DISABLE_CONNECTIVITY, + defaultSnapshot.dockerDisableConnectivity, ); const [dockerPoweredOffSeverity, setDockerPoweredOffSeverity] = createSignal< 'warning' | 'critical' - >(FACTORY_DOCKER_STATE_SEVERITY); - const [dockerIgnoredPrefixes, setDockerIgnoredPrefixes] = createSignal([]); - const [ignoredGuestPrefixes, setIgnoredGuestPrefixes] = createSignal([]); - const [guestTagWhitelist, setGuestTagWhitelist] = createSignal([]); - const [guestTagBlacklist, setGuestTagBlacklist] = createSignal([]); - const [storageDefault, setStorageDefault] = createSignal(FACTORY_STORAGE_DEFAULT); - const [backupDefaults, setBackupDefaults] = createSignal({ - ...FACTORY_BACKUP_DEFAULTS, - }); - const [timeThresholds, setTimeThresholds] = createSignal({ - guest: DEFAULT_DELAY_SECONDS, - node: DEFAULT_DELAY_SECONDS, - storage: DEFAULT_DELAY_SECONDS, - pbs: DEFAULT_DELAY_SECONDS, - agent: DEFAULT_DELAY_SECONDS, - }); + >(defaultSnapshot.dockerPoweredOffSeverity); + const [dockerIgnoredPrefixes, setDockerIgnoredPrefixes] = createSignal( + defaultSnapshot.dockerIgnoredPrefixes, + ); + const [ignoredGuestPrefixes, setIgnoredGuestPrefixes] = createSignal( + defaultSnapshot.ignoredGuestPrefixes, + ); + const [guestTagWhitelist, setGuestTagWhitelist] = createSignal( + defaultSnapshot.guestTagWhitelist, + ); + const [guestTagBlacklist, setGuestTagBlacklist] = createSignal( + defaultSnapshot.guestTagBlacklist, + ); + const [storageDefault, setStorageDefault] = createSignal(defaultSnapshot.storageDefault); + const [backupDefaults, setBackupDefaults] = createSignal( + defaultSnapshot.backupDefaults, + ); + const [timeThresholds, setTimeThresholds] = createSignal(defaultSnapshot.timeThresholds); const [metricTimeThresholds, setMetricTimeThresholds] = createSignal< Record> - >({}); - const [snapshotDefaults, setSnapshotDefaults] = createSignal({ - ...FACTORY_SNAPSHOT_DEFAULTS, - }); - const [pmgThresholds, setPMGThresholds] = createSignal({ - queueTotalWarning: 500, - queueTotalCritical: 1000, - oldestMessageWarnMins: 30, - oldestMessageCritMins: 60, - deferredQueueWarn: 200, - deferredQueueCritical: 500, - holdQueueWarn: 100, - holdQueueCritical: 300, - quarantineSpamWarn: 2000, - quarantineSpamCritical: 5000, - quarantineVirusWarn: 2000, - quarantineVirusCritical: 5000, - quarantineGrowthWarnPct: 25, - quarantineGrowthWarnMin: 250, - quarantineGrowthCritPct: 50, - quarantineGrowthCritMin: 500, - }); - const [disableAllNodes, setDisableAllNodes] = createSignal(false); - const [disableAllGuests, setDisableAllGuests] = createSignal(false); - const [disableAllAgents, setDisableAllAgents] = createSignal(false); - const [disableAllStorage, setDisableAllStorage] = createSignal(false); - const [disableAllPBS, setDisableAllPBS] = createSignal(false); - const [disableAllPMG, setDisableAllPMG] = createSignal(false); - const [disableAllDockerHosts, setDisableAllDockerHosts] = createSignal(false); - const [disableAllDockerServices, setDisableAllDockerServices] = createSignal(false); - const [disableAllDockerContainers, setDisableAllDockerContainers] = createSignal(false); - const [disableAllNodesOffline, setDisableAllNodesOffline] = createSignal(false); - const [disableAllGuestsOffline, setDisableAllGuestsOffline] = createSignal(false); - const [disableAllAgentsOffline, setDisableAllAgentsOffline] = createSignal(false); - const [disableAllPBSOffline, setDisableAllPBSOffline] = createSignal(false); - const [disableAllPMGOffline, setDisableAllPMGOffline] = createSignal(false); - const [disableAllDockerHostsOffline, setDisableAllDockerHostsOffline] = createSignal(false); + >(defaultSnapshot.metricTimeThresholds); + const [snapshotDefaults, setSnapshotDefaults] = createSignal( + defaultSnapshot.snapshotDefaults, + ); + const [pmgThresholds, setPMGThresholds] = createSignal(defaultSnapshot.pmgThresholds); + const [disableAllNodes, setDisableAllNodes] = createSignal(defaultSnapshot.disableAllNodes); + const [disableAllGuests, setDisableAllGuests] = createSignal(defaultSnapshot.disableAllGuests); + const [disableAllAgents, setDisableAllAgents] = createSignal(defaultSnapshot.disableAllAgents); + const [disableAllStorage, setDisableAllStorage] = createSignal(defaultSnapshot.disableAllStorage); + const [disableAllPBS, setDisableAllPBS] = createSignal(defaultSnapshot.disableAllPBS); + const [disableAllPMG, setDisableAllPMG] = createSignal(defaultSnapshot.disableAllPMG); + const [disableAllDockerHosts, setDisableAllDockerHosts] = createSignal( + defaultSnapshot.disableAllDockerHosts, + ); + const [disableAllDockerServices, setDisableAllDockerServices] = createSignal( + defaultSnapshot.disableAllDockerServices, + ); + const [disableAllDockerContainers, setDisableAllDockerContainers] = createSignal( + defaultSnapshot.disableAllDockerContainers, + ); + const [disableAllNodesOffline, setDisableAllNodesOffline] = createSignal( + defaultSnapshot.disableAllNodesOffline, + ); + const [disableAllGuestsOffline, setDisableAllGuestsOffline] = createSignal( + defaultSnapshot.disableAllGuestsOffline, + ); + const [disableAllAgentsOffline, setDisableAllAgentsOffline] = createSignal( + defaultSnapshot.disableAllAgentsOffline, + ); + const [disableAllPBSOffline, setDisableAllPBSOffline] = createSignal( + defaultSnapshot.disableAllPBSOffline, + ); + const [disableAllPMGOffline, setDisableAllPMGOffline] = createSignal( + defaultSnapshot.disableAllPMGOffline, + ); + const [disableAllDockerHostsOffline, setDisableAllDockerHostsOffline] = createSignal( + defaultSnapshot.disableAllDockerHostsOffline, + ); const destinationsState = useAlertDestinationsState({ activeTab: props.activeTab }); const overridesState = useAlertOverridesState({ allResources: props.allResources, @@ -215,6 +167,110 @@ export function useAlertsConfigurationState(props: AlertsConfigurationSurfacePro setOverviewOverrides: props.setOverviewOverrides, }); + const applyConfigurationSnapshot = (snapshot: AlertsConfigurationSnapshot) => { + setScheduleQuietHours({ + ...snapshot.scheduleQuietHours, + days: { ...snapshot.scheduleQuietHours.days }, + suppress: { ...snapshot.scheduleQuietHours.suppress }, + }); + setScheduleCooldown({ ...snapshot.scheduleCooldown }); + setScheduleGrouping({ ...snapshot.scheduleGrouping }); + setScheduleEscalation({ + enabled: snapshot.scheduleEscalation.enabled, + levels: snapshot.scheduleEscalation.levels.map((level) => ({ ...level })), + }); + setNotifyOnResolve(snapshot.notifyOnResolve); + setGuestDefaults({ ...snapshot.guestDefaults }); + setGuestDisableConnectivity(snapshot.guestDisableConnectivity); + setGuestPoweredOffSeverity(snapshot.guestPoweredOffSeverity); + setNodeDefaults({ ...snapshot.nodeDefaults }); + setPBSDefaults({ ...snapshot.pbsDefaults }); + setAgentDefaults({ ...snapshot.agentDefaults }); + setDockerDefaults({ ...snapshot.dockerDefaults }); + setDockerDisableConnectivity(snapshot.dockerDisableConnectivity); + setDockerPoweredOffSeverity(snapshot.dockerPoweredOffSeverity); + setDockerIgnoredPrefixes([...snapshot.dockerIgnoredPrefixes]); + setIgnoredGuestPrefixes([...snapshot.ignoredGuestPrefixes]); + setGuestTagWhitelist([...snapshot.guestTagWhitelist]); + setGuestTagBlacklist([...snapshot.guestTagBlacklist]); + setStorageDefault(snapshot.storageDefault); + setBackupDefaults({ + ...snapshot.backupDefaults, + ignoreVMIDs: [...(snapshot.backupDefaults.ignoreVMIDs ?? [])], + }); + setTimeThresholds({ ...snapshot.timeThresholds }); + setMetricTimeThresholds(structuredClone(snapshot.metricTimeThresholds)); + setSnapshotDefaults({ ...snapshot.snapshotDefaults }); + setPMGThresholds({ ...snapshot.pmgThresholds }); + setDisableAllNodes(snapshot.disableAllNodes); + setDisableAllGuests(snapshot.disableAllGuests); + setDisableAllAgents(snapshot.disableAllAgents); + setDisableAllStorage(snapshot.disableAllStorage); + setDisableAllPBS(snapshot.disableAllPBS); + setDisableAllPMG(snapshot.disableAllPMG); + setDisableAllDockerHosts(snapshot.disableAllDockerHosts); + setDisableAllDockerServices(snapshot.disableAllDockerServices); + setDisableAllDockerContainers(snapshot.disableAllDockerContainers); + setDisableAllNodesOffline(snapshot.disableAllNodesOffline); + setDisableAllGuestsOffline(snapshot.disableAllGuestsOffline); + setDisableAllAgentsOffline(snapshot.disableAllAgentsOffline); + setDisableAllPBSOffline(snapshot.disableAllPBSOffline); + setDisableAllPMGOffline(snapshot.disableAllPMGOffline); + setDisableAllDockerHostsOffline(snapshot.disableAllDockerHostsOffline); + }; + + const captureConfigurationSnapshot = (): AlertsConfigurationSnapshot => ({ + scheduleQuietHours: { + ...scheduleQuietHours(), + days: { ...scheduleQuietHours().days }, + suppress: { ...scheduleQuietHours().suppress }, + }, + scheduleCooldown: { ...scheduleCooldown() }, + scheduleGrouping: { ...scheduleGrouping() }, + scheduleEscalation: { + enabled: scheduleEscalation().enabled, + levels: scheduleEscalation().levels.map((level) => ({ ...level })), + }, + notifyOnResolve: notifyOnResolve(), + guestDefaults: { ...guestDefaults() }, + guestDisableConnectivity: guestDisableConnectivity(), + guestPoweredOffSeverity: guestPoweredOffSeverity(), + nodeDefaults: { ...nodeDefaults() }, + pbsDefaults: { ...pbsDefaults() }, + agentDefaults: { ...agentDefaults() }, + dockerDefaults: { ...dockerDefaults() }, + dockerDisableConnectivity: dockerDisableConnectivity(), + dockerPoweredOffSeverity: dockerPoweredOffSeverity(), + dockerIgnoredPrefixes: [...dockerIgnoredPrefixes()], + ignoredGuestPrefixes: [...ignoredGuestPrefixes()], + guestTagWhitelist: [...guestTagWhitelist()], + guestTagBlacklist: [...guestTagBlacklist()], + storageDefault: storageDefault(), + backupDefaults: { + ...backupDefaults(), + ignoreVMIDs: [...(backupDefaults().ignoreVMIDs ?? [])], + }, + timeThresholds: { ...timeThresholds() }, + metricTimeThresholds: structuredClone(metricTimeThresholds()), + snapshotDefaults: { ...snapshotDefaults() }, + pmgThresholds: { ...pmgThresholds() }, + disableAllNodes: disableAllNodes(), + disableAllGuests: disableAllGuests(), + disableAllAgents: disableAllAgents(), + disableAllStorage: disableAllStorage(), + disableAllPBS: disableAllPBS(), + disableAllPMG: disableAllPMG(), + disableAllDockerHosts: disableAllDockerHosts(), + disableAllDockerServices: disableAllDockerServices(), + disableAllDockerContainers: disableAllDockerContainers(), + disableAllNodesOffline: disableAllNodesOffline(), + disableAllGuestsOffline: disableAllGuestsOffline(), + disableAllAgentsOffline: disableAllAgentsOffline(), + disableAllPBSOffline: disableAllPBSOffline(), + disableAllPMGOffline: disableAllPMGOffline(), + disableAllDockerHostsOffline: disableAllDockerHostsOffline(), + }); + const resetGuestDefaults = () => { setGuestDefaults({ ...FACTORY_GUEST_DEFAULTS }); props.setHasUnsavedChanges(true); @@ -258,313 +314,14 @@ export function useAlertsConfigurationState(props: AlertsConfigurationSurfacePro setIsReloadingConfig(true); props.setHasUnsavedChanges(false); destinationsState.resetDestinations(); - - setGuestDefaults({ ...FACTORY_GUEST_DEFAULTS }); - setGuestDisableConnectivity(false); - setGuestPoweredOffSeverity('warning'); - setNodeDefaults({ ...FACTORY_NODE_DEFAULTS }); - setPBSDefaults({ ...FACTORY_PBS_DEFAULTS }); - setAgentDefaults({ ...FACTORY_AGENT_DEFAULTS }); - setDockerDefaults({ ...FACTORY_DOCKER_DEFAULTS }); - setDockerDisableConnectivity(FACTORY_DOCKER_STATE_DISABLE_CONNECTIVITY); - setDockerPoweredOffSeverity(FACTORY_DOCKER_STATE_SEVERITY); - setDockerIgnoredPrefixes([]); - setIgnoredGuestPrefixes([]); - setGuestTagWhitelist([]); - setGuestTagBlacklist([]); - setStorageDefault(FACTORY_STORAGE_DEFAULT); - setTimeThresholds({ - guest: DEFAULT_DELAY_SECONDS, - node: DEFAULT_DELAY_SECONDS, - storage: DEFAULT_DELAY_SECONDS, - pbs: DEFAULT_DELAY_SECONDS, - agent: DEFAULT_DELAY_SECONDS, - }); - setMetricTimeThresholds({}); - setScheduleQuietHours(createDefaultQuietHours()); - setScheduleCooldown(createDefaultCooldown()); - setScheduleGrouping(createDefaultGrouping()); - setScheduleEscalation(createDefaultEscalation()); - setNotifyOnResolve(createDefaultResolveNotifications()); - setBackupDefaults({ ...FACTORY_BACKUP_DEFAULTS }); - setSnapshotDefaults({ ...FACTORY_SNAPSHOT_DEFAULTS }); + applyConfigurationSnapshot(createDefaultAlertsConfigurationSnapshot()); try { const config = await AlertsAPI.getConfig(); - - if (config.guestDefaults) { - setGuestDefaults({ - cpu: getTriggerValue(config.guestDefaults.cpu) ?? FACTORY_GUEST_DEFAULTS.cpu, - memory: getTriggerValue(config.guestDefaults.memory) ?? FACTORY_GUEST_DEFAULTS.memory, - disk: getTriggerValue(config.guestDefaults.disk) ?? FACTORY_GUEST_DEFAULTS.disk, - diskRead: - getTriggerValue(config.guestDefaults.diskRead) ?? FACTORY_GUEST_DEFAULTS.diskRead, - diskWrite: - getTriggerValue(config.guestDefaults.diskWrite) ?? FACTORY_GUEST_DEFAULTS.diskWrite, - networkIn: - getTriggerValue(config.guestDefaults.networkIn) ?? FACTORY_GUEST_DEFAULTS.networkIn, - networkOut: - getTriggerValue(config.guestDefaults.networkOut) ?? FACTORY_GUEST_DEFAULTS.networkOut, - }); - setGuestDisableConnectivity(Boolean(config.guestDefaults.disableConnectivity)); - setGuestPoweredOffSeverity( - config.guestDefaults.poweredOffSeverity === 'critical' ? 'critical' : 'warning', - ); - } - - if (config.nodeDefaults) { - setNodeDefaults({ - cpu: getTriggerValue(config.nodeDefaults.cpu) ?? FACTORY_NODE_DEFAULTS.cpu, - memory: getTriggerValue(config.nodeDefaults.memory) ?? FACTORY_NODE_DEFAULTS.memory, - disk: getTriggerValue(config.nodeDefaults.disk) ?? FACTORY_NODE_DEFAULTS.disk, - temperature: - getTriggerValue(config.nodeDefaults.temperature) ?? FACTORY_NODE_DEFAULTS.temperature, - }); - } - - if (config.pbsDefaults) { - setPBSDefaults({ - cpu: getTriggerValue(config.pbsDefaults.cpu) ?? FACTORY_PBS_DEFAULTS.cpu, - memory: getTriggerValue(config.pbsDefaults.memory) ?? FACTORY_PBS_DEFAULTS.memory, - }); - } - - if (config.agentDefaults) { - setAgentDefaults({ - cpu: getTriggerValue(config.agentDefaults.cpu) ?? FACTORY_AGENT_DEFAULTS.cpu, - memory: getTriggerValue(config.agentDefaults.memory) ?? FACTORY_AGENT_DEFAULTS.memory, - disk: getTriggerValue(config.agentDefaults.disk) ?? FACTORY_AGENT_DEFAULTS.disk, - diskTemperature: - getTriggerValue(config.agentDefaults.diskTemperature) ?? - FACTORY_AGENT_DEFAULTS.diskTemperature, - }); - } - - if (config.dockerDefaults) { - const normalizeGap = (value: unknown, fallback: number) => { - const numeric = Number(value); - if (!Number.isFinite(numeric)) { - return fallback; - } - return Math.max(0, Math.min(100, numeric)); - }; - - const serviceWarnGap = normalizeGap( - config.dockerDefaults.serviceWarnGapPercent, - FACTORY_DOCKER_DEFAULTS.serviceWarnGapPercent, - ); - let serviceCriticalGap = normalizeGap( - config.dockerDefaults.serviceCriticalGapPercent, - FACTORY_DOCKER_DEFAULTS.serviceCriticalGapPercent, - ); - if (serviceCriticalGap > 0 && serviceWarnGap > serviceCriticalGap) { - serviceCriticalGap = serviceWarnGap; - } - - setDockerDefaults({ - cpu: getTriggerValue(config.dockerDefaults.cpu) ?? FACTORY_DOCKER_DEFAULTS.cpu, - memory: getTriggerValue(config.dockerDefaults.memory) ?? FACTORY_DOCKER_DEFAULTS.memory, - disk: getTriggerValue(config.dockerDefaults.disk) ?? FACTORY_DOCKER_DEFAULTS.disk, - restartCount: - config.dockerDefaults.restartCount ?? FACTORY_DOCKER_DEFAULTS.restartCount, - restartWindow: - config.dockerDefaults.restartWindow ?? FACTORY_DOCKER_DEFAULTS.restartWindow, - memoryWarnPct: - config.dockerDefaults.memoryWarnPct ?? FACTORY_DOCKER_DEFAULTS.memoryWarnPct, - memoryCriticalPct: - config.dockerDefaults.memoryCriticalPct ?? - FACTORY_DOCKER_DEFAULTS.memoryCriticalPct, - serviceWarnGapPercent: serviceWarnGap, - serviceCriticalGapPercent: serviceCriticalGap, - }); - setDockerDisableConnectivity(Boolean(config.dockerDefaults.stateDisableConnectivity)); - setDockerPoweredOffSeverity( - config.dockerDefaults.statePoweredOffSeverity === 'critical' ? 'critical' : 'warning', - ); - } - - setDockerIgnoredPrefixes(config.dockerIgnoredContainerPrefixes ?? []); - setIgnoredGuestPrefixes(config.ignoredGuestPrefixes ?? []); - setGuestTagWhitelist(config.guestTagWhitelist ?? []); - setGuestTagBlacklist(config.guestTagBlacklist ?? []); - - if (config.storageDefault) { - setStorageDefault(getTriggerValue(config.storageDefault) ?? FACTORY_STORAGE_DEFAULT); - } - if (config.timeThresholds) { - setTimeThresholds({ - guest: config.timeThresholds.guest ?? DEFAULT_DELAY_SECONDS, - node: config.timeThresholds.node ?? DEFAULT_DELAY_SECONDS, - storage: config.timeThresholds.storage ?? DEFAULT_DELAY_SECONDS, - pbs: config.timeThresholds.pbs ?? DEFAULT_DELAY_SECONDS, - agent: config.timeThresholds.agent ?? DEFAULT_DELAY_SECONDS, - }); - } - if (config.metricTimeThresholds) { - setMetricTimeThresholds(normalizeMetricDelayMap(config.metricTimeThresholds)); - } - - if (config.backupDefaults) { - const enabled = Boolean(config.backupDefaults.enabled); - const rawWarning = - config.backupDefaults.warningDays ?? FACTORY_BACKUP_DEFAULTS.warningDays; - const rawCritical = - config.backupDefaults.criticalDays ?? FACTORY_BACKUP_DEFAULTS.criticalDays; - const safeCritical = Math.max(0, rawCritical); - const normalizedWarning = Math.max(0, rawWarning); - const warningDays = - safeCritical > 0 && normalizedWarning > safeCritical ? safeCritical : normalizedWarning; - const criticalDays = Math.max(safeCritical, warningDays); - const freshHours = - config.backupDefaults.freshHours ?? FACTORY_BACKUP_DEFAULTS.freshHours; - const staleHours = - config.backupDefaults.staleHours ?? FACTORY_BACKUP_DEFAULTS.staleHours; - const alertOrphaned = - config.backupDefaults.alertOrphaned ?? FACTORY_BACKUP_DEFAULTS.alertOrphaned ?? true; - const ignoreVMIDs = Array.from( - new Set( - (config.backupDefaults.ignoreVMIDs ?? FACTORY_BACKUP_DEFAULTS.ignoreVMIDs ?? []) - .map((value) => value.trim()) - .filter((value) => value.length > 0), - ), - ); - setBackupDefaults({ - enabled, - warningDays, - criticalDays, - freshHours, - staleHours, - alertOrphaned, - ignoreVMIDs, - }); - } - - if (config.snapshotDefaults) { - const enabled = Boolean(config.snapshotDefaults.enabled); - const rawWarning = - config.snapshotDefaults.warningDays ?? FACTORY_SNAPSHOT_DEFAULTS.warningDays; - const rawCritical = - config.snapshotDefaults.criticalDays ?? FACTORY_SNAPSHOT_DEFAULTS.criticalDays; - const safeCritical = Math.max(0, rawCritical); - const normalizedWarning = Math.max(0, rawWarning); - const warningDays = - safeCritical > 0 && normalizedWarning > safeCritical ? safeCritical : normalizedWarning; - const criticalDays = Math.max(safeCritical, warningDays); - setSnapshotDefaults({ enabled, warningDays, criticalDays }); - } - - if (config.pmgDefaults) { - setPMGThresholds({ - queueTotalWarning: config.pmgDefaults.queueTotalWarning ?? 500, - queueTotalCritical: config.pmgDefaults.queueTotalCritical ?? 1000, - oldestMessageWarnMins: config.pmgDefaults.oldestMessageWarnMins ?? 30, - oldestMessageCritMins: config.pmgDefaults.oldestMessageCritMins ?? 60, - deferredQueueWarn: config.pmgDefaults.deferredQueueWarn ?? 200, - deferredQueueCritical: config.pmgDefaults.deferredQueueCritical ?? 500, - holdQueueWarn: config.pmgDefaults.holdQueueWarn ?? 100, - holdQueueCritical: config.pmgDefaults.holdQueueCritical ?? 300, - quarantineSpamWarn: config.pmgDefaults.quarantineSpamWarn ?? 2000, - quarantineSpamCritical: config.pmgDefaults.quarantineSpamCritical ?? 5000, - quarantineVirusWarn: config.pmgDefaults.quarantineVirusWarn ?? 2000, - quarantineVirusCritical: config.pmgDefaults.quarantineVirusCritical ?? 5000, - quarantineGrowthWarnPct: config.pmgDefaults.quarantineGrowthWarnPct ?? 25, - quarantineGrowthWarnMin: config.pmgDefaults.quarantineGrowthWarnMin ?? 250, - quarantineGrowthCritPct: config.pmgDefaults.quarantineGrowthCritPct ?? 50, - quarantineGrowthCritMin: config.pmgDefaults.quarantineGrowthCritMin ?? 500, - }); - } - - setDisableAllNodes(config.disableAllNodes ?? false); - setDisableAllGuests(config.disableAllGuests ?? false); - setDisableAllAgents(config.disableAllAgents ?? false); - setDisableAllStorage(config.disableAllStorage ?? false); - setDisableAllPBS(config.disableAllPBS ?? false); - setDisableAllPMG(config.disableAllPMG ?? false); - setDisableAllDockerHosts(config.disableAllDockerHosts ?? false); - setDisableAllDockerServices(config.disableAllDockerServices ?? false); - setDisableAllDockerContainers(config.disableAllDockerContainers ?? false); - setDisableAllNodesOffline(config.disableAllNodesOffline ?? false); - setDisableAllGuestsOffline(config.disableAllGuestsOffline ?? false); - setDisableAllAgentsOffline(config.disableAllAgentsOffline ?? false); - setDisableAllPBSOffline(config.disableAllPBSOffline ?? false); - setDisableAllPMGOffline(config.disableAllPMGOffline ?? false); - setDisableAllDockerHostsOffline(config.disableAllDockerHostsOffline ?? false); + applyConfigurationSnapshot(readAlertsConfigurationSnapshot(config)); overridesState.replaceRawOverridesConfig(config.overrides || {}); - if (config.schedule) { - if (config.schedule.quietHours) { - const quietHours = config.schedule.quietHours; - const days = Array.isArray(quietHours.days) - ? { - sunday: quietHours.days.includes(0), - monday: quietHours.days.includes(1), - tuesday: quietHours.days.includes(2), - wednesday: quietHours.days.includes(3), - thursday: quietHours.days.includes(4), - friday: quietHours.days.includes(5), - saturday: quietHours.days.includes(6), - } - : ((quietHours.days as Record) || createDefaultQuietHours().days); - setScheduleQuietHours({ - enabled: quietHours.enabled || false, - start: quietHours.start || '22:00', - end: quietHours.end || '08:00', - timezone: - quietHours.timezone || Intl.DateTimeFormat().resolvedOptions().timeZone || 'UTC', - days, - suppress: { - performance: quietHours.suppress?.performance ?? false, - storage: quietHours.suppress?.storage ?? false, - offline: quietHours.suppress?.offline ?? false, - }, - }); - } - - if (config.schedule.cooldown !== undefined) { - const rawCooldown = config.schedule.cooldown; - const cooldownEnabled = rawCooldown > 0; - setScheduleCooldown({ - enabled: cooldownEnabled, - minutes: cooldownEnabled ? clampCooldownMinutes(rawCooldown) : 0, - maxAlerts: fallbackMaxAlertsPerHour(config.schedule.maxAlertsHour), - }); - } - - if (config.schedule.grouping) { - const groupingConfig = config.schedule.grouping; - const rawGroupingWindowSeconds = - typeof groupingConfig.window === 'number' - ? groupingConfig.window - : GROUPING_WINDOW_DEFAULT_SECONDS; - const normalizedGroupingWindowSeconds = Math.max(0, rawGroupingWindowSeconds); - setScheduleGrouping({ - enabled: - groupingConfig.enabled !== undefined - ? Boolean(groupingConfig.enabled) - : normalizedGroupingWindowSeconds > 0, - window: Math.round(normalizedGroupingWindowSeconds / 60), - byNode: groupingConfig.byNode !== undefined ? groupingConfig.byNode : true, - byGuest: groupingConfig.byGuest !== undefined ? groupingConfig.byGuest : false, - }); - } - - if (config.schedule.notifyOnResolve !== undefined) { - setNotifyOnResolve(Boolean(config.schedule.notifyOnResolve)); - } - - if (config.schedule.escalation) { - const levels = (config.schedule.escalation.levels || []).map((level) => ({ - after: typeof level.after === 'number' ? level.after : 15, - notify: (level.notify as EscalationNotifyTarget) || 'all', - })); - setScheduleEscalation({ - enabled: Boolean(config.schedule.escalation.enabled), - levels, - }); - } - } - await destinationsState.loadDestinations(); if (options.notify) { @@ -581,182 +338,18 @@ export function useAlertsConfigurationState(props: AlertsConfigurationSurfacePro }; const saveAlertConfiguration = async () => { - const createHysteresisThreshold = (trigger: number | undefined, clearMargin = 5) => { - const normalized = typeof trigger === 'number' ? trigger : 0; - return { - trigger: normalized, - clear: Math.max(0, normalized - clearMargin), - }; - }; - - const snapshotConfig = snapshotDefaults(); - const normalizedSnapshotWarning = Math.max(0, snapshotConfig.warningDays ?? 0); - const normalizedSnapshotCritical = Math.max(0, snapshotConfig.criticalDays ?? 0); - const finalSnapshotCritical = - normalizedSnapshotCritical > 0 - ? Math.max(normalizedSnapshotCritical, normalizedSnapshotWarning) - : normalizedSnapshotWarning; - - const backupConfig = backupDefaults(); - const normalizedBackupWarning = Math.max(0, backupConfig.warningDays ?? 0); - const normalizedBackupCritical = Math.max(0, backupConfig.criticalDays ?? 0); - const finalBackupCritical = - normalizedBackupCritical > 0 - ? Math.max(normalizedBackupCritical, normalizedBackupWarning) - : normalizedBackupWarning; - - const dockerDefaultsValue = dockerDefaults(); - if ( - dockerDefaultsValue.serviceCriticalGapPercent > 0 && - dockerDefaultsValue.serviceWarnGapPercent > dockerDefaultsValue.serviceCriticalGapPercent - ) { - notificationStore.error( - 'Swarm service critical gap must be greater than or equal to the warning gap when enabled.', - ); + const result = buildAlertsConfigurationPayload({ + snapshot: captureConfigurationSnapshot(), + rawOverridesConfig: overridesState.rawOverridesConfig(), + alertsActivationState: props.alertsActivationState(), + alertsActivationConfig: props.alertsActivationConfig(), + }); + if (result.dockerValidationError) { + notificationStore.error(ALERT_DOCKER_GAP_VALIDATION_ERROR); return; } - const normalizedCooldownMinutes = scheduleCooldown().enabled - ? clampCooldownMinutes(scheduleCooldown().minutes) - : 0; - const normalizedMaxAlertsHour = clampMaxAlertsPerHour(scheduleCooldown().maxAlerts); - const groupingState = scheduleGrouping(); - const groupingWindowSeconds = - groupingState.enabled && groupingState.window >= 0 ? groupingState.window * 60 : 0; - const groupingEnabled = groupingState.enabled && groupingWindowSeconds > 0; - const activationConfig = props.alertsActivationConfig(); - - const alertConfig = { - enabled: activationConfig?.enabled ?? true, - activationState: props.alertsActivationState() ?? undefined, - activationTime: activationConfig?.activationTime ?? undefined, - observationWindowHours: activationConfig?.observationWindowHours ?? undefined, - disableAllNodes: disableAllNodes(), - disableAllGuests: disableAllGuests(), - disableAllAgents: disableAllAgents(), - disableAllStorage: disableAllStorage(), - disableAllPBS: disableAllPBS(), - disableAllPMG: disableAllPMG(), - disableAllDockerHosts: disableAllDockerHosts(), - disableAllDockerContainers: disableAllDockerContainers(), - disableAllDockerServices: disableAllDockerServices(), - disableAllNodesOffline: disableAllNodesOffline(), - disableAllGuestsOffline: disableAllGuestsOffline(), - disableAllPBSOffline: disableAllPBSOffline(), - disableAllAgentsOffline: disableAllAgentsOffline(), - disableAllPMGOffline: disableAllPMGOffline(), - disableAllDockerHostsOffline: disableAllDockerHostsOffline(), - guestDefaults: { - cpu: createHysteresisThreshold(guestDefaults().cpu), - memory: createHysteresisThreshold(guestDefaults().memory), - disk: createHysteresisThreshold(guestDefaults().disk), - diskRead: createHysteresisThreshold(guestDefaults().diskRead), - diskWrite: createHysteresisThreshold(guestDefaults().diskWrite), - networkIn: createHysteresisThreshold(guestDefaults().networkIn), - networkOut: createHysteresisThreshold(guestDefaults().networkOut), - disableConnectivity: guestDisableConnectivity(), - poweredOffSeverity: guestPoweredOffSeverity(), - }, - nodeDefaults: { - cpu: createHysteresisThreshold(nodeDefaults().cpu), - memory: createHysteresisThreshold(nodeDefaults().memory), - disk: createHysteresisThreshold(nodeDefaults().disk), - temperature: createHysteresisThreshold(nodeDefaults().temperature), - }, - agentDefaults: { - cpu: createHysteresisThreshold(agentDefaults().cpu), - memory: createHysteresisThreshold(agentDefaults().memory), - disk: createHysteresisThreshold(agentDefaults().disk), - diskTemperature: createHysteresisThreshold(agentDefaults().diskTemperature), - }, - pbsDefaults: { - cpu: createHysteresisThreshold(pbsDefaults().cpu), - memory: createHysteresisThreshold(pbsDefaults().memory), - }, - dockerDefaults: { - cpu: createHysteresisThreshold(dockerDefaultsValue.cpu), - memory: createHysteresisThreshold(dockerDefaultsValue.memory), - disk: createHysteresisThreshold(dockerDefaultsValue.disk), - restartCount: dockerDefaultsValue.restartCount, - restartWindow: dockerDefaultsValue.restartWindow, - memoryWarnPct: dockerDefaultsValue.memoryWarnPct, - memoryCriticalPct: dockerDefaultsValue.memoryCriticalPct, - serviceWarnGapPercent: dockerDefaultsValue.serviceWarnGapPercent, - serviceCriticalGapPercent: dockerDefaultsValue.serviceCriticalGapPercent, - stateDisableConnectivity: dockerDisableConnectivity(), - statePoweredOffSeverity: dockerPoweredOffSeverity(), - }, - dockerIgnoredContainerPrefixes: dockerIgnoredPrefixes() - .map((prefix) => prefix.trim()) - .filter((prefix) => prefix.length > 0), - ignoredGuestPrefixes: ignoredGuestPrefixes() - .map((prefix) => prefix.trim()) - .filter((prefix) => prefix.length > 0), - guestTagWhitelist: guestTagWhitelist() - .map((tag) => tag.trim()) - .filter((tag) => tag.length > 0), - guestTagBlacklist: guestTagBlacklist() - .map((tag) => tag.trim()) - .filter((tag) => tag.length > 0), - storageDefault: createHysteresisThreshold(storageDefault()), - minimumDelta: 2.0, - suppressionWindow: 5, - hysteresisMargin: 5.0, - timeThresholds: timeThresholds(), - metricTimeThresholds: normalizeMetricDelayMap(metricTimeThresholds()), - snapshotDefaults: { - enabled: snapshotConfig.enabled, - warningDays: normalizedSnapshotWarning, - criticalDays: finalSnapshotCritical, - }, - backupDefaults: { - enabled: backupConfig.enabled, - warningDays: normalizedBackupWarning, - criticalDays: finalBackupCritical, - freshHours: backupConfig.freshHours ?? 24, - staleHours: backupConfig.staleHours ?? 72, - alertOrphaned: backupConfig.alertOrphaned ?? true, - ignoreVMIDs: (backupConfig.ignoreVMIDs ?? []) - .map((value) => value.trim()) - .filter((value) => value.length > 0), - }, - pmgDefaults: pmgThresholds(), - overrides: overridesState.rawOverridesConfig(), - schedule: { - quietHours: scheduleQuietHours(), - cooldown: normalizedCooldownMinutes, - notifyOnResolve: notifyOnResolve(), - maxAlertsHour: normalizedMaxAlertsHour, - escalation: scheduleEscalation(), - grouping: { - enabled: groupingEnabled, - window: groupingWindowSeconds, - byNode: groupingState.byNode, - byGuest: groupingState.byGuest, - }, - }, - aggregation: { - enabled: true, - timeWindow: 10, - countThreshold: 3, - similarityWindow: 5.0, - }, - flapping: { - enabled: true, - threshold: 5, - window: 10, - suppressionTime: 30, - minStability: 0.8, - }, - ioNormalization: { - enabled: true, - vmDiskMax: 500.0, - containerDiskMax: 300.0, - networkMax: 1000.0, - }, - }; - - await AlertsAPI.updateConfig(alertConfig); + await AlertsAPI.updateConfig(result.alertConfig!); await destinationsState.saveDestinations(); props.setHasUnsavedChanges(false); diff --git a/frontend-modern/src/pages/__tests__/Alerts.helpers.test.ts b/frontend-modern/src/pages/__tests__/Alerts.helpers.test.ts index 73a95f00af..b83b626624 100644 --- a/frontend-modern/src/pages/__tests__/Alerts.helpers.test.ts +++ b/frontend-modern/src/pages/__tests__/Alerts.helpers.test.ts @@ -2,6 +2,7 @@ import { describe, expect, it } from 'vitest'; import alertsPageSource from '@/pages/Alerts.tsx?raw'; import alertsConfigurationSurfaceSource from '@/features/alerts/AlertsConfigurationSurface.tsx?raw'; import alertsConfigurationStateSource from '@/features/alerts/useAlertsConfigurationState.ts?raw'; +import alertsConfigurationModelSource from '@/features/alerts/alertsConfigurationModel.ts?raw'; import alertOverridesStateSource from '@/features/alerts/useAlertOverridesState.ts?raw'; import alertDestinationsStateSource from '@/features/alerts/useAlertDestinationsState.ts?raw'; import alertDestinationsTabStateSource from '@/features/alerts/useAlertDestinationsTabState.ts?raw'; @@ -196,16 +197,32 @@ describe('tab path helpers', () => { expect(alertsConfigurationSurfaceSource).not.toContain("eventBus.on('org_switched'"); expect(alertsConfigurationStateSource).toContain('export function useAlertsConfigurationState'); expect(alertsConfigurationStateSource).toContain('AlertsAPI.getConfig'); + expect(alertsConfigurationStateSource).toContain('createDefaultAlertsConfigurationSnapshot'); + expect(alertsConfigurationStateSource).toContain('readAlertsConfigurationSnapshot'); + expect(alertsConfigurationStateSource).toContain('buildAlertsConfigurationPayload'); expect(alertsConfigurationStateSource).toContain('useAlertDestinationsState'); expect(alertsConfigurationStateSource).toContain('useAlertOverridesState'); expect(alertsConfigurationStateSource).not.toContain('NotificationsAPI.getEmailConfig'); expect(alertsConfigurationStateSource).not.toContain('NotificationsAPI.updateEmailConfig'); expect(alertsConfigurationStateSource).toContain("eventBus.on('org_switched'"); + expect(alertsConfigurationStateSource).not.toContain('const createHysteresisThreshold ='); + expect(alertsConfigurationStateSource).not.toContain('const normalizeGap ='); expect(alertsConfigurationStateSource).not.toContain( 'const [rawOverridesConfig, setRawOverridesConfig] = createSignal', ); expect(alertsConfigurationStateSource).not.toContain('const hostOverrideIdCandidates ='); expect(alertsConfigurationStateSource).not.toContain('const allGuests = createMemo'); + expect(alertsConfigurationModelSource).toContain( + 'export function createDefaultAlertsConfigurationSnapshot', + ); + expect(alertsConfigurationModelSource).toContain( + 'export function readAlertsConfigurationSnapshot', + ); + expect(alertsConfigurationModelSource).toContain( + 'export function buildAlertsConfigurationPayload', + ); + expect(alertsConfigurationModelSource).toContain('const createHysteresisThreshold ='); + expect(alertsConfigurationModelSource).toContain('const normalizeGap ='); expect(alertOverridesStateSource).toContain('export function useAlertOverridesState'); expect(alertOverridesStateSource).toContain('getActionableAgentIdFromResource'); expect(alertOverridesStateSource).toContain('pbsInstanceFromResource'); diff --git a/frontend-modern/src/utils/__tests__/frontendResourceTypeBoundaries.test.ts b/frontend-modern/src/utils/__tests__/frontendResourceTypeBoundaries.test.ts index f355479a30..f956070c3d 100644 --- a/frontend-modern/src/utils/__tests__/frontendResourceTypeBoundaries.test.ts +++ b/frontend-modern/src/utils/__tests__/frontendResourceTypeBoundaries.test.ts @@ -326,6 +326,7 @@ import alertWebhookPresentationSource from '@/utils/alertWebhookPresentation.ts? import alertOverviewTabSource from '@/features/alerts/OverviewTab.tsx?raw'; import alertsConfigurationSurfaceSource from '@/features/alerts/AlertsConfigurationSurface.tsx?raw'; import alertsConfigurationStateSource from '@/features/alerts/useAlertsConfigurationState.ts?raw'; +import alertsConfigurationModelSource from '@/features/alerts/alertsConfigurationModel.ts?raw'; import alertOverridesStateSource from '@/features/alerts/useAlertOverridesState.ts?raw'; import alertDestinationsStateSource from '@/features/alerts/useAlertDestinationsState.ts?raw'; import alertDestinationsTabStateSource from '@/features/alerts/useAlertDestinationsTabState.ts?raw'; @@ -2774,16 +2775,32 @@ describe('frontend resource type boundaries', () => { expect(alertsConfigurationSurfaceSource).toContain('useAlertsConfigurationState'); expect(alertsConfigurationSurfaceSource).not.toContain('AlertsAPI.getConfig'); expect(alertsConfigurationStateSource).toContain('AlertsAPI.getConfig'); + expect(alertsConfigurationStateSource).toContain('createDefaultAlertsConfigurationSnapshot'); + expect(alertsConfigurationStateSource).toContain('readAlertsConfigurationSnapshot'); + expect(alertsConfigurationStateSource).toContain('buildAlertsConfigurationPayload'); expect(alertsConfigurationStateSource).toContain('useAlertDestinationsState'); expect(alertsConfigurationStateSource).toContain('useAlertOverridesState'); expect(alertsConfigurationStateSource).not.toContain('NotificationsAPI.getEmailConfig'); expect(alertsConfigurationStateSource).not.toContain('NotificationsAPI.updateEmailConfig'); expect(alertsConfigurationStateSource).toContain("eventBus.on('org_switched'"); + expect(alertsConfigurationStateSource).not.toContain('const createHysteresisThreshold ='); + expect(alertsConfigurationStateSource).not.toContain('const normalizeGap ='); expect(alertsConfigurationStateSource).not.toContain( 'const [rawOverridesConfig, setRawOverridesConfig] = createSignal', ); expect(alertsConfigurationStateSource).not.toContain('const hostOverrideIdCandidates ='); expect(alertsConfigurationStateSource).not.toContain('const allGuests = createMemo'); + expect(alertsConfigurationModelSource).toContain( + 'export function createDefaultAlertsConfigurationSnapshot', + ); + expect(alertsConfigurationModelSource).toContain( + 'export function readAlertsConfigurationSnapshot', + ); + expect(alertsConfigurationModelSource).toContain( + 'export function buildAlertsConfigurationPayload', + ); + expect(alertsConfigurationModelSource).toContain('const createHysteresisThreshold ='); + expect(alertsConfigurationModelSource).toContain('const normalizeGap ='); expect(alertOverridesStateSource).toContain('export function useAlertOverridesState'); expect(alertOverridesStateSource).toContain('getActionableAgentIdFromResource'); expect(alertOverridesStateSource).toContain('pbsInstanceFromResource'); @@ -3858,6 +3875,7 @@ describe('frontend resource type boundaries', () => { "import { ScheduleTab } from './tabs/ScheduleTab';", ); expect(alertsConfigurationSurfaceSource).toContain('useAlertsConfigurationState'); + expect(alertsConfigurationStateSource).toContain('buildAlertsConfigurationPayload'); expect(alertsPageSource).not.toContain('function DestinationsTab('); expect(alertsPageSource).not.toContain('function ScheduleTab('); expect(alertDestinationsTabSource).toContain('useAlertDestinationsTabState');