diff --git a/docs/release-control/v6/internal/subsystems/alerts.md b/docs/release-control/v6/internal/subsystems/alerts.md index 88d78dcdd..3143d73c7 100644 --- a/docs/release-control/v6/internal/subsystems/alerts.md +++ b/docs/release-control/v6/internal/subsystems/alerts.md @@ -2200,6 +2200,12 @@ global detection or delivery controls are paused. A repeat is durable escalation evidence with an explicit repeat marker rather than a new incident or a replay of missed intervals. +Every escalation level delay uses the same 5–180 minute safety boundary at the +frontend state owner and the backend configuration-normalization boundary. +Malformed API payloads and manually entered values therefore cannot turn a +level into an immediate escalation or an unreachable timer beyond the supported +schedule range. + The Schedule surface loads the same email, Apprise, and webhook catalog as the Destinations surface. It shows disabled and deleted selections explicitly, prevents an escalation level from becoming destinationless, and explains the diff --git a/docs/release-control/v6/internal/subsystems/frontend-primitives.md b/docs/release-control/v6/internal/subsystems/frontend-primitives.md index 41a3ccf07..91e054936 100644 --- a/docs/release-control/v6/internal/subsystems/frontend-primitives.md +++ b/docs/release-control/v6/internal/subsystems/frontend-primitives.md @@ -6704,4 +6704,6 @@ used by the Notifications tab, preserves selected-but-disabled and deleted entries visibly, and prevents the final selection from being removed. The critical-repeat control exposes a bounded numeric interval only when enabled and states every lifecycle condition that stops paging. Desktop and mobile -layouts must retain associated labels and avoid horizontal overflow. +layouts must retain associated labels and avoid horizontal overflow. Escalation +level delays and repeat intervals share the rendered 5–180 minute bounds; the +feature state clamps typed values before they can leave the control surface. diff --git a/frontend-modern/browser-verification.json b/frontend-modern/browser-verification.json index a79b4887d..da980c9eb 100644 --- a/frontend-modern/browser-verification.json +++ b/frontend-modern/browser-verification.json @@ -1,43 +1,34 @@ { "version": 1, - "base_sha": "e0baecc418d15a6638ef80ed1e2b39d08aa9a5ad", - "verified_at": "2026-08-27T18:57:57Z", + "base_sha": "636367e96ee97ce56c7b1353635236a418a3922c", + "verified_at": "2026-08-27T19:10:47Z", "result": "passed", "changed_paths": [ - "frontend-modern/src/components/Storage/StorageDetailKeyValueRow.tsx", - "frontend-modern/src/components/shared/InfoCardFrame.tsx", - "frontend-modern/src/components/shared/cards/DisksCard.tsx", - "frontend-modern/src/components/shared/cards/HardwareCard.tsx", - "frontend-modern/src/components/shared/cards/RootDiskCard.tsx", - "frontend-modern/src/components/shared/cards/SystemInfoCard.tsx", - "frontend-modern/src/components/shared/cards/TemperaturesCard.tsx", - "frontend-modern/src/features/storageBackups/detailPresentation.ts" + "frontend-modern/src/features/alerts/AlertEscalationSection.tsx", + "frontend-modern/src/features/alerts/helpers.ts", + "frontend-modern/src/features/alerts/useAlertScheduleState.ts" ], "content_sha256": { - "frontend-modern/src/components/Storage/StorageDetailKeyValueRow.tsx": "db827a709c0f018c24dd0821ae9845022a2ba15c7052b6471926957aafdf6270", - "frontend-modern/src/components/shared/InfoCardFrame.tsx": "0819bce063e2caf72ffbbd3beb398fd4776a7fe089da7eac5c899bd92fffe66c", - "frontend-modern/src/components/shared/cards/DisksCard.tsx": "f9721f90d6b1c1a7030bbc1767c90a4d353d8c4a82843e47ad37aae8f687c46d", - "frontend-modern/src/components/shared/cards/HardwareCard.tsx": "66df4609cc019cf5a7d0928abb82a2ad88f99683cd277ee1f0e38b9b48d281dc", - "frontend-modern/src/components/shared/cards/RootDiskCard.tsx": "48a1698a5781639a2fcb027837edc6c7b580e392b37041c89911f23c71d80fc6", - "frontend-modern/src/components/shared/cards/SystemInfoCard.tsx": "723fab36dbd6eeaf882f6eb89d2d0c7c276f5b930c17bfcbc44413a13f531880", - "frontend-modern/src/components/shared/cards/TemperaturesCard.tsx": "f91cecf1de23abec0b7b571b02516643206ba2b27f7cb37cd8b941f2588229dd", - "frontend-modern/src/features/storageBackups/detailPresentation.ts": "6dd2da6052aa37e6cfe2488356a296c578c26f7aed3ee8e49f6a5362135b34e7" + "frontend-modern/src/features/alerts/AlertEscalationSection.tsx": "f80f94d529196fffb4e184288c5ce9369f673bc8cabc0eb2047ebc10b7dfe363", + "frontend-modern/src/features/alerts/helpers.ts": "27feef6ede9bdb6668af5c447c5b9256f019b8df5a291ae8b5d436d371363794", + "frontend-modern/src/features/alerts/useAlertScheduleState.ts": "84ec18253c926be70d941daf5b56bf045cf372ded5026033849d38f76c23e84f" }, - "routes": ["/standalone/machines", "/proxmox/storage"], + "routes": ["/alerts/schedule"], "viewports": [ - { "width": 1920, "height": 800 }, + { "width": 1280, "height": 720 }, { "width": 390, "height": 844 } ], "states": [ - "Apollo-114 expanded with Platform details and Machine details visible", - "west-f-service-pool expanded with Configuration and ZFS Pool cards visible" + "alert escalation enabled with one level and selected disabled destinations visible", + "level delay normalized to the 5 minute lower bound", + "level delay normalized to the 180 minute upper bound", + "upper-bound level retained in the narrow layout without horizontal overflow" ], "interactions": [ - "expanded the Apollo-114 row, opened Platform details, and revealed the nested Machine details", - "confirmed System, Hardware, Disks, and temperature facts use an adjacent fixed label track at 1920px", - "confirmed machine facts retain condensed right-aligned values and clean wrapping at 390px", - "expanded west-f-service-pool and confirmed Configuration values sit adjacent to their labels at 1920px", - "confirmed storage Configuration values retain condensed right alignment and clean wrapping at 390px", - "reloaded the phone machine route and repeated the complete disclosure interaction" + "enabled alert escalation and added an escalation level", + "entered 0 minutes and blurred the field, confirming it normalized to 5", + "entered 999 minutes and blurred the field, confirming it normalized to 180", + "resized from desktop to 390px and confirmed zero document-level horizontal overflow", + "confirmed the browser console contained no warnings or errors" ] } diff --git a/frontend-modern/src/features/alerts/AlertEscalationSection.tsx b/frontend-modern/src/features/alerts/AlertEscalationSection.tsx index 241f3a382..2e319a43b 100644 --- a/frontend-modern/src/features/alerts/AlertEscalationSection.tsx +++ b/frontend-modern/src/features/alerts/AlertEscalationSection.tsx @@ -22,6 +22,7 @@ import { } from '@/utils/alertConfigPresentation'; import type { EscalationConfig, EscalationDestination, EscalationNotifyTarget } from './types'; +import { ESCALATION_DELAY_MAX_MINUTES, ESCALATION_DELAY_MIN_MINUTES } from './helpers'; interface AlertEscalationSectionProps { escalation: EscalationConfig; @@ -102,8 +103,8 @@ export function AlertEscalationSection(props: AlertEscalationSectionProps) { props.setEscalationAfter(index(), event.currentTarget.value) @@ -195,8 +196,8 @@ export function AlertEscalationSection(props: AlertEscalationSectionProps) { props.setEscalationRepeatEvery(event.currentTarget.value)} class={`${controlClass('px-2 py-1 text-sm')} w-20`} diff --git a/frontend-modern/src/features/alerts/__tests__/helpers.test.ts b/frontend-modern/src/features/alerts/__tests__/helpers.test.ts index 8a7519a14..160cc8da7 100644 --- a/frontend-modern/src/features/alerts/__tests__/helpers.test.ts +++ b/frontend-modern/src/features/alerts/__tests__/helpers.test.ts @@ -10,9 +10,20 @@ import { createDefaultAppriseConfig, createDefaultEmailConfig, alertTypeDisplayLabel, + clampEscalationDelayMinutes, } from '@/features/alerts/helpers'; describe('alerts helpers', () => { + describe('clampEscalationDelayMinutes', () => { + it('keeps escalation schedules inside the supported interval', () => { + expect(clampEscalationDelayMinutes(-1, 30)).toBe(5); + expect(clampEscalationDelayMinutes(15, 30)).toBe(15); + expect(clampEscalationDelayMinutes(181, 30)).toBe(180); + expect(clampEscalationDelayMinutes(Number.NaN, 30)).toBe(30); + expect(clampEscalationDelayMinutes(Number.NaN, Number.NaN)).toBe(5); + }); + }); + describe('clampMaxAlertsPerHour', () => { it('returns default min for NaN', () => { expect(clampMaxAlertsPerHour(NaN)).toBe(1); diff --git a/frontend-modern/src/features/alerts/__tests__/useAlertScheduleState.test.tsx b/frontend-modern/src/features/alerts/__tests__/useAlertScheduleState.test.tsx index 60b8cbfda..ccedc8d5e 100644 --- a/frontend-modern/src/features/alerts/__tests__/useAlertScheduleState.test.tsx +++ b/frontend-modern/src/features/alerts/__tests__/useAlertScheduleState.test.tsx @@ -59,7 +59,7 @@ describe('useAlertScheduleState', () => { result.setNotifyOnResolveEnabled(true); result.setEscalationEnabled(true); result.addEscalationLevel(); - result.setEscalationAfter(0, '30'); + result.setEscalationAfter(0, '0'); result.setEscalationNotify(0, 'webhook'); result.setEscalationDestinationIds(0, ['webhook:pager']); result.setEscalationRepeatCritical(true); @@ -83,13 +83,18 @@ describe('useAlertScheduleState', () => { repeatEvery: 15, levels: [ expect.objectContaining({ - after: 30, + after: 5, notify: 'webhook', destinationIds: ['webhook:pager'], }), ], }); + result.setEscalationAfter(0, '180'); + result.addEscalationLevel(); + expect(escalation().levels[1].after).toBe(180); + + result.removeEscalationLevel(1); result.removeEscalationLevel(0); expect(escalation().levels).toHaveLength(0); diff --git a/frontend-modern/src/features/alerts/helpers.ts b/frontend-modern/src/features/alerts/helpers.ts index be876f583..22f62a0b7 100644 --- a/frontend-modern/src/features/alerts/helpers.ts +++ b/frontend-modern/src/features/alerts/helpers.ts @@ -36,6 +36,15 @@ export const fallbackMaxAlertsPerHour = (value?: number): number => { return clampMaxAlertsPerHour(numericValue); }; +export const ESCALATION_DELAY_MIN_MINUTES = 5; +export const ESCALATION_DELAY_MAX_MINUTES = 180; + +export const clampEscalationDelayMinutes = (value: number, fallback: number): number => { + const candidate = Number.isFinite(value) ? value : fallback; + if (!Number.isFinite(candidate)) return ESCALATION_DELAY_MIN_MINUTES; + return Math.min(ESCALATION_DELAY_MAX_MINUTES, Math.max(ESCALATION_DELAY_MIN_MINUTES, candidate)); +}; + export const getLocalTimezone = () => Intl.DateTimeFormat().resolvedOptions().timeZone || 'UTC'; export const createDefaultQuietHours = (): QuietHoursConfig => ({ diff --git a/frontend-modern/src/features/alerts/useAlertScheduleState.ts b/frontend-modern/src/features/alerts/useAlertScheduleState.ts index dab762723..58c89ff01 100644 --- a/frontend-modern/src/features/alerts/useAlertScheduleState.ts +++ b/frontend-modern/src/features/alerts/useAlertScheduleState.ts @@ -6,6 +6,7 @@ import { createDefaultGrouping, createDefaultQuietHours, createDefaultResolveNotifications, + clampEscalationDelayMinutes, fallbackMaxAlertsPerHour, } from './helpers'; import type { @@ -308,7 +309,7 @@ export function useAlertScheduleState(props: UseAlertScheduleStateProps) { const parsed = Number.parseInt(rawValue, 10); nextLevels[index] = { ...currentLevel, - after: Number.isNaN(parsed) ? currentLevel.after : parsed, + after: clampEscalationDelayMinutes(parsed, currentLevel.after), }; props.setEscalation({ ...props.escalation(), @@ -357,9 +358,7 @@ export function useAlertScheduleState(props: UseAlertScheduleStateProps) { const parsed = Number.parseInt(rawValue, 10); props.setEscalation({ ...props.escalation(), - repeatEvery: Number.isNaN(parsed) - ? props.escalation().repeatEvery - : Math.min(180, Math.max(5, parsed)), + repeatEvery: clampEscalationDelayMinutes(parsed, props.escalation().repeatEvery), }); markUnsaved(); }; @@ -377,7 +376,10 @@ export function useAlertScheduleState(props: UseAlertScheduleStateProps) { const addEscalationLevel = () => { const lastLevel = props.escalation().levels[props.escalation().levels.length - 1]; - const nextAfter = typeof lastLevel?.after === 'number' ? lastLevel.after + 30 : 15; + const nextAfter = clampEscalationDelayMinutes( + typeof lastLevel?.after === 'number' ? lastLevel.after + 30 : 15, + 15, + ); const destinationIds = props.escalationDestinations().map((destination) => destination.id); props.setEscalation({ ...props.escalation(), diff --git a/internal/alerts/config/types.go b/internal/alerts/config/types.go index b2baebf89..83b64372f 100644 --- a/internal/alerts/config/types.go +++ b/internal/alerts/config/types.go @@ -135,6 +135,8 @@ func NormalizeNotificationDeliveryTarget(target string) string { } const ( + MinEscalationDelayMinutes = 5 + MaxEscalationDelayMinutes = 180 DefaultEscalationRepeatMinutes = 30 MinEscalationRepeatMinutes = 5 MaxEscalationRepeatMinutes = 180 @@ -185,6 +187,7 @@ func NormalizeEscalationConfig(config *EscalationConfig) { return } for index := range config.Levels { + config.Levels[index].After = max(MinEscalationDelayMinutes, min(MaxEscalationDelayMinutes, config.Levels[index].After)) config.Levels[index].Notify = NormalizeNotificationDeliveryTarget(config.Levels[index].Notify) config.Levels[index].DestinationIDs = NormalizeEscalationDestinationIDs(config.Levels[index].DestinationIDs) } diff --git a/internal/alerts/config_facade.go b/internal/alerts/config_facade.go index e020ff3dc..b27cd84a1 100644 --- a/internal/alerts/config_facade.go +++ b/internal/alerts/config_facade.go @@ -43,6 +43,8 @@ const ( AlertIntentSignalOffline = alertconfig.AlertIntentSignalOffline AlertIntentSignalAvailability = alertconfig.AlertIntentSignalAvailability DefaultEscalationRepeatMinutes = alertconfig.DefaultEscalationRepeatMinutes + MinEscalationDelayMinutes = alertconfig.MinEscalationDelayMinutes + MaxEscalationDelayMinutes = alertconfig.MaxEscalationDelayMinutes MinEscalationRepeatMinutes = alertconfig.MinEscalationRepeatMinutes MaxEscalationRepeatMinutes = alertconfig.MaxEscalationRepeatMinutes ) diff --git a/internal/alerts/config_validation_test.go b/internal/alerts/config_validation_test.go index 5b8f5a0ec..74a9fc860 100644 --- a/internal/alerts/config_validation_test.go +++ b/internal/alerts/config_validation_test.go @@ -90,11 +90,14 @@ func TestUpdateConfigNormalizesExactEscalationRoutingAndRepeatPolicy(t *testing. Enabled: true, RepeatCritical: true, RepeatEvery: 1, - Levels: []EscalationLevel{{ - After: 15, - Notify: "WEBHOOKS", - DestinationIDs: []string{" webhook:pager ", "email", "webhook:pager", "https://secret.example"}, - }}, + Levels: []EscalationLevel{ + { + After: -1, + Notify: "WEBHOOKS", + DestinationIDs: []string{" webhook:pager ", "email", "webhook:pager", "https://secret.example"}, + }, + {After: 181, Notify: "email"}, + }, } m.UpdateConfig(cfg) @@ -106,6 +109,12 @@ func TestUpdateConfigNormalizesExactEscalationRoutingAndRepeatPolicy(t *testing. if got.Levels[0].Notify != "webhook" { t.Fatalf("legacy target = %q, want webhook", got.Levels[0].Notify) } + if got.Levels[0].After != MinEscalationDelayMinutes { + t.Fatalf("minimum escalation delay = %d, want %d", got.Levels[0].After, MinEscalationDelayMinutes) + } + if got.Levels[1].After != MaxEscalationDelayMinutes { + t.Fatalf("maximum escalation delay = %d, want %d", got.Levels[1].After, MaxEscalationDelayMinutes) + } wantIDs := []string{"webhook:pager", "email"} if len(got.Levels[0].DestinationIDs) != len(wantIDs) { t.Fatalf("destination IDs = %v, want %v", got.Levels[0].DestinationIDs, wantIDs) diff --git a/internal/alerts/snooze_lifecycle_test.go b/internal/alerts/snooze_lifecycle_test.go index 30ad67650..50316cf17 100644 --- a/internal/alerts/snooze_lifecycle_test.go +++ b/internal/alerts/snooze_lifecycle_test.go @@ -115,7 +115,7 @@ func TestSnoozeExpiryResumesEscalationWithoutReplayingMissedLevels(t *testing.T) cfg.Enabled = true cfg.ActivationState = ActivationActive cfg.Schedule.Escalation.Enabled = true - cfg.Schedule.Escalation.Levels = []EscalationLevel{{After: 1, Notify: "all"}} + cfg.Schedule.Escalation.Levels = []EscalationLevel{{After: MinEscalationDelayMinutes, Notify: "all"}} m.UpdateConfig(cfg) alert := &Alert{ID: "disk:vm/100", CanonicalState: "disk:vm/100", CanonicalSpecID: "disk", Type: "disk", Level: AlertLevelCritical, ResourceID: "vm/100", ResourceName: "vm-100", StartTime: now.Add(-time.Hour), LastSeen: now} m.mu.Lock() @@ -139,7 +139,7 @@ func TestSnoozeExpiryResumesEscalationWithoutReplayingMissedLevels(t *testing.T) t.Fatal("expired snooze did not reopen the alert") } - now = now.Add(2 * time.Minute) + now = now.Add((MinEscalationDelayMinutes + 1) * time.Minute) m.checkEscalations() select { case <-escalations: