fix: require explicit repair before clearing a corrupt mute schedule

The suppression engine already fails closed on an unreadable stored
schedule (scheduleInvalid), but the frontend never surfaced that flag:
a corrupt rule looked identical to an ordinary unscheduled one, and
opening Edit then clicking Update sent an explicit schedule: null,
silently turning the corruption into a valid all-day mute. Add the
flag to the rule type, show an Invalid schedule badge on the card, and
block saving in the edit form until the operator explicitly touches
the weekly window (configures a new one, or toggles it to confirm the
clear).
This commit is contained in:
SaelixCode
2026-07-21 14:04:44 -04:00
parent 1d1fba0e33
commit cb97cc097c
2 changed files with 133 additions and 3 deletions
@@ -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<number[]>([]);
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({
<div className="flex items-center gap-2">
<TogglePill
checked={formScheduleEnabled}
onChange={setFormScheduleEnabled}
onChange={(v) => { setFormScheduleEnabled(v); markScheduleTouched(); }}
id="mute-rule-schedule"
/>
<Label htmlFor="mute-rule-schedule" className="mb-0">Weekly window (UTC)</Label>
</div>
{formScheduleInvalid && !formScheduleTouched && (
<p className="text-xs text-destructive">
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.
</p>
)}
{formScheduleEnabled && (
<div className="space-y-2 rounded-md border border-border/60 p-3">
<div className="flex flex-wrap gap-1.5">
@@ -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(); }}
/>
</div>
<div className="space-y-1">
@@ -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(); }}
/>
</div>
</div>
@@ -673,6 +699,11 @@ export function NotificationSuppressionSection({
{rule.expires_at != null && rule.expires_at <= Date.now() && (
<Badge variant="secondary" className="text-[10px] shrink-0 text-muted-foreground">Expired</Badge>
)}
{rule.scheduleInvalid && (
<Badge variant="destructive" className="text-[10px] shrink-0" title="Stored weekly window could not be read; alerts deliver normally until repaired in Edit">
Invalid schedule
</Badge>
)}
</div>
<div className="flex items-center gap-1 shrink-0">
<TogglePill checked={rule.enabled} onChange={() => handleToggleEnabled(rule)} className="scale-75" />
@@ -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(<NotificationSuppressionSection />);
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(<NotificationSuppressionSection />);
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(<NotificationSuppressionSection />);
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(<NotificationSuppressionSection />);
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',
});
});
});
});