feat: add weekly UTC maintenance windows to mute rules

Let mute rules suppress only during recurring UTC windows, normalize
replica node identity, and fail-open when remotes lack schedule support
so older nodes never keep an all-day scheduled mute after a successful cleanup DELETE.
This commit is contained in:
SaelixCode
2026-07-20 23:46:15 -04:00
parent ad00517a0e
commit 48a2662f5e
17 changed files with 1133 additions and 18 deletions
@@ -40,10 +40,41 @@ interface NotificationSuppressionRule {
applies_to: AppliesTo;
enabled: boolean;
expires_at: number | null;
schedule: MuteRuleSchedule | null;
created_at: number;
updated_at: number;
}
type MuteRuleSchedule = {
days: number[];
start_minute: number;
end_minute: number;
tz: 'UTC';
};
const DAY_LABELS = ['Sun', 'Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat'] as const;
function minuteToTimeInput(minute: number): string {
const h = Math.floor(minute / 60);
const m = minute % 60;
return `${String(h).padStart(2, '0')}:${String(m).padStart(2, '0')}`;
}
function timeInputToMinute(value: string): number | null {
const match = /^(\d{2}):(\d{2})(?::\d{2})?$/.exec(value);
if (!match) return null;
const h = Number(match[1]);
const m = Number(match[2]);
if (h > 23 || m > 59) return null;
return h * 60 + m;
}
function formatScheduleSummary(schedule: MuteRuleSchedule | null): string | null {
if (!schedule) return null;
const days = schedule.days.map((d) => DAY_LABELS[d] ?? String(d)).join(', ');
return `UTC ${days} ${minuteToTimeInput(schedule.start_minute)}-${minuteToTimeInput(schedule.end_minute)}`;
}
const LEVEL_LABELS: Record<NotificationLevel, string> = {
info: 'Info',
warning: 'Warning',
@@ -127,6 +158,10 @@ export function NotificationSuppressionSection({
const [formEnabled, setFormEnabled] = useState(true);
const [formExpirationPreset, setFormExpirationPreset] = useState<ExpirationPreset>('forever');
const [formCustomExpiry, setFormCustomExpiry] = useState('');
const [formScheduleEnabled, setFormScheduleEnabled] = useState(false);
const [formScheduleDays, setFormScheduleDays] = useState<number[]>([]);
const [formScheduleStart, setFormScheduleStart] = useState('02:00');
const [formScheduleEnd, setFormScheduleEnd] = useState('06:00');
const fetchRules = useCallback(async () => {
try {
@@ -176,6 +211,10 @@ export function NotificationSuppressionSection({
});
setFormExpirationPreset('forever');
setFormCustomExpiry('');
setFormScheduleEnabled(false);
setFormScheduleDays([]);
setFormScheduleStart('02:00');
setFormScheduleEnd('06:00');
setEditingId(null);
setShowForm(true);
onPrefillConsumed?.();
@@ -192,6 +231,10 @@ export function NotificationSuppressionSection({
setFormEnabled(true);
setFormExpirationPreset('forever');
setFormCustomExpiry('');
setFormScheduleEnabled(false);
setFormScheduleDays([]);
setFormScheduleStart('02:00');
setFormScheduleEnd('06:00');
setEditingId(null);
setShowForm(false);
};
@@ -209,6 +252,17 @@ export function NotificationSuppressionSection({
setFormEnabled(rule.enabled);
setFormExpirationPreset(preset);
setFormCustomExpiry(customMs != null ? new Date(customMs).toISOString().slice(0, 16) : '');
if (rule.schedule) {
setFormScheduleEnabled(true);
setFormScheduleDays([...rule.schedule.days]);
setFormScheduleStart(minuteToTimeInput(rule.schedule.start_minute));
setFormScheduleEnd(minuteToTimeInput(rule.schedule.end_minute));
} else {
setFormScheduleEnabled(false);
setFormScheduleDays([]);
setFormScheduleStart('02:00');
setFormScheduleEnd('06:00');
}
setShowForm(true);
};
@@ -225,6 +279,30 @@ export function NotificationSuppressionSection({
return;
}
let schedule: MuteRuleSchedule | null = null;
if (formScheduleEnabled) {
if (formScheduleDays.length === 0) {
toast.error('Select at least one day for the weekly window.');
return;
}
const startMinute = timeInputToMinute(formScheduleStart);
const endMinute = timeInputToMinute(formScheduleEnd);
if (startMinute == null || endMinute == null) {
toast.error('Enter valid UTC start and end times.');
return;
}
if (startMinute === endMinute) {
toast.error('Weekly window start and end must differ.');
return;
}
schedule = {
days: [...new Set(formScheduleDays)].sort((a, b) => a - b),
start_minute: startMinute,
end_minute: endMinute,
tz: 'UTC',
};
}
setSaving(true);
try {
const body = {
@@ -237,6 +315,7 @@ export function NotificationSuppressionSection({
applies_to: formAppliesTo,
enabled: formEnabled,
expires_at: expirationFromPreset(formExpirationPreset, customMs),
schedule,
};
const url = editingId
@@ -483,6 +562,57 @@ export function NotificationSuppressionSection({
)}
</div>
<div className="space-y-2">
<div className="flex items-center gap-2">
<TogglePill
checked={formScheduleEnabled}
onChange={setFormScheduleEnabled}
id="mute-rule-schedule"
/>
<Label htmlFor="mute-rule-schedule" className="mb-0">Weekly window (UTC)</Label>
</div>
{formScheduleEnabled && (
<div className="space-y-2 rounded-md border border-border/60 p-3">
<div className="flex flex-wrap gap-1.5">
{DAY_LABELS.map((label, day) => {
const selected = formScheduleDays.includes(day);
return (
<Button
key={label}
type="button"
size="sm"
variant={selected ? 'default' : 'outline'}
className="h-7 px-2 text-xs"
onClick={() => {
setFormScheduleDays((prev) =>
selected
? prev.filter((d) => d !== day)
: [...prev, day].sort((a, b) => a - b),
);
}}
>
{label}
</Button>
);
})}
</div>
<div className="grid grid-cols-2 gap-2">
<div className="space-y-1">
<Label className="text-xs text-muted-foreground">Start (UTC)</Label>
<Input type="time" value={formScheduleStart} onChange={(e) => setFormScheduleStart(e.target.value)} />
</div>
<div className="space-y-1">
<Label className="text-xs text-muted-foreground">End (UTC)</Label>
<Input type="time" value={formScheduleEnd} onChange={(e) => setFormScheduleEnd(e.target.value)} />
</div>
</div>
<p className="text-xs text-muted-foreground">
Outside this window the rule does not mute. Cross-midnight windows use the start day only.
</p>
</div>
)}
</div>
<div className="flex items-center gap-2">
<TogglePill checked={formEnabled} onChange={setFormEnabled} id="mute-rule-enabled" />
<span className="text-sm text-stat-value select-none">
@@ -553,6 +683,12 @@ export function NotificationSuppressionSection({
)}
<span className="text-muted-foreground/50">|</span>
<span className="tabular-nums">Expires: {formatExpiry(rule.expires_at)}</span>
{formatScheduleSummary(rule.schedule) && (
<>
<span className="text-muted-foreground/50">|</span>
<span className="tabular-nums">{formatScheduleSummary(rule.schedule)}</span>
</>
)}
</div>
</div>
))}
+1
View File
@@ -14,6 +14,7 @@ export const CAPABILITIES = [
'notifications',
'notification-routing',
'notification-suppression',
'notification-suppression-schedule',
'host-console',
'container-exec',
'audit-log',
+30
View File
@@ -0,0 +1,30 @@
import { describe, it, expect, vi, beforeEach } from 'vitest';
const mockApiFetch = vi.fn();
vi.mock('@/lib/api', () => ({
apiFetch: (...args: unknown[]) => mockApiFetch(...args),
}));
vi.mock('@/components/ui/toast-store', () => ({
toast: { success: vi.fn(), error: vi.fn() },
}));
import { createMuteRule, stackMuteAllDraft } from './muteRules';
describe('muteRules schedule defaults', () => {
beforeEach(() => {
mockApiFetch.mockReset();
mockApiFetch.mockResolvedValue({ ok: true, json: async () => ({}) });
});
it('createMuteRule sends schedule null when draft omits it', async () => {
await createMuteRule(stackMuteAllDraft('web'));
expect(mockApiFetch).toHaveBeenCalledWith(
'/notification-suppression-rules',
expect.objectContaining({
method: 'POST',
body: expect.stringContaining('"schedule":null'),
}),
);
});
});
+9
View File
@@ -16,6 +16,14 @@ export type MuteRuleDraft = {
applies_to?: MuteRuleAppliesTo;
enabled?: boolean;
expires_at?: number | null;
schedule?: MuteRuleSchedule | null;
};
export type MuteRuleSchedule = {
days: number[];
start_minute: number;
end_minute: number;
tz: 'UTC';
};
export const MUTE_RULES_CHANGED_EVENT = 'sencho:mute-rules-changed';
@@ -28,6 +36,7 @@ const DEFAULT_BODY = {
applies_to: 'both' as MuteRuleAppliesTo,
enabled: true,
expires_at: null as number | null,
schedule: null as MuteRuleSchedule | null,
node_id: null as number | null,
stack_patterns: [] as string[],
label_ids: null as number[] | null,