mirror of
https://github.com/rcourtman/Pulse.git
synced 2026-09-10 02:25:56 +00:00
Bound alert escalation schedule delays
Change-source: pulse-maintainer
This commit is contained in:
@@ -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
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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(),
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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
|
||||
)
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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:
|
||||
|
||||
Reference in New Issue
Block a user