Bound alert escalation schedule delays

Change-source: pulse-maintainer
This commit is contained in:
pulse-triage[bot]
2026-08-27 20:12:36 +01:00
parent 636367e96e
commit 7e1dbd91eb
12 changed files with 88 additions and 47 deletions
@@ -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 5180 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
@@ -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 5180 minute bounds; the
feature state clamps typed values before they can leave the control surface.
+19 -28
View File
@@ -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"
]
}
@@ -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) {
<input
id={afterId()}
type="number"
min="5"
max="180"
min={ESCALATION_DELAY_MIN_MINUTES}
max={ESCALATION_DELAY_MAX_MINUTES}
value={level.after}
onChange={(event) =>
props.setEscalationAfter(index(), event.currentTarget.value)
@@ -195,8 +196,8 @@ export function AlertEscalationSection(props: AlertEscalationSectionProps) {
<input
id={`${fieldIdPrefix}-repeat-every`}
type="number"
min="5"
max="180"
min={ESCALATION_DELAY_MIN_MINUTES}
max={ESCALATION_DELAY_MAX_MINUTES}
value={props.escalation.repeatEvery}
onChange={(event) => props.setEscalationRepeatEvery(event.currentTarget.value)}
class={`${controlClass('px-2 py-1 text-sm')} w-20`}
@@ -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);
@@ -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);
@@ -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 => ({
@@ -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(),
+3
View File
@@ -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)
}
+2
View File
@@ -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
)
+14 -5
View File
@@ -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)
+2 -2
View File
@@ -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: