diff --git a/frontend/src/components/settings/NotificationSuppressionSection.tsx b/frontend/src/components/settings/NotificationSuppressionSection.tsx index 06af75ee..ecc9bb65 100644 --- a/frontend/src/components/settings/NotificationSuppressionSection.tsx +++ b/frontend/src/components/settings/NotificationSuppressionSection.tsx @@ -41,6 +41,7 @@ interface NotificationSuppressionRule { enabled: boolean; expires_at: number | null; schedule: MuteRuleSchedule | null; + scheduleInvalid: boolean; created_at: number; updated_at: number; } @@ -162,6 +163,11 @@ export function NotificationSuppressionSection({ const [formScheduleDays, setFormScheduleDays] = useState([]); const [formScheduleStart, setFormScheduleStart] = useState('02:00'); const [formScheduleEnd, setFormScheduleEnd] = useState('06:00'); + // True when editing a rule whose stored schedule could not be read. Its window + // must not be silently cleared: the operator has to touch the controls first. + const [formScheduleInvalid, setFormScheduleInvalid] = useState(false); + const [formScheduleTouched, setFormScheduleTouched] = useState(false); + const markScheduleTouched = () => setFormScheduleTouched(true); const fetchRules = useCallback(async () => { try { @@ -215,6 +221,8 @@ export function NotificationSuppressionSection({ setFormScheduleDays([]); setFormScheduleStart('02:00'); setFormScheduleEnd('06:00'); + setFormScheduleInvalid(false); + setFormScheduleTouched(false); setEditingId(null); setShowForm(true); onPrefillConsumed?.(); @@ -235,6 +243,8 @@ export function NotificationSuppressionSection({ setFormScheduleDays([]); setFormScheduleStart('02:00'); setFormScheduleEnd('06:00'); + setFormScheduleInvalid(false); + setFormScheduleTouched(false); setEditingId(null); setShowForm(false); }; @@ -263,11 +273,19 @@ export function NotificationSuppressionSection({ setFormScheduleStart('02:00'); setFormScheduleEnd('06:00'); } + setFormScheduleInvalid(rule.scheduleInvalid); + setFormScheduleTouched(false); setShowForm(true); }; const handleSave = async () => { if (!formName.trim()) { toast.error('Name is required.'); return; } + if (formScheduleInvalid && !formScheduleTouched) { + toast.error( + "This rule's stored weekly window could not be read. Turn the weekly window on to set a new schedule, or toggle it off then on to confirm clearing it.", + ); + return; + } const preparedPatterns = patternChipsRef.current?.prepareSave(); if (!preparedPatterns?.ok) { toast.error('Fix invalid stack patterns before saving.'); @@ -566,11 +584,18 @@ export function NotificationSuppressionSection({
{ setFormScheduleEnabled(v); markScheduleTouched(); }} id="mute-rule-schedule" />
+ {formScheduleInvalid && !formScheduleTouched && ( +

+ Stored weekly window could not be read and was not applied (alerts have been + delivering normally). Configure a new window above, or toggle it on then off to + confirm clearing it. +

+ )} {formScheduleEnabled && (
@@ -591,6 +616,7 @@ export function NotificationSuppressionSection({ ? prev.filter((d) => d !== day) : [...prev, day].sort((a, b) => a - b), ); + markScheduleTouched(); }} > {label} @@ -605,7 +631,7 @@ export function NotificationSuppressionSection({ id="mute-schedule-start" type="time" value={formScheduleStart} - onChange={(e) => setFormScheduleStart(e.target.value)} + onChange={(e) => { setFormScheduleStart(e.target.value); markScheduleTouched(); }} />
@@ -614,7 +640,7 @@ export function NotificationSuppressionSection({ id="mute-schedule-end" type="time" value={formScheduleEnd} - onChange={(e) => setFormScheduleEnd(e.target.value)} + onChange={(e) => { setFormScheduleEnd(e.target.value); markScheduleTouched(); }} />
@@ -673,6 +699,11 @@ export function NotificationSuppressionSection({ {rule.expires_at != null && rule.expires_at <= Date.now() && ( Expired )} + {rule.scheduleInvalid && ( + + Invalid schedule + + )}
handleToggleEnabled(rule)} className="scale-75" /> diff --git a/frontend/src/components/settings/__tests__/NotificationSuppressionSection.test.tsx b/frontend/src/components/settings/__tests__/NotificationSuppressionSection.test.tsx index 8fd53da3..c5b88f4d 100644 --- a/frontend/src/components/settings/__tests__/NotificationSuppressionSection.test.tsx +++ b/frontend/src/components/settings/__tests__/NotificationSuppressionSection.test.tsx @@ -60,6 +60,24 @@ const scheduledRule = { end_minute: 360, tz: 'UTC', }, + scheduleInvalid: false, + created_at: 1, + updated_at: 1, +}; + +const corruptScheduleRule = { + id: 43, + name: 'Corrupt window', + node_id: null, + stack_patterns: [], + label_ids: null, + categories: null, + levels: null, + applies_to: 'both', + enabled: true, + expires_at: null, + schedule: null, + scheduleInvalid: true, created_at: 1, updated_at: 1, }; @@ -269,4 +287,85 @@ describe('NotificationSuppressionSection', () => { expect(body.schedule).toBeNull(); }); }); + + it('marks a corrupt stored schedule as invalid instead of an ordinary unscheduled rule', async () => { + mockListRules([corruptScheduleRule]); + render(); + await waitFor(() => expect(screen.getByText('Corrupt window')).toBeInTheDocument()); + expect(screen.getByText('Invalid schedule')).toBeInTheDocument(); + }); + + it('blocks saving a corrupt-schedule rule until the operator touches the weekly window', async () => { + mockListRules([corruptScheduleRule]); + render(); + await waitFor(() => expect(screen.getByText('Corrupt window')).toBeInTheDocument()); + + await userEvent.click(screen.getByTitle('Edit')); + await waitFor(() => expect(screen.getByRole('dialog', { name: /Edit mute rule/i })).toBeInTheDocument()); + expect(screen.getByLabelText(/Weekly window \(UTC\)/i)).toHaveAttribute('aria-checked', 'false'); + + await userEvent.click(screen.getByRole('button', { name: /Create|Update/i })); + await waitFor(() => { + expect(toast.error).toHaveBeenCalledWith( + expect.stringContaining('could not be read'), + ); + }); + const puts = mockedFetch.mock.calls.filter( + ([url, opts]) => url === '/notification-suppression-rules/43' && (opts as { method?: string })?.method === 'PUT', + ); + expect(puts).toHaveLength(0); + }); + + it('allows saving a corrupt-schedule rule once the operator explicitly clears it', async () => { + mockListRules([corruptScheduleRule]); + render(); + await waitFor(() => expect(screen.getByText('Corrupt window')).toBeInTheDocument()); + + await userEvent.click(screen.getByTitle('Edit')); + await waitFor(() => expect(screen.getByRole('dialog', { name: /Edit mute rule/i })).toBeInTheDocument()); + + // Explicit acknowledgement: turn the window on, then off again, to intentionally clear it. + await userEvent.click(screen.getByLabelText(/Weekly window \(UTC\)/i)); + await userEvent.click(screen.getByLabelText(/Weekly window \(UTC\)/i)); + + await userEvent.click(screen.getByRole('button', { name: /Create|Update/i })); + await waitFor(() => { + const put = mockedFetch.mock.calls.find( + ([url, opts]) => + url === '/notification-suppression-rules/43' && (opts as { method?: string })?.method === 'PUT', + ); + expect(put).toBeTruthy(); + const body = JSON.parse((put![1] as { body: string }).body); + expect(body.schedule).toBeNull(); + }); + }); + + it('allows saving a corrupt-schedule rule once the operator configures a new valid schedule', async () => { + mockListRules([corruptScheduleRule]); + render(); + await waitFor(() => expect(screen.getByText('Corrupt window')).toBeInTheDocument()); + + await userEvent.click(screen.getByTitle('Edit')); + await waitFor(() => expect(screen.getByRole('dialog', { name: /Edit mute rule/i })).toBeInTheDocument()); + await enableWeeklyWindow(); + await userEvent.click(screen.getByRole('button', { name: 'Sat' })); + fireEvent.change(screen.getByLabelText('Start (UTC)'), { target: { value: '02:00' } }); + fireEvent.change(screen.getByLabelText('End (UTC)'), { target: { value: '06:00' } }); + + await userEvent.click(screen.getByRole('button', { name: /Create|Update/i })); + await waitFor(() => { + const put = mockedFetch.mock.calls.find( + ([url, opts]) => + url === '/notification-suppression-rules/43' && (opts as { method?: string })?.method === 'PUT', + ); + expect(put).toBeTruthy(); + const body = JSON.parse((put![1] as { body: string }).body); + expect(body.schedule).toEqual({ + days: [6], + start_minute: 120, + end_minute: 360, + tz: 'UTC', + }); + }); + }); });