Extract alert schedule state owner

This commit is contained in:
rcourtman
2026-03-22 14:33:40 +00:00
parent bd362a28da
commit 7ca829e7e9
7 changed files with 532 additions and 249 deletions
@@ -102,6 +102,14 @@ The alerts schedule surface now also routes quiet-hour suppress-category card
styling through `frontend-modern/src/utils/alertSchedulePresentation.ts`
instead of leaving that selectable-card presentation inline in
`frontend-modern/src/pages/Alerts.tsx`.
That schedule surface now also follows the same shell/runtime split as the
other feature tabs: `frontend-modern/src/features/alerts/tabs/ScheduleTab.tsx`
stays the render shell, while
`frontend-modern/src/features/alerts/useAlertScheduleState.ts` owns schedule
reset behavior, quiet-hours day/category toggles, cooldown/grouping/escalation
update policy, and the canonical defaults handoff. Future schedule control-flow
work should extend that hook instead of rebuilding those mutations inline in
the tab shell.
Incident-event filter chip and filter-action styling now routes through
`frontend-modern/src/utils/alertIncidentPresentation.ts` for both
@@ -226,6 +226,13 @@ must both compose
acknowledge/restore behavior rather than keeping duplicate API and notification
logic inline in `useAlertOverviewState.ts` or
`frontend-modern/src/components/Alerts/RecentAlertsPanel.tsx`.
The same feature-owner rule now applies to the alert scheduling surface:
`frontend-modern/src/features/alerts/tabs/ScheduleTab.tsx` must remain the
schedule render shell, while
`frontend-modern/src/features/alerts/useAlertScheduleState.ts` owns schedule
reset/update policy and canonical default application. The tab should not
re-accumulate quiet-hours, cooldown, grouping, or escalation mutation logic
inline.
The updates settings surface now follows the same presentation-owner rule.
`frontend-modern/src/components/Settings/UpdatesSettingsPanel.tsx` stays the
@@ -0,0 +1,82 @@
import { renderHook } from '@solidjs/testing-library';
import { createSignal } from 'solid-js';
import { describe, expect, it } from 'vitest';
import { useAlertScheduleState } from '../useAlertScheduleState';
import {
createDefaultCooldown,
createDefaultEscalation,
createDefaultGrouping,
createDefaultQuietHours,
createDefaultResolveNotifications,
} from '../helpers';
describe('useAlertScheduleState', () => {
it('owns schedule reset and update control flow outside the tab shell', () => {
const [hasUnsavedChanges, setHasUnsavedChanges] = createSignal(false);
const [quietHours, setQuietHours] = createSignal(createDefaultQuietHours());
const [cooldown, setCooldown] = createSignal(createDefaultCooldown());
const [grouping, setGrouping] = createSignal(createDefaultGrouping());
const [notifyOnResolve, setNotifyOnResolve] = createSignal(false);
const [escalation, setEscalation] = createSignal(createDefaultEscalation());
const { result } = renderHook(() =>
useAlertScheduleState({
setHasUnsavedChanges,
quietHours,
setQuietHours,
cooldown,
setCooldown,
grouping,
setGrouping,
notifyOnResolve,
setNotifyOnResolve,
escalation,
setEscalation,
}),
);
result.setQuietHoursEnabled(true);
result.setQuietHoursStart('21:30');
result.toggleQuietDay('saturday');
result.setQuietSuppressCategory('storage', true);
result.setCooldownEnabled(true);
result.setCooldownMinutes('45');
result.setCooldownMaxAlerts('5');
result.setGroupingEnabled(true);
result.setGroupingWindow('8');
result.setGroupingByNode(true);
result.setNotifyOnResolveEnabled(true);
result.setEscalationEnabled(true);
result.addEscalationLevel();
result.setEscalationAfter(0, '30');
result.setEscalationNotify(0, 'webhook');
expect(hasUnsavedChanges()).toBe(true);
expect(quietHours()).toMatchObject({
enabled: true,
start: '21:30',
suppress: expect.objectContaining({ storage: true }),
days: expect.objectContaining({ saturday: true }),
});
expect(result.weekendsOnly()).toBe(false);
expect(cooldown()).toMatchObject({ enabled: true, minutes: 45, maxAlerts: 5 });
expect(grouping()).toMatchObject({ enabled: true, window: 8, byNode: true });
expect(notifyOnResolve()).toBe(true);
expect(escalation()).toMatchObject({
enabled: true,
levels: [expect.objectContaining({ after: 30, notify: 'webhook' })],
});
result.removeEscalationLevel(0);
expect(escalation().levels).toHaveLength(0);
result.resetToDefaults();
expect(quietHours()).toEqual(createDefaultQuietHours());
expect(cooldown()).toEqual(createDefaultCooldown());
expect(grouping()).toEqual(createDefaultGrouping());
expect(notifyOnResolve()).toBe(createDefaultResolveNotifications());
expect(escalation()).toEqual(createDefaultEscalation());
});
});
@@ -68,22 +68,17 @@ import {
} from '@/utils/alertSchedulePresentation';
import {
createDefaultCooldown,
createDefaultEscalation,
createDefaultGrouping,
createDefaultQuietHours,
createDefaultResolveNotifications,
fallbackMaxAlertsPerHour,
} from '../helpers';
ALERT_SCHEDULE_DAYS,
ALERT_SCHEDULE_TIMEZONES,
useAlertScheduleState,
} from '../useAlertScheduleState';
import type {
CooldownConfig,
EscalationConfig,
EscalationLevel,
EscalationNotifyTarget,
GroupingConfig,
QuietHoursConfig,
} from '../types';
import { fallbackCooldownMinutes } from '../types';
export interface ScheduleTabProps {
setHasUnsavedChanges: (value: boolean) => void;
@@ -99,99 +94,9 @@ export interface ScheduleTabProps {
setEscalation: (value: EscalationConfig) => void;
}
const timezones = [
'UTC',
'Africa/Cairo',
'Africa/Johannesburg',
'Africa/Lagos',
'Africa/Nairobi',
'America/Anchorage',
'America/Argentina/Buenos_Aires',
'America/Bogota',
'America/Caracas',
'America/Chicago',
'America/Denver',
'America/Halifax',
'America/Lima',
'America/Los_Angeles',
'America/Mexico_City',
'America/New_York',
'America/Phoenix',
'America/Santiago',
'America/Sao_Paulo',
'America/St_Johns',
'America/Toronto',
'America/Vancouver',
'Asia/Bangkok',
'Asia/Dhaka',
'Asia/Dubai',
'Asia/Hong_Kong',
'Asia/Jakarta',
'Asia/Jerusalem',
'Asia/Karachi',
'Asia/Kolkata',
'Asia/Kuala_Lumpur',
'Asia/Manila',
'Asia/Riyadh',
'Asia/Seoul',
'Asia/Shanghai',
'Asia/Singapore',
'Asia/Taipei',
'Asia/Tehran',
'Asia/Tokyo',
'Australia/Adelaide',
'Australia/Brisbane',
'Australia/Melbourne',
'Australia/Perth',
'Australia/Sydney',
'Europe/Amsterdam',
'Europe/Athens',
'Europe/Berlin',
'Europe/Brussels',
'Europe/Budapest',
'Europe/Copenhagen',
'Europe/Dublin',
'Europe/Helsinki',
'Europe/Istanbul',
'Europe/Lisbon',
'Europe/London',
'Europe/Madrid',
'Europe/Moscow',
'Europe/Oslo',
'Europe/Paris',
'Europe/Prague',
'Europe/Rome',
'Europe/Stockholm',
'Europe/Vienna',
'Europe/Warsaw',
'Europe/Zurich',
'Pacific/Auckland',
'Pacific/Fiji',
'Pacific/Guam',
'Pacific/Honolulu',
];
const days = [
{ id: 'monday', label: 'M', fullLabel: 'Monday' },
{ id: 'tuesday', label: 'T', fullLabel: 'Tuesday' },
{ id: 'wednesday', label: 'W', fullLabel: 'Wednesday' },
{ id: 'thursday', label: 'T', fullLabel: 'Thursday' },
{ id: 'friday', label: 'F', fullLabel: 'Friday' },
{ id: 'saturday', label: 'S', fullLabel: 'Saturday' },
{ id: 'sunday', label: 'S', fullLabel: 'Sunday' },
];
export function ScheduleTab(props: ScheduleTabProps) {
const quietHourSuppressOptions = getAlertConfigQuietHourSuppressOptions();
const resetToDefaults = () => {
props.setQuietHours(createDefaultQuietHours());
props.setCooldown(createDefaultCooldown());
props.setGrouping(createDefaultGrouping());
props.setNotifyOnResolve(createDefaultResolveNotifications());
props.setEscalation(createDefaultEscalation());
props.setHasUnsavedChanges(true);
};
const scheduleState = useAlertScheduleState(props);
return (
<div class="space-y-6">
@@ -204,7 +109,7 @@ export function ScheduleTab(props: ScheduleTabProps) {
</div>
<button
type="button"
onClick={resetToDefaults}
onClick={scheduleState.resetToDefaults}
class="inline-flex items-center gap-2 self-start rounded-md border border-border bg-surface px-3 py-2 text-sm font-medium shadow-sm transition-colors hover:bg-surface-hover"
title={getAlertConfigResetDefaultsTitle()}
>
@@ -233,11 +138,7 @@ export function ScheduleTab(props: ScheduleTabProps) {
<Toggle
checked={props.quietHours().enabled}
onChange={(event) => {
props.setQuietHours({
...props.quietHours(),
enabled: event.currentTarget.checked,
});
props.setHasUnsavedChanges(true);
scheduleState.setQuietHoursEnabled(event.currentTarget.checked);
}}
containerClass="sm:self-start"
label={
@@ -260,11 +161,7 @@ export function ScheduleTab(props: ScheduleTabProps) {
type="time"
value={props.quietHours().start}
onChange={(event) => {
props.setQuietHours({
...props.quietHours(),
start: event.currentTarget.value,
});
props.setHasUnsavedChanges(true);
scheduleState.setQuietHoursStart(event.currentTarget.value);
}}
class={controlClass('font-mono')}
/>
@@ -277,11 +174,7 @@ export function ScheduleTab(props: ScheduleTabProps) {
type="time"
value={props.quietHours().end}
onChange={(event) => {
props.setQuietHours({
...props.quietHours(),
end: event.currentTarget.value,
});
props.setHasUnsavedChanges(true);
scheduleState.setQuietHoursEnd(event.currentTarget.value);
}}
class={controlClass('font-mono')}
/>
@@ -293,15 +186,13 @@ export function ScheduleTab(props: ScheduleTabProps) {
<select
value={props.quietHours().timezone}
onChange={(event) => {
props.setQuietHours({
...props.quietHours(),
timezone: event.currentTarget.value,
});
props.setHasUnsavedChanges(true);
scheduleState.setQuietHoursTimezone(event.currentTarget.value);
}}
class={controlClass('pr-8')}
>
<For each={timezones}>{(timezone) => <option value={timezone}>{timezone}</option>}</For>
<For each={ALERT_SCHEDULE_TIMEZONES}>
{(timezone) => <option value={timezone}>{timezone}</option>}
</For>
</select>
</div>
</div>
@@ -311,17 +202,12 @@ export function ScheduleTab(props: ScheduleTabProps) {
Quiet days
</span>
<div class="grid grid-cols-7 gap-1">
<For each={days}>
<For each={ALERT_SCHEDULE_DAYS}>
{(day) => (
<button
type="button"
onClick={() => {
const currentDays = props.quietHours().days;
props.setQuietHours({
...props.quietHours(),
days: { ...currentDays, [day.id]: !currentDays[day.id] },
});
props.setHasUnsavedChanges(true);
scheduleState.toggleQuietDay(day.id);
}}
title={day.fullLabel}
class={getAlertQuietDayButtonClass(props.quietHours().days[day.id])}
@@ -333,29 +219,11 @@ export function ScheduleTab(props: ScheduleTabProps) {
</div>
<p class="mt-2 text-xs text-muted">
<Show
when={
props.quietHours().days.monday &&
props.quietHours().days.tuesday &&
props.quietHours().days.wednesday &&
props.quietHours().days.thursday &&
props.quietHours().days.friday &&
!props.quietHours().days.saturday &&
!props.quietHours().days.sunday
}
when={scheduleState.weekdaysOnly()}
>
Weekdays only
</Show>
<Show
when={
!props.quietHours().days.monday &&
!props.quietHours().days.tuesday &&
!props.quietHours().days.wednesday &&
!props.quietHours().days.thursday &&
!props.quietHours().days.friday &&
props.quietHours().days.saturday &&
props.quietHours().days.sunday
}
>
<Show when={scheduleState.weekendsOnly()}>
Weekends only
</Show>
</p>
@@ -380,14 +248,10 @@ export function ScheduleTab(props: ScheduleTabProps) {
type="checkbox"
checked={props.quietHours().suppress[option.key]}
onChange={(event) => {
props.setQuietHours({
...props.quietHours(),
suppress: {
...props.quietHours().suppress,
[option.key]: event.currentTarget.checked,
},
});
props.setHasUnsavedChanges(true);
scheduleState.setQuietSuppressCategory(
option.key,
event.currentTarget.checked,
);
}}
class="sr-only"
/>
@@ -432,18 +296,7 @@ export function ScheduleTab(props: ScheduleTabProps) {
<Toggle
checked={props.cooldown().enabled}
onChange={(event) => {
const enabled = event.currentTarget.checked;
const current = props.cooldown();
const next: CooldownConfig = {
...current,
enabled,
};
if (enabled) {
next.minutes = fallbackCooldownMinutes(current.minutes);
next.maxAlerts = fallbackMaxAlertsPerHour(current.maxAlerts);
}
props.setCooldown(next);
props.setHasUnsavedChanges(true);
scheduleState.setCooldownEnabled(event.currentTarget.checked);
}}
containerClass="sm:self-start"
label={
@@ -469,12 +322,7 @@ export function ScheduleTab(props: ScheduleTabProps) {
max="120"
value={props.cooldown().minutes}
onChange={(event) => {
const value = Number.parseInt(event.currentTarget.value, 10);
props.setCooldown({
...props.cooldown(),
minutes: Number.isNaN(value) ? props.cooldown().minutes : value,
});
props.setHasUnsavedChanges(true);
scheduleState.setCooldownMinutes(event.currentTarget.value);
}}
class={controlClass('pr-16')}
/>
@@ -496,12 +344,7 @@ export function ScheduleTab(props: ScheduleTabProps) {
max="10"
value={props.cooldown().maxAlerts}
onChange={(event) => {
const value = Number.parseInt(event.currentTarget.value, 10);
props.setCooldown({
...props.cooldown(),
maxAlerts: Number.isNaN(value) ? props.cooldown().maxAlerts : value,
});
props.setHasUnsavedChanges(true);
scheduleState.setCooldownMaxAlerts(event.currentTarget.value);
}}
class={controlClass('pr-16')}
/>
@@ -523,11 +366,7 @@ export function ScheduleTab(props: ScheduleTabProps) {
<Toggle
checked={props.grouping().enabled}
onChange={(event) => {
props.setGrouping({
...props.grouping(),
enabled: event.currentTarget.checked,
});
props.setHasUnsavedChanges(true);
scheduleState.setGroupingEnabled(event.currentTarget.checked);
}}
containerClass="sm:self-start"
label={
@@ -552,11 +391,7 @@ export function ScheduleTab(props: ScheduleTabProps) {
max="30"
value={props.grouping().window}
onChange={(event) => {
props.setGrouping({
...props.grouping(),
window: Number.parseInt(event.currentTarget.value, 10),
});
props.setHasUnsavedChanges(true);
scheduleState.setGroupingWindow(event.currentTarget.value);
}}
class="flex-1"
/>
@@ -577,11 +412,7 @@ export function ScheduleTab(props: ScheduleTabProps) {
type="checkbox"
checked={props.grouping().byNode}
onChange={(event) => {
props.setGrouping({
...props.grouping(),
byNode: event.currentTarget.checked,
});
props.setHasUnsavedChanges(true);
scheduleState.setGroupingByNode(event.currentTarget.checked);
}}
class="sr-only"
/>
@@ -608,11 +439,7 @@ export function ScheduleTab(props: ScheduleTabProps) {
type="checkbox"
checked={props.grouping().byGuest}
onChange={(event) => {
props.setGrouping({
...props.grouping(),
byGuest: event.currentTarget.checked,
});
props.setHasUnsavedChanges(true);
scheduleState.setGroupingByGuest(event.currentTarget.checked);
}}
class="sr-only"
/>
@@ -646,8 +473,7 @@ export function ScheduleTab(props: ScheduleTabProps) {
<Toggle
checked={props.notifyOnResolve()}
onChange={(event) => {
props.setNotifyOnResolve(event.currentTarget.checked);
props.setHasUnsavedChanges(true);
scheduleState.setNotifyOnResolveEnabled(event.currentTarget.checked);
}}
containerClass="sm:self-start"
label={
@@ -669,11 +495,7 @@ export function ScheduleTab(props: ScheduleTabProps) {
<Toggle
checked={props.escalation().enabled}
onChange={(event) => {
props.setEscalation({
...props.escalation(),
enabled: event.currentTarget.checked,
});
props.setHasUnsavedChanges(true);
scheduleState.setEscalationEnabled(event.currentTarget.checked);
}}
containerClass="sm:self-start"
label={
@@ -702,17 +524,10 @@ export function ScheduleTab(props: ScheduleTabProps) {
max="180"
value={level.after}
onChange={(event) => {
const nextLevels = [...props.escalation().levels];
const parsed = Number.parseInt(event.currentTarget.value, 10);
nextLevels[index()] = {
...level,
after: Number.isNaN(parsed) ? level.after : parsed,
};
props.setEscalation({
...props.escalation(),
levels: nextLevels,
});
props.setHasUnsavedChanges(true);
scheduleState.setEscalationAfter(
index(),
event.currentTarget.value,
);
}}
class={`${controlClass('px-2 py-1 text-sm')} w-20`}
/>
@@ -727,16 +542,10 @@ export function ScheduleTab(props: ScheduleTabProps) {
<select
value={level.notify}
onChange={(event) => {
const nextLevels = [...props.escalation().levels];
nextLevels[index()] = {
...level,
notify: event.currentTarget.value as EscalationNotifyTarget,
};
props.setEscalation({
...props.escalation(),
levels: nextLevels,
});
props.setHasUnsavedChanges(true);
scheduleState.setEscalationNotify(
index(),
event.currentTarget.value as EscalationNotifyTarget,
);
}}
class={`${controlClass('px-2 py-1 text-sm')} flex-1`}
>
@@ -751,15 +560,7 @@ export function ScheduleTab(props: ScheduleTabProps) {
<button
type="button"
onClick={() => {
const nextLevels = props.escalation().levels.filter(
(_level: EscalationLevel, currentIndex: number) =>
currentIndex !== index(),
);
props.setEscalation({
...props.escalation(),
levels: nextLevels,
});
props.setHasUnsavedChanges(true);
scheduleState.removeEscalationLevel(index());
}}
class="rounded-md p-1.5 text-red-600 transition-colors hover:bg-red-100 dark:hover:bg-red-900"
title={ALERT_CONFIG_ESCALATION_REMOVE_TITLE}
@@ -780,17 +581,7 @@ export function ScheduleTab(props: ScheduleTabProps) {
<button
type="button"
onClick={() => {
const lastLevel = props.escalation().levels[props.escalation().levels.length - 1];
const nextAfter =
typeof lastLevel?.after === 'number' ? lastLevel.after + 30 : 15;
props.setEscalation({
...props.escalation(),
levels: [
...props.escalation().levels,
{ after: nextAfter, notify: 'all' as EscalationNotifyTarget },
],
});
props.setHasUnsavedChanges(true);
scheduleState.addEscalationLevel();
}}
class="flex w-full items-center justify-center gap-2 rounded-md border-2 border-dashed border-border py-2 text-sm text-muted transition-all hover:border-slate-400 hover:bg-surface-hover"
>
@@ -0,0 +1,369 @@
import { createMemo } from 'solid-js';
import {
createDefaultCooldown,
createDefaultEscalation,
createDefaultGrouping,
createDefaultQuietHours,
createDefaultResolveNotifications,
fallbackMaxAlertsPerHour,
} from './helpers';
import type {
CooldownConfig,
EscalationConfig,
EscalationLevel,
EscalationNotifyTarget,
GroupingConfig,
QuietHoursConfig,
} from './types';
import { fallbackCooldownMinutes } from './types';
export interface UseAlertScheduleStateProps {
setHasUnsavedChanges: (value: boolean) => void;
quietHours: () => QuietHoursConfig;
setQuietHours: (value: QuietHoursConfig) => void;
cooldown: () => CooldownConfig;
setCooldown: (value: CooldownConfig) => void;
grouping: () => GroupingConfig;
setGrouping: (value: GroupingConfig) => void;
notifyOnResolve: () => boolean;
setNotifyOnResolve: (value: boolean) => void;
escalation: () => EscalationConfig;
setEscalation: (value: EscalationConfig) => void;
}
export const ALERT_SCHEDULE_TIMEZONES = [
'UTC',
'Africa/Cairo',
'Africa/Johannesburg',
'Africa/Lagos',
'Africa/Nairobi',
'America/Anchorage',
'America/Argentina/Buenos_Aires',
'America/Bogota',
'America/Caracas',
'America/Chicago',
'America/Denver',
'America/Halifax',
'America/Lima',
'America/Los_Angeles',
'America/Mexico_City',
'America/New_York',
'America/Phoenix',
'America/Santiago',
'America/Sao_Paulo',
'America/St_Johns',
'America/Toronto',
'America/Vancouver',
'Asia/Bangkok',
'Asia/Dhaka',
'Asia/Dubai',
'Asia/Hong_Kong',
'Asia/Jakarta',
'Asia/Jerusalem',
'Asia/Karachi',
'Asia/Kolkata',
'Asia/Kuala_Lumpur',
'Asia/Manila',
'Asia/Riyadh',
'Asia/Seoul',
'Asia/Shanghai',
'Asia/Singapore',
'Asia/Taipei',
'Asia/Tehran',
'Asia/Tokyo',
'Australia/Adelaide',
'Australia/Brisbane',
'Australia/Melbourne',
'Australia/Perth',
'Australia/Sydney',
'Europe/Amsterdam',
'Europe/Athens',
'Europe/Berlin',
'Europe/Brussels',
'Europe/Budapest',
'Europe/Copenhagen',
'Europe/Dublin',
'Europe/Helsinki',
'Europe/Istanbul',
'Europe/Lisbon',
'Europe/London',
'Europe/Madrid',
'Europe/Moscow',
'Europe/Oslo',
'Europe/Paris',
'Europe/Prague',
'Europe/Rome',
'Europe/Stockholm',
'Europe/Vienna',
'Europe/Warsaw',
'Europe/Zurich',
'Pacific/Auckland',
'Pacific/Fiji',
'Pacific/Guam',
'Pacific/Honolulu',
] as const;
export const ALERT_SCHEDULE_DAYS = [
{ id: 'monday', label: 'M', fullLabel: 'Monday' },
{ id: 'tuesday', label: 'T', fullLabel: 'Tuesday' },
{ id: 'wednesday', label: 'W', fullLabel: 'Wednesday' },
{ id: 'thursday', label: 'T', fullLabel: 'Thursday' },
{ id: 'friday', label: 'F', fullLabel: 'Friday' },
{ id: 'saturday', label: 'S', fullLabel: 'Saturday' },
{ id: 'sunday', label: 'S', fullLabel: 'Sunday' },
] as const;
type AlertScheduleDayId = (typeof ALERT_SCHEDULE_DAYS)[number]['id'];
type QuietSuppressKey = keyof QuietHoursConfig['suppress'];
export function useAlertScheduleState(props: UseAlertScheduleStateProps) {
const markUnsaved = () => {
props.setHasUnsavedChanges(true);
};
const resetToDefaults = () => {
props.setQuietHours(createDefaultQuietHours());
props.setCooldown(createDefaultCooldown());
props.setGrouping(createDefaultGrouping());
props.setNotifyOnResolve(createDefaultResolveNotifications());
props.setEscalation(createDefaultEscalation());
markUnsaved();
};
const setQuietHoursEnabled = (enabled: boolean) => {
props.setQuietHours({
...props.quietHours(),
enabled,
});
markUnsaved();
};
const setQuietHoursStart = (start: string) => {
props.setQuietHours({
...props.quietHours(),
start,
});
markUnsaved();
};
const setQuietHoursEnd = (end: string) => {
props.setQuietHours({
...props.quietHours(),
end,
});
markUnsaved();
};
const setQuietHoursTimezone = (timezone: string) => {
props.setQuietHours({
...props.quietHours(),
timezone,
});
markUnsaved();
};
const toggleQuietDay = (dayId: AlertScheduleDayId) => {
const currentQuietHours = props.quietHours();
props.setQuietHours({
...currentQuietHours,
days: {
...currentQuietHours.days,
[dayId]: !currentQuietHours.days[dayId],
},
});
markUnsaved();
};
const setQuietSuppressCategory = (key: QuietSuppressKey, enabled: boolean) => {
const currentQuietHours = props.quietHours();
props.setQuietHours({
...currentQuietHours,
suppress: {
...currentQuietHours.suppress,
[key]: enabled,
},
});
markUnsaved();
};
const weekdaysOnly = createMemo(() => {
const currentDays = props.quietHours().days;
return (
currentDays.monday &&
currentDays.tuesday &&
currentDays.wednesday &&
currentDays.thursday &&
currentDays.friday &&
!currentDays.saturday &&
!currentDays.sunday
);
});
const weekendsOnly = createMemo(() => {
const currentDays = props.quietHours().days;
return (
!currentDays.monday &&
!currentDays.tuesday &&
!currentDays.wednesday &&
!currentDays.thursday &&
!currentDays.friday &&
currentDays.saturday &&
currentDays.sunday
);
});
const setCooldownEnabled = (enabled: boolean) => {
const currentCooldown = props.cooldown();
const nextCooldown: CooldownConfig = {
...currentCooldown,
enabled,
};
if (enabled) {
nextCooldown.minutes = fallbackCooldownMinutes(currentCooldown.minutes);
nextCooldown.maxAlerts = fallbackMaxAlertsPerHour(currentCooldown.maxAlerts);
}
props.setCooldown(nextCooldown);
markUnsaved();
};
const setCooldownMinutes = (rawValue: string) => {
const parsed = Number.parseInt(rawValue, 10);
props.setCooldown({
...props.cooldown(),
minutes: Number.isNaN(parsed) ? props.cooldown().minutes : parsed,
});
markUnsaved();
};
const setCooldownMaxAlerts = (rawValue: string) => {
const parsed = Number.parseInt(rawValue, 10);
props.setCooldown({
...props.cooldown(),
maxAlerts: Number.isNaN(parsed) ? props.cooldown().maxAlerts : parsed,
});
markUnsaved();
};
const setGroupingEnabled = (enabled: boolean) => {
props.setGrouping({
...props.grouping(),
enabled,
});
markUnsaved();
};
const setGroupingWindow = (rawValue: string) => {
props.setGrouping({
...props.grouping(),
window: Number.parseInt(rawValue, 10),
});
markUnsaved();
};
const setGroupingByNode = (enabled: boolean) => {
props.setGrouping({
...props.grouping(),
byNode: enabled,
});
markUnsaved();
};
const setGroupingByGuest = (enabled: boolean) => {
props.setGrouping({
...props.grouping(),
byGuest: enabled,
});
markUnsaved();
};
const setNotifyOnResolveEnabled = (enabled: boolean) => {
props.setNotifyOnResolve(enabled);
markUnsaved();
};
const setEscalationEnabled = (enabled: boolean) => {
props.setEscalation({
...props.escalation(),
enabled,
});
markUnsaved();
};
const setEscalationAfter = (index: number, rawValue: string) => {
const nextLevels = [...props.escalation().levels];
const currentLevel = nextLevels[index];
const parsed = Number.parseInt(rawValue, 10);
nextLevels[index] = {
...currentLevel,
after: Number.isNaN(parsed) ? currentLevel.after : parsed,
};
props.setEscalation({
...props.escalation(),
levels: nextLevels,
});
markUnsaved();
};
const setEscalationNotify = (index: number, notify: EscalationNotifyTarget) => {
const nextLevels = [...props.escalation().levels];
nextLevels[index] = {
...nextLevels[index],
notify,
};
props.setEscalation({
...props.escalation(),
levels: nextLevels,
});
markUnsaved();
};
const removeEscalationLevel = (index: number) => {
const nextLevels = props.escalation().levels.filter(
(_level: EscalationLevel, currentIndex: number) => currentIndex !== index,
);
props.setEscalation({
...props.escalation(),
levels: nextLevels,
});
markUnsaved();
};
const addEscalationLevel = () => {
const lastLevel = props.escalation().levels[props.escalation().levels.length - 1];
const nextAfter = typeof lastLevel?.after === 'number' ? lastLevel.after + 30 : 15;
props.setEscalation({
...props.escalation(),
levels: [
...props.escalation().levels,
{ after: nextAfter, notify: 'all' as EscalationNotifyTarget },
],
});
markUnsaved();
};
return {
weekdaysOnly,
weekendsOnly,
resetToDefaults,
setQuietHoursEnabled,
setQuietHoursStart,
setQuietHoursEnd,
setQuietHoursTimezone,
toggleQuietDay,
setQuietSuppressCategory,
setCooldownEnabled,
setCooldownMinutes,
setCooldownMaxAlerts,
setGroupingEnabled,
setGroupingWindow,
setGroupingByNode,
setGroupingByGuest,
setNotifyOnResolveEnabled,
setEscalationEnabled,
setEscalationAfter,
setEscalationNotify,
removeEscalationLevel,
addEscalationLevel,
};
}
@@ -7,6 +7,7 @@ import alertAcknowledgementStateSource from '@/features/alerts/useAlertAcknowled
import alertHistoryStateSource from '@/features/alerts/useAlertHistoryState.ts?raw';
import alertIncidentTimelineStateSource from '@/features/alerts/useAlertIncidentTimelineState.ts?raw';
import alertOverviewStateSource from '@/features/alerts/useAlertOverviewState.ts?raw';
import alertScheduleStateSource from '@/features/alerts/useAlertScheduleState.ts?raw';
import alertDestinationsTabSource from '@/features/alerts/tabs/DestinationsTab.tsx?raw';
import alertHistoryTabSource from '@/features/alerts/tabs/HistoryTab.tsx?raw';
import alertOverviewTabSource from '@/features/alerts/OverviewTab.tsx?raw';
@@ -241,6 +242,18 @@ describe('tab path helpers', () => {
expect(recentAlertsPanelSource).toContain('useAlertAcknowledgementState');
expect(recentAlertsPanelSource).not.toContain('AlertsAPI.bulkAcknowledge');
expect(recentAlertsPanelSource).not.toContain('AlertsAPI.acknowledge');
expect(alertScheduleTabSource).toContain('useAlertScheduleState');
expect(alertScheduleTabSource).not.toContain('createDefaultQuietHours');
expect(alertScheduleTabSource).not.toContain('createDefaultCooldown');
expect(alertScheduleTabSource).not.toContain('createDefaultGrouping');
expect(alertScheduleTabSource).not.toContain('createDefaultEscalation');
expect(alertScheduleTabSource).not.toContain('const timezones = [');
expect(alertScheduleTabSource).not.toContain('const days = [');
expect(alertScheduleStateSource).toContain('export function useAlertScheduleState');
expect(alertScheduleStateSource).toContain('createDefaultQuietHours');
expect(alertScheduleStateSource).toContain('createDefaultCooldown');
expect(alertScheduleStateSource).toContain('createDefaultGrouping');
expect(alertScheduleStateSource).toContain('createDefaultEscalation');
expect(alertScheduleTabSource).toContain('getAlertConfigQuietHourSuppressOptions');
expect(alertThresholdsTabSource).toContain('ThresholdsTable');
expect(thresholdsTableSource).toContain(
@@ -331,6 +331,7 @@ import alertAcknowledgementStateSource from '@/features/alerts/useAlertAcknowled
import alertHistoryStateSource from '@/features/alerts/useAlertHistoryState.ts?raw';
import alertIncidentTimelineStateSource from '@/features/alerts/useAlertIncidentTimelineState.ts?raw';
import alertOverviewStateSource from '@/features/alerts/useAlertOverviewState.ts?raw';
import alertScheduleStateSource from '@/features/alerts/useAlertScheduleState.ts?raw';
import alertDestinationsTabSource from '@/features/alerts/tabs/DestinationsTab.tsx?raw';
import alertHistoryTabSource from '@/features/alerts/tabs/HistoryTab.tsx?raw';
import alertScheduleTabSource from '@/features/alerts/tabs/ScheduleTab.tsx?raw';
@@ -3669,6 +3670,18 @@ describe('frontend resource type boundaries', () => {
expect(recentAlertsPanelSource).toContain('useAlertAcknowledgementState');
expect(recentAlertsPanelSource).not.toContain('AlertsAPI.bulkAcknowledge');
expect(recentAlertsPanelSource).not.toContain('AlertsAPI.acknowledge');
expect(alertScheduleTabSource).toContain('useAlertScheduleState');
expect(alertScheduleTabSource).not.toContain('createDefaultQuietHours');
expect(alertScheduleTabSource).not.toContain('createDefaultCooldown');
expect(alertScheduleTabSource).not.toContain('createDefaultGrouping');
expect(alertScheduleTabSource).not.toContain('createDefaultEscalation');
expect(alertScheduleTabSource).not.toContain('const timezones = [');
expect(alertScheduleTabSource).not.toContain('const days = [');
expect(alertScheduleStateSource).toContain('export function useAlertScheduleState');
expect(alertScheduleStateSource).toContain('createDefaultQuietHours');
expect(alertScheduleStateSource).toContain('createDefaultCooldown');
expect(alertScheduleStateSource).toContain('createDefaultGrouping');
expect(alertScheduleStateSource).toContain('createDefaultEscalation');
expect(alertOverviewPresentationSource).toContain(
'export function getAlertTimelineLoadingState',
);