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
@@ -0,0 +1,107 @@
import { describe, it, expect } from 'vitest';
import {
isScheduleActive,
parseNotificationSchedule,
parseStoredNotificationSchedule,
scheduleAllowsSuppression,
type NotificationSchedule,
} from '../helpers/notificationSchedule';
/** Build a UTC epoch for a known weekday. 2026-07-18 is Saturday (getUTCDay()===6). */
function utcMs(iso: string): number {
return Date.parse(iso);
}
const satWindow: NotificationSchedule = {
days: [6],
start_minute: 22 * 60,
end_minute: 2 * 60,
tz: 'UTC',
};
const sameDay: NotificationSchedule = {
days: [1],
start_minute: 2 * 60,
end_minute: 6 * 60,
tz: 'UTC',
};
describe('parseNotificationSchedule', () => {
it('accepts unique days in any order and returns sorted', () => {
const result = parseNotificationSchedule({
days: [3, 1, 6],
start_minute: 0,
end_minute: 60,
tz: 'UTC',
});
expect(result).toEqual({
ok: true,
schedule: { days: [1, 3, 6], start_minute: 0, end_minute: 60, tz: 'UTC' },
});
});
it('rejects duplicates, empty days, equal endpoints, and non-UTC tz', () => {
expect(parseNotificationSchedule({
days: [1, 1], start_minute: 0, end_minute: 1, tz: 'UTC',
}).ok).toBe(false);
expect(parseNotificationSchedule({
days: [], start_minute: 0, end_minute: 1, tz: 'UTC',
}).ok).toBe(false);
expect(parseNotificationSchedule({
days: [1], start_minute: 30, end_minute: 30, tz: 'UTC',
}).ok).toBe(false);
expect(parseNotificationSchedule({
days: [1], start_minute: 0, end_minute: 1, tz: 'America/New_York',
}).ok).toBe(false);
});
});
describe('isScheduleActive', () => {
it('treats null schedule as always active', () => {
expect(isScheduleActive(null, utcMs('2026-07-20T12:00:00.000Z'))).toBe(true);
});
it('same-day: start inclusive, end exclusive', () => {
// Monday 2026-07-20
expect(isScheduleActive(sameDay, utcMs('2026-07-20T02:00:00.000Z'))).toBe(true);
expect(isScheduleActive(sameDay, utcMs('2026-07-20T05:59:00.000Z'))).toBe(true);
expect(isScheduleActive(sameDay, utcMs('2026-07-20T06:00:00.000Z'))).toBe(false);
expect(isScheduleActive(sameDay, utcMs('2026-07-20T01:59:00.000Z'))).toBe(false);
});
it('cross-midnight Saturday window: Sat evening and Sun morning only', () => {
expect(isScheduleActive(satWindow, utcMs('2026-07-18T22:00:00.000Z'))).toBe(true); // Sat
expect(isScheduleActive(satWindow, utcMs('2026-07-18T23:30:00.000Z'))).toBe(true);
expect(isScheduleActive(satWindow, utcMs('2026-07-19T01:59:00.000Z'))).toBe(true); // Sun
expect(isScheduleActive(satWindow, utcMs('2026-07-19T02:00:00.000Z'))).toBe(false);
expect(isScheduleActive(satWindow, utcMs('2026-07-18T21:59:00.000Z'))).toBe(false);
expect(isScheduleActive(satWindow, utcMs('2026-07-19T22:00:00.000Z'))).toBe(false); // Sun evening
expect(isScheduleActive(satWindow, utcMs('2026-07-20T01:00:00.000Z'))).toBe(false); // Mon morning
});
it('supports multiple start days', () => {
const multi: NotificationSchedule = {
days: [1, 3],
start_minute: 10 * 60,
end_minute: 11 * 60,
tz: 'UTC',
};
expect(isScheduleActive(multi, utcMs('2026-07-20T10:30:00.000Z'))).toBe(true); // Mon
expect(isScheduleActive(multi, utcMs('2026-07-22T10:30:00.000Z'))).toBe(true); // Wed
expect(isScheduleActive(multi, utcMs('2026-07-21T10:30:00.000Z'))).toBe(false); // Tue
});
it('uses UTC independent of host-local timezone interpretation of fixed instants', () => {
const ms = utcMs('2026-07-18T22:00:00.000Z');
expect(new Date(ms).getUTCDay()).toBe(6);
expect(isScheduleActive(satWindow, ms)).toBe(true);
});
});
describe('scheduleAllowsSuppression / stored parse', () => {
it('fails closed for invalid stored schedule', () => {
expect(scheduleAllowsSuppression(null, true, Date.now())).toBe(false);
expect(parseStoredNotificationSchedule('{not-json')).toEqual({ kind: 'invalid' });
expect(parseStoredNotificationSchedule(null)).toEqual({ kind: 'null' });
});
});
@@ -293,6 +293,138 @@ describe('Notification suppression - CRUD', () => {
});
expect(ok.status).toBe(200);
expect(DatabaseService.getInstance().getNotificationSuppressionRule(9001)?.stack_patterns).toEqual(['prod-*']);
expect(DatabaseService.getInstance().getNotificationSuppressionRule(9001)?.schedule).toBeNull();
DatabaseService.getInstance().deleteNotificationSuppressionRule(9001);
const omitSched = await request(app)
.post('/api/notification-suppression-rules/replica')
.set('Authorization', `Bearer ${token}`)
.send({
rule: {
id: 9003,
name: 'replica-omit-sched',
applies_to: 'both',
stack_patterns: [],
node_id: null,
label_ids: null,
categories: null,
levels: null,
enabled: true,
expires_at: null,
created_at: 1,
updated_at: 1,
},
});
expect(omitSched.status).toBe(200);
expect(DatabaseService.getInstance().getNotificationSuppressionRule(9003)?.schedule).toBeNull();
DatabaseService.getInstance().deleteNotificationSuppressionRule(9003);
});
it('schedule: create omit null; PUT preserve; null clear; canonicalize days; reject invalid', async () => {
const omitted = await request(app)
.post('/api/notification-suppression-rules')
.set('Cookie', authCookie)
.send({ name: 'sched omit', applies_to: 'both' });
expect(omitted.status).toBe(201);
expect(omitted.body.schedule).toBeNull();
const id = omitted.body.id as number;
const withSched = await request(app)
.put(`/api/notification-suppression-rules/${id}`)
.set('Cookie', authCookie)
.send({
schedule: { days: [3, 1], start_minute: 60, end_minute: 120, tz: 'UTC' },
});
expect(withSched.status).toBe(200);
expect(withSched.body.schedule).toEqual({
days: [1, 3],
start_minute: 60,
end_minute: 120,
tz: 'UTC',
});
const preserved = await request(app)
.put(`/api/notification-suppression-rules/${id}`)
.set('Cookie', authCookie)
.send({ enabled: false });
expect(preserved.status).toBe(200);
expect(preserved.body.schedule).toEqual({
days: [1, 3],
start_minute: 60,
end_minute: 120,
tz: 'UTC',
});
const cleared = await request(app)
.put(`/api/notification-suppression-rules/${id}`)
.set('Cookie', authCookie)
.send({ schedule: null });
expect(cleared.status).toBe(200);
expect(cleared.body.schedule).toBeNull();
const bad = await request(app)
.put(`/api/notification-suppression-rules/${id}`)
.set('Cookie', authCookie)
.send({ schedule: { days: [1], start_minute: 10, end_minute: 10, tz: 'UTC' } });
expect(bad.status).toBe(400);
DatabaseService.getInstance().deleteNotificationSuppressionRule(id);
});
it('replica rejects invalid schedule and accepts valid schedule', async () => {
const jwt = await import('jsonwebtoken');
const { TEST_JWT_SECRET } = await import('./helpers/testConstants');
const token = jwt.default.sign({ scope: 'node_proxy' }, TEST_JWT_SECRET, { expiresIn: '1m' });
const bad = await request(app)
.post('/api/notification-suppression-rules/replica')
.set('Authorization', `Bearer ${token}`)
.send({
rule: {
id: 9002,
name: 'replica-sched',
applies_to: 'both',
stack_patterns: [],
node_id: null,
label_ids: null,
categories: null,
levels: null,
enabled: true,
expires_at: null,
schedule: { days: [1], start_minute: 0, end_minute: 0, tz: 'UTC' },
created_at: 1,
updated_at: 1,
},
});
expect(bad.status).toBe(400);
const ok = await request(app)
.post('/api/notification-suppression-rules/replica')
.set('Authorization', `Bearer ${token}`)
.send({
rule: {
id: 9002,
name: 'replica-sched',
applies_to: 'both',
stack_patterns: [],
node_id: null,
label_ids: null,
categories: null,
levels: null,
enabled: true,
expires_at: null,
schedule: { days: [6], start_minute: 1320, end_minute: 120, tz: 'UTC' },
created_at: 1,
updated_at: 1,
},
});
expect(ok.status).toBe(200);
expect(DatabaseService.getInstance().getNotificationSuppressionRule(9002)?.schedule).toEqual({
days: [6],
start_minute: 1320,
end_minute: 120,
tz: 'UTC',
});
DatabaseService.getInstance().deleteNotificationSuppressionRule(9002);
});
});
@@ -0,0 +1,92 @@
/**
* Additive schedule column on notification_suppression_rules.
*/
import { describe, it, expect, afterEach } from 'vitest';
import fs from 'fs';
import os from 'os';
import path from 'path';
import Database from 'better-sqlite3';
import { DatabaseService } from '../services/DatabaseService';
function resetDatabaseSingleton(): void {
const holder = DatabaseService as unknown as { instance?: DatabaseService };
const existing = holder.instance;
if (existing) {
try {
existing.getDb().close();
} catch {
// already closed
}
holder.instance = undefined;
}
}
describe('notification suppression schedule column migration', () => {
let scratchDir: string | null = null;
let prevDataDir: string | undefined;
afterEach(() => {
resetDatabaseSingleton();
if (prevDataDir === undefined) {
delete process.env.DATA_DIR;
} else {
process.env.DATA_DIR = prevDataDir;
}
if (scratchDir) {
try {
fs.rmSync(scratchDir, { recursive: true, force: true });
} catch {
// best-effort
}
scratchDir = null;
}
});
it('adds schedule via DatabaseService startup; legacy rows load as null', { timeout: 60_000 }, () => {
scratchDir = fs.mkdtempSync(path.join(os.tmpdir(), 'sencho-supp-sched-mig-'));
const dbPath = path.join(scratchDir, 'sencho.db');
const seed = new Database(dbPath);
try {
seed.exec(`
CREATE TABLE notification_suppression_rules (
id INTEGER PRIMARY KEY AUTOINCREMENT,
name TEXT NOT NULL,
node_id INTEGER NULL,
stack_patterns TEXT NOT NULL,
label_ids TEXT NULL,
categories TEXT NULL,
levels TEXT NULL,
applies_to TEXT NOT NULL,
enabled INTEGER DEFAULT 1,
expires_at INTEGER NULL,
created_at INTEGER NOT NULL,
updated_at INTEGER NOT NULL
);
INSERT INTO notification_suppression_rules
(name, node_id, stack_patterns, label_ids, categories, levels, applies_to, enabled, expires_at, created_at, updated_at)
VALUES ('Legacy mute', NULL, '[]', NULL, NULL, NULL, 'both', 1, NULL, 1, 1);
`);
} finally {
seed.close();
}
prevDataDir = process.env.DATA_DIR;
process.env.DATA_DIR = scratchDir;
resetDatabaseSingleton();
const db = DatabaseService.getInstance();
const cols = db.getDb().prepare('PRAGMA table_info(notification_suppression_rules)').all() as Array<{ name: string }>;
expect(cols.filter((c) => c.name === 'schedule')).toHaveLength(1);
const rule = db.getNotificationSuppressionRules().find((r) => r.name === 'Legacy mute');
expect(rule).toBeDefined();
expect(rule!.schedule).toBeNull();
expect(rule!.scheduleInvalid).toBe(false);
resetDatabaseSingleton();
process.env.DATA_DIR = scratchDir;
const db2 = DatabaseService.getInstance();
const cols2 = db2.getDb().prepare('PRAGMA table_info(notification_suppression_rules)').all() as Array<{ name: string }>;
expect(cols2.filter((c) => c.name === 'schedule')).toHaveLength(1);
});
});
@@ -0,0 +1,224 @@
/**
* Fleet sync for suppression rules: node_id normalize, capability gate, stale DELETE.
*/
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
const mockFetchMeta = vi.fn();
const mockGetProxyTarget = vi.fn();
const mockGetNodes = vi.fn();
const mockGetNode = vi.fn();
const mockRemoteAdvertises = vi.fn();
vi.mock('../services/DatabaseService', () => ({
DatabaseService: {
getInstance: () => ({
getNodes: mockGetNodes,
getNode: mockGetNode,
}),
},
}));
vi.mock('../services/NodeRegistry', () => ({
NodeRegistry: {
getInstance: () => ({
getProxyTarget: mockGetProxyTarget,
fetchMetaForNode: mockFetchMeta,
}),
},
}));
vi.mock('../services/LicenseService', () => ({
LicenseService: {
getInstance: () => ({
getProxyHeaders: () => ({ tier: 'community' }),
}),
},
}));
vi.mock('../helpers/remoteCapabilities', () => ({
remoteAdvertisesCapability: (...args: unknown[]) => mockRemoteAdvertises(...args),
}));
const mockFetch = vi.fn();
vi.stubGlobal('fetch', mockFetch);
import {
syncSuppressionRuleToFleet,
syncSuppressionRuleUpdateToFleet,
replicationTargetIds,
} from '../helpers/notificationSuppressionSync';
import type { NotificationSuppressionRule } from '../services/DatabaseService';
function makeRule(overrides: Partial<NotificationSuppressionRule> = {}): NotificationSuppressionRule {
return {
id: 42,
name: 'Fleet mute',
node_id: null,
stack_patterns: [],
label_ids: null,
categories: null,
levels: null,
applies_to: 'both',
enabled: true,
expires_at: null,
schedule: null,
scheduleInvalid: false,
created_at: 1,
updated_at: 1,
...overrides,
};
}
const remoteA = { id: 10, name: 'remote-a', type: 'remote' as const };
const remoteB = { id: 11, name: 'remote-b', type: 'remote' as const };
describe('notificationSuppressionSync', () => {
beforeEach(() => {
vi.clearAllMocks();
mockGetNodes.mockReturnValue([remoteA, remoteB]);
mockGetNode.mockImplementation((id: number) =>
[remoteA, remoteB].find((n) => n.id === id),
);
mockGetProxyTarget.mockImplementation((id: number) => ({
apiUrl: `http://node-${id}.example:1852`,
apiToken: 'tok',
}));
mockFetch.mockResolvedValue({ ok: true, status: 200, text: async () => '' });
mockRemoteAdvertises.mockResolvedValue(true);
});
afterEach(() => {
vi.restoreAllMocks();
});
it('replicationTargetIds: fleet-wide all remotes; scoped one remote', () => {
expect(replicationTargetIds(makeRule({ node_id: null }))).toEqual([10, 11]);
expect(replicationTargetIds(makeRule({ node_id: 10 }))).toEqual([10]);
});
it('unscheduled push sends node_id null without capability probe', async () => {
syncSuppressionRuleToFleet(makeRule({ schedule: null, node_id: 10 }));
await vi.waitFor(() => expect(mockFetch).toHaveBeenCalled());
expect(mockRemoteAdvertises).not.toHaveBeenCalled();
const body = JSON.parse((mockFetch.mock.calls[0][1] as { body: string }).body);
expect(body.rule.node_id).toBeNull();
expect(body.rule.schedule).toBeNull();
});
it('supported remote receives scheduled rule with node_id null', async () => {
mockRemoteAdvertises.mockResolvedValue(true);
syncSuppressionRuleToFleet(makeRule({
node_id: 10,
schedule: { days: [6], start_minute: 120, end_minute: 360, tz: 'UTC' },
}));
await vi.waitFor(() => expect(mockFetch).toHaveBeenCalled());
expect(mockRemoteAdvertises).toHaveBeenCalled();
const [url, init] = mockFetch.mock.calls[0] as [string, { method: string; body: string }];
expect(url).toContain('/api/notification-suppression-rules/replica');
expect(init.method).toBe('POST');
const body = JSON.parse(init.body);
expect(body.rule.node_id).toBeNull();
expect(body.rule.schedule.days).toEqual([6]);
});
it('probe false + DELETE success: no POST; cleanup logged as removed', async () => {
const warn = vi.spyOn(console, 'warn').mockImplementation(() => {});
const error = vi.spyOn(console, 'error').mockImplementation(() => {});
mockRemoteAdvertises.mockResolvedValue(false);
mockFetch.mockResolvedValue({ ok: true, status: 200, text: async () => '' });
syncSuppressionRuleToFleet(makeRule({
node_id: 10,
schedule: { days: [1], start_minute: 0, end_minute: 60, tz: 'UTC' },
}));
await vi.waitFor(() => expect(mockFetch).toHaveBeenCalled());
expect(mockFetch.mock.calls.every((c) => (c[1] as { method: string }).method === 'DELETE')).toBe(true);
expect(warn.mock.calls.some((c) => String(c[0]).includes('replica was removed'))).toBe(true);
expect(error.mock.calls.some((c) => String(c[0]).includes('cleanup pending'))).toBe(false);
});
it('probe false + no proxy target: no successful-cleanup claim', async () => {
const warn = vi.spyOn(console, 'warn').mockImplementation(() => {});
const error = vi.spyOn(console, 'error').mockImplementation(() => {});
mockRemoteAdvertises.mockResolvedValue(false);
mockGetProxyTarget.mockReturnValue(null);
syncSuppressionRuleToFleet(makeRule({
node_id: 10,
schedule: { days: [1], start_minute: 0, end_minute: 60, tz: 'UTC' },
}));
await vi.waitFor(() => expect(error).toHaveBeenCalled());
expect(mockFetch).not.toHaveBeenCalled();
expect(warn.mock.calls.some((c) => String(c[0]).includes('replica was removed'))).toBe(false);
expect(error.mock.calls.some((c) => String(c[0]).includes('cleanup pending'))).toBe(true);
});
it('skips sync when scheduleInvalid (does not POST as unscheduled)', async () => {
const warn = vi.spyOn(console, 'warn').mockImplementation(() => {});
syncSuppressionRuleToFleet(makeRule({
node_id: 10,
schedule: null,
scheduleInvalid: true,
}));
await vi.waitFor(() => expect(warn).toHaveBeenCalled());
expect(mockFetch).not.toHaveBeenCalled();
expect(warn.mock.calls.some((c) => String(c[0]).includes('corrupt schedule'))).toBe(true);
});
it('unscheduled-to-scheduled on unsupported target attempts DELETE', async () => {
mockRemoteAdvertises.mockResolvedValue(false);
mockFetch.mockResolvedValue({ ok: true, status: 200, text: async () => '' });
const previous = makeRule({ node_id: 10, schedule: null });
const updated = makeRule({
node_id: 10,
schedule: { days: [1], start_minute: 0, end_minute: 60, tz: 'UTC' },
});
syncSuppressionRuleUpdateToFleet(previous, updated);
await vi.waitFor(() => expect(mockFetch).toHaveBeenCalled());
expect(mockFetch.mock.calls.every((c) => (c[1] as { method: string }).method === 'DELETE')).toBe(true);
});
it('probe false + DELETE failure: no POST; logs cleanup pending', async () => {
const error = vi.spyOn(console, 'error').mockImplementation(() => {});
mockRemoteAdvertises.mockResolvedValue(false);
mockFetch.mockResolvedValue({ ok: false, status: 503, text: async () => 'down' });
syncSuppressionRuleToFleet(makeRule({
node_id: 10,
schedule: { days: [1], start_minute: 0, end_minute: 60, tz: 'UTC' },
}));
await vi.waitFor(() => expect(error).toHaveBeenCalled());
expect(mockFetch.mock.calls.every((c) => (c[1] as { method: string }).method === 'DELETE')).toBe(true);
expect(error.mock.calls.some((c) => String(c[0]).includes('cleanup pending'))).toBe(true);
expect(error.mock.calls.some((c) => String(c[0]).includes('rule 42'))).toBe(true);
});
it('scheduled-to-unscheduled POST refresh does not require capability', async () => {
mockRemoteAdvertises.mockResolvedValue(false);
const previous = makeRule({
node_id: 10,
schedule: { days: [1], start_minute: 0, end_minute: 60, tz: 'UTC' },
});
const updated = makeRule({ node_id: 10, schedule: null });
syncSuppressionRuleUpdateToFleet(previous, updated);
await vi.waitFor(() => expect(mockFetch).toHaveBeenCalled());
expect(mockRemoteAdvertises).not.toHaveBeenCalled();
const body = JSON.parse((mockFetch.mock.calls[0][1] as { body: string }).body);
expect(body.rule.schedule).toBeNull();
});
it('stale targets receive DELETE on scope change', async () => {
const previous = makeRule({ node_id: null, schedule: null });
const updated = makeRule({ node_id: 10, schedule: null });
syncSuppressionRuleUpdateToFleet(previous, updated);
await vi.waitFor(() => expect(mockFetch.mock.calls.length).toBeGreaterThanOrEqual(2));
const deletes = mockFetch.mock.calls.filter((c) => (c[1] as { method: string }).method === 'DELETE');
expect(deletes.some((c) => String(c[0]).includes('node-11'))).toBe(true);
const posts = mockFetch.mock.calls.filter((c) => (c[1] as { method: string }).method === 'POST');
expect(posts.some((c) => String(c[0]).includes('node-10'))).toBe(true);
});
});
@@ -73,6 +73,8 @@ function makeSuppressionRule(overrides: Record<string, unknown> = {}) {
applies_to: 'both' as const,
enabled: true,
expires_at: null as number | null,
schedule: null as null,
scheduleInvalid: false,
created_at: Date.now(),
updated_at: Date.now(),
...overrides,
@@ -176,4 +178,74 @@ describe('NotificationService - suppression logic', () => {
expect.objectContaining({ method: 'POST' }),
);
});
it('suppresses inside weekly window and records match', async () => {
// Monday 2026-07-20 03:00 UTC inside 02:00-06:00 Mon window
vi.spyOn(Date, 'now').mockReturnValue(Date.parse('2026-07-20T03:00:00.000Z'));
mockGetEnabledNotificationSuppressionRules.mockReturnValue([
makeSuppressionRule({
categories: ['monitor_alert'],
applies_to: 'both',
schedule: { days: [1], start_minute: 120, end_minute: 360, tz: 'UTC' },
}),
]);
mockGetEnabledAgents.mockReturnValue([{ type: 'slack', url: 'https://hooks.slack.com/x', enabled: true }]);
await svc.dispatchAlert('error', 'monitor_alert', 'Crash', { stackName: 'my-app' });
expect(mockBroadcast).not.toHaveBeenCalled();
expect(mockFetch).not.toHaveBeenCalled();
expect(mockUpdateNotificationSuppressionMatch).toHaveBeenCalled();
expect(mockGetEnabledNotificationSuppressionRules).toHaveBeenCalledWith(Date.parse('2026-07-20T03:00:00.000Z'));
});
it('does not suppress outside weekly window', async () => {
vi.spyOn(Date, 'now').mockReturnValue(Date.parse('2026-07-20T12:00:00.000Z'));
mockGetEnabledNotificationSuppressionRules.mockReturnValue([
makeSuppressionRule({
categories: ['monitor_alert'],
applies_to: 'both',
schedule: { days: [1], start_minute: 120, end_minute: 360, tz: 'UTC' },
}),
]);
mockGetEnabledAgents.mockReturnValue([{ type: 'slack', url: 'https://hooks.slack.com/x', enabled: true }]);
await svc.dispatchAlert('error', 'monitor_alert', 'Crash', { stackName: 'my-app' });
expect(mockBroadcast).toHaveBeenCalled();
expect(mockFetch).toHaveBeenCalled();
expect(mockUpdateNotificationSuppressionMatch).not.toHaveBeenCalled();
});
it('does not suppress when stored schedule is invalid', async () => {
mockGetEnabledNotificationSuppressionRules.mockReturnValue([
makeSuppressionRule({
categories: ['monitor_alert'],
applies_to: 'both',
schedule: null,
scheduleInvalid: true,
}),
]);
mockGetEnabledAgents.mockReturnValue([{ type: 'slack', url: 'https://hooks.slack.com/x', enabled: true }]);
await svc.dispatchAlert('error', 'monitor_alert', 'Crash', { stackName: 'my-app' });
expect(mockBroadcast).toHaveBeenCalled();
expect(mockFetch).toHaveBeenCalled();
expect(mockUpdateNotificationSuppressionMatch).not.toHaveBeenCalled();
});
it('does not suppress when scheduled rule is expired relative to atMs', async () => {
const atMs = Date.parse('2026-07-20T03:00:00.000Z');
vi.spyOn(Date, 'now').mockReturnValue(atMs);
// getEnabled already filters expiry; empty list simulates expired
mockGetEnabledNotificationSuppressionRules.mockReturnValue([]);
mockGetEnabledAgents.mockReturnValue([{ type: 'slack', url: 'https://hooks.slack.com/x', enabled: true }]);
await svc.dispatchAlert('error', 'monitor_alert', 'Crash', { stackName: 'my-app' });
expect(mockGetEnabledNotificationSuppressionRules).toHaveBeenCalledWith(atMs);
expect(mockBroadcast).toHaveBeenCalled();
expect(mockFetch).toHaveBeenCalled();
});
});
+119
View File
@@ -0,0 +1,119 @@
/**
* Weekly UTC maintenance windows for notification suppression rules.
* days are start days (Date#getUTCDay); start inclusive, end exclusive.
*/
export interface NotificationSchedule {
days: number[];
start_minute: number;
end_minute: number;
tz: 'UTC';
}
export type ParseNotificationScheduleResult =
| { ok: true; schedule: NotificationSchedule }
| { ok: false; error: string };
const SCHEDULE_KEYS = new Set(['days', 'start_minute', 'end_minute', 'tz']);
function isPlainObject(value: unknown): value is Record<string, unknown> {
return typeof value === 'object' && value !== null && !Array.isArray(value);
}
/** Strict write-boundary parser. Accepts days in any order; returns sorted unique days. */
export function parseNotificationSchedule(raw: unknown): ParseNotificationScheduleResult {
if (!isPlainObject(raw)) {
return { ok: false, error: 'schedule must be an object or null' };
}
const keys = Object.keys(raw);
if (keys.length !== 4 || keys.some((k) => !SCHEDULE_KEYS.has(k))) {
return { ok: false, error: 'schedule must have exactly days, start_minute, end_minute, and tz' };
}
if (raw.tz !== 'UTC') {
return { ok: false, error: 'schedule.tz must be UTC' };
}
if (!Array.isArray(raw.days) || raw.days.length === 0) {
return { ok: false, error: 'schedule.days must be a nonempty array' };
}
if (raw.days.some((d) => typeof d !== 'number' || !Number.isInteger(d) || d < 0 || d > 6)) {
return { ok: false, error: 'schedule.days must be integers 0..6' };
}
const days = [...new Set(raw.days as number[])].sort((a, b) => a - b);
if (days.length !== raw.days.length) {
return { ok: false, error: 'schedule.days must not contain duplicates' };
}
if (
typeof raw.start_minute !== 'number'
|| !Number.isInteger(raw.start_minute)
|| raw.start_minute < 0
|| raw.start_minute > 1439
) {
return { ok: false, error: 'schedule.start_minute must be an integer 0..1439' };
}
if (
typeof raw.end_minute !== 'number'
|| !Number.isInteger(raw.end_minute)
|| raw.end_minute < 0
|| raw.end_minute > 1439
) {
return { ok: false, error: 'schedule.end_minute must be an integer 0..1439' };
}
if (raw.start_minute === raw.end_minute) {
return { ok: false, error: 'schedule.start_minute and end_minute must differ' };
}
return {
ok: true,
schedule: {
days,
start_minute: raw.start_minute,
end_minute: raw.end_minute,
tz: 'UTC',
},
};
}
/**
* Parse a DB column value. Missing/null → legacy null (always in window).
* Non-null corrupt JSON → invalid (caller must not suppress).
*/
export function parseStoredNotificationSchedule(
raw: unknown,
): { kind: 'null' } | { kind: 'ok'; schedule: NotificationSchedule } | { kind: 'invalid' } {
if (raw == null || raw === '') return { kind: 'null' };
let parsed: unknown = raw;
if (typeof raw === 'string') {
try {
parsed = JSON.parse(raw);
} catch {
return { kind: 'invalid' };
}
}
const result = parseNotificationSchedule(parsed);
if (!result.ok) return { kind: 'invalid' };
return { kind: 'ok', schedule: result.schedule };
}
/** True when the schedule window covers atMs (UTC). Null schedule is always active. */
export function isScheduleActive(schedule: NotificationSchedule | null, atMs: number): boolean {
if (schedule == null) return true;
const date = new Date(atMs);
const day = date.getUTCDay();
const minute = date.getUTCHours() * 60 + date.getUTCMinutes();
const { days, start_minute: start, end_minute: end } = schedule;
if (start < end) {
return days.includes(day) && minute >= start && minute < end;
}
const prevDay = (day + 6) % 7;
return (days.includes(day) && minute >= start)
|| (days.includes(prevDay) && minute < end);
}
/** Whether a loaded rule may suppress at atMs (filters already matched). */
export function scheduleAllowsSuppression(
schedule: NotificationSchedule | null,
scheduleInvalid: boolean,
atMs: number,
): boolean {
if (scheduleInvalid) return false;
return isScheduleActive(schedule, atMs);
}
@@ -2,6 +2,10 @@ import { DatabaseService, type NotificationSuppressionRule, type Node } from '..
import { NodeRegistry } from '../services/NodeRegistry';
import { LicenseService } from '../services/LicenseService';
import { PROXY_TIER_HEADER } from '../services/license-headers';
import {
NOTIFICATION_SUPPRESSION_SCHEDULE_CAPABILITY,
} from '../services/CapabilityRegistry';
import { remoteAdvertisesCapability } from './remoteCapabilities';
import { getErrorMessage } from '../utils/errors';
const SYNC_TIMEOUT_MS = 15_000;
@@ -16,7 +20,12 @@ function buildRemoteHeaders(apiToken: string): Record<string, string> {
return headers;
}
function replicationTargets(rule: NotificationSuppressionRule): Node[] {
/** Hub node ids that should receive a replica for this rule (before wire identity normalize). */
export function replicationTargetIds(rule: NotificationSuppressionRule): number[] {
return replicationTargets(rule).map((n) => n.id);
}
export function replicationTargets(rule: NotificationSuppressionRule): Node[] {
const db = DatabaseService.getInstance();
const remotes = db.getNodes().filter((n) => n.type === 'remote');
if (rule.node_id != null) {
@@ -26,6 +35,10 @@ function replicationTargets(rule: NotificationSuppressionRule): Node[] {
return remotes;
}
function replicaPayload(rule: NotificationSuppressionRule): NotificationSuppressionRule {
return { ...rule, node_id: null };
}
async function pushRuleToNode(node: Node, rule: NotificationSuppressionRule): Promise<void> {
const target = NodeRegistry.getInstance().getProxyTarget(node.id);
if (!target?.apiUrl) {
@@ -36,7 +49,7 @@ async function pushRuleToNode(node: Node, rule: NotificationSuppressionRule): Pr
const res = await fetch(`${baseUrl}/api/notification-suppression-rules/replica`, {
method: 'POST',
headers: buildRemoteHeaders(target.apiToken),
body: JSON.stringify({ rule }),
body: JSON.stringify({ rule: replicaPayload(rule) }),
signal: AbortSignal.timeout(SYNC_TIMEOUT_MS),
});
if (!res.ok) {
@@ -48,8 +61,7 @@ async function pushRuleToNode(node: Node, rule: NotificationSuppressionRule): Pr
async function deleteRuleOnNode(node: Node, ruleId: number): Promise<void> {
const target = NodeRegistry.getInstance().getProxyTarget(node.id);
if (!target?.apiUrl) {
console.warn(`[SuppressionSync] Skipping node "${node.name}": no proxy target`);
return;
throw new Error(`no proxy target for node "${node.name}" (id=${node.id})`);
}
const baseUrl = target.apiUrl.replace(/\/$/, '');
const res = await fetch(`${baseUrl}/api/notification-suppression-rules/replica/${ruleId}`, {
@@ -63,6 +75,47 @@ async function deleteRuleOnNode(node: Node, ruleId: number): Promise<void> {
}
}
async function pushOrCleanupScheduled(node: Node, rule: NotificationSuppressionRule): Promise<void> {
const supportsSchedule = await remoteAdvertisesCapability(
node.id,
NOTIFICATION_SUPPRESSION_SCHEDULE_CAPABILITY,
);
if (supportsSchedule) {
await pushRuleToNode(node, rule);
return;
}
// Probe false means unsupported OR unreachable. Never POST a scheduled rule
// through the legacy contract (older remotes would mute all day). Attempt DELETE;
// only claim cleanup when DELETE succeeds.
try {
await deleteRuleOnNode(node, rule.id);
console.warn(
`[SuppressionSync] Scheduled rule ${rule.id} not applied on node "${node.name}" (id=${node.id}): ` +
`capability unsupported-or-unreachable; DELETE succeeded and replica was removed`,
);
} catch (err) {
console.error(
`[SuppressionSync] Scheduled rule ${rule.id}: cleanup pending on node "${node.name}" (id=${node.id}); ` +
`capability unsupported-or-unreachable and DELETE failed (${getErrorMessage(err, String(err))}). ` +
`Prior replica may remain until connectivity returns and the rule is re-saved`,
);
}
}
async function syncRuleToNode(node: Node, rule: NotificationSuppressionRule): Promise<void> {
if (rule.scheduleInvalid) {
console.warn(
`[SuppressionSync] Skipping push of rule ${rule.id} to node "${node.name}": corrupt schedule; not syncing as unscheduled`,
);
return;
}
if (rule.schedule != null) {
await pushOrCleanupScheduled(node, rule);
return;
}
await pushRuleToNode(node, rule);
}
/** Best-effort push of a suppression rule to fleet nodes that should evaluate it. */
export function syncSuppressionRuleToFleet(rule: NotificationSuppressionRule): void {
const targets = replicationTargets(rule);
@@ -70,7 +123,7 @@ export function syncSuppressionRuleToFleet(rule: NotificationSuppressionRule): v
void Promise.allSettled(
targets.map(async (node) => {
try {
await pushRuleToNode(node, rule);
await syncRuleToNode(node, rule);
} catch (err) {
console.error(
`[SuppressionSync] Failed to push rule ${rule.id} to node "${node.name}":`,
@@ -81,6 +134,45 @@ export function syncSuppressionRuleToFleet(rule: NotificationSuppressionRule): v
);
}
/**
* After an update, DELETE replicas on targets that left the set, then refresh
* remaining / new targets. previous is the pre-update rule (for target diff).
*/
export function syncSuppressionRuleUpdateToFleet(
previous: NotificationSuppressionRule,
updated: NotificationSuppressionRule,
): void {
const oldIds = new Set(replicationTargetIds(previous));
const newIds = new Set(replicationTargetIds(updated));
const db = DatabaseService.getInstance();
const staleIds = [...oldIds].filter((id) => !newIds.has(id));
void Promise.allSettled([
...staleIds.map(async (id) => {
const node = db.getNode(id);
if (!node || node.type !== 'remote') return;
try {
await deleteRuleOnNode(node, previous.id);
} catch (err) {
console.error(
`[SuppressionSync] Failed to delete stale rule ${previous.id} on node "${node.name}":`,
getErrorMessage(err, String(err)),
);
}
}),
...replicationTargets(updated).map(async (node) => {
try {
await syncRuleToNode(node, updated);
} catch (err) {
console.error(
`[SuppressionSync] Failed to push rule ${updated.id} to node "${node.name}":`,
getErrorMessage(err, String(err)),
);
}
}),
]);
}
/** Best-effort delete of a replicated rule on fleet nodes. */
export function deleteSuppressionRuleFromFleet(rule: NotificationSuppressionRule): void {
const targets = replicationTargets(rule);
+65 -3
View File
@@ -17,9 +17,14 @@ import {
storedAppriseToWriteConfig,
} from '../helpers/notificationChannels';
import { parseStackPatternsInput } from '../helpers/stackPattern';
import {
parseNotificationSchedule,
type NotificationSchedule,
} from '../helpers/notificationSchedule';
import {
deleteSuppressionRuleFromFleet,
syncSuppressionRuleToFleet,
syncSuppressionRuleUpdateToFleet,
} from '../helpers/notificationSuppressionSync';
import { isDebugEnabled } from '../utils/debug';
import { logDebugTiming } from '../utils/requestTiming';
@@ -109,6 +114,27 @@ function validateExpiresAt(expires_at: unknown, res: Response): number | null |
return expires_at;
}
/**
* Resolve schedule with presence semantics.
* Create/replica omit → null; PUT omit → undefined (preserve); null clears; object validates.
*/
function resolveScheduleField(
schedule: unknown,
opts: { present: boolean; isCreate: boolean },
res: Response,
): NotificationSchedule | null | undefined | false {
if (!opts.present) {
return opts.isCreate ? null : undefined;
}
if (schedule === null) return null;
const parsed = parseNotificationSchedule(schedule);
if (!parsed.ok) {
res.status(400).json({ error: parsed.error });
return false;
}
return parsed.schedule;
}
function normalizeStoredLevels(levels: unknown): ('info' | 'warning' | 'error')[] | null {
if (!Array.isArray(levels) || levels.length === 0) return null;
return levels as ('info' | 'warning' | 'error')[];
@@ -135,7 +161,7 @@ function parseSuppressionRuleBody(
req: Request,
res: Response,
isCreate: boolean,
): Omit<NotificationSuppressionRule, 'id' | 'created_at' | 'updated_at'> | null {
): Omit<NotificationSuppressionRule, 'id' | 'created_at' | 'updated_at' | 'scheduleInvalid'> | null {
const {
name,
node_id: rawNodeId,
@@ -146,6 +172,7 @@ function parseSuppressionRuleBody(
applies_to,
enabled,
expires_at,
schedule,
} = req.body;
if (isCreate && (!name || typeof name !== 'string' || !name.trim())) {
@@ -183,6 +210,12 @@ function parseSuppressionRuleBody(
const expiresAtResult = validateExpiresAt(expires_at, res);
if (expiresAtResult === false) return null;
const scheduleResult = resolveScheduleField(schedule, {
present: 'schedule' in req.body,
isCreate,
}, res);
if (scheduleResult === false) return null;
if (enabled !== undefined && typeof enabled !== 'boolean') {
res.status(400).json({ error: 'enabled must be a boolean' });
return null;
@@ -198,6 +231,7 @@ function parseSuppressionRuleBody(
applies_to: (appliesToResult ?? 'both') as NotificationSuppressionAppliesTo,
enabled: enabled !== false,
expires_at: expiresAtResult ?? null,
schedule: scheduleResult ?? null,
};
}
@@ -508,9 +542,26 @@ notificationSuppressionRouter.post('/replica', authMiddleware, (req: Request, re
res.status(400).json({ error: patterns.error });
return;
}
if (!validateLabelIds(rule.label_ids, res)) return;
if (!validateCategories(rule.categories, res, VALID_SUPPRESSION_CATEGORIES)) return;
if (!validateLevels(rule.levels, res)) return;
const scheduleResult = resolveScheduleField(rule.schedule, {
present: 'schedule' in rule,
isCreate: true,
}, res);
if (scheduleResult === false) return;
DatabaseService.getInstance().upsertNotificationSuppressionRuleReplica({
...rule,
stack_patterns: patterns.patterns,
label_ids: Array.isArray(rule.label_ids) && rule.label_ids.length > 0 ? rule.label_ids : null,
categories: Array.isArray(rule.categories) && rule.categories.length > 0 ? rule.categories : null,
levels: normalizeStoredLevels(rule.levels),
schedule: scheduleResult ?? null,
scheduleInvalid: false,
enabled: rule.enabled !== false,
expires_at: rule.expires_at ?? null,
node_id: rule.node_id ?? null,
});
res.json({ success: true });
} catch (error) {
@@ -583,6 +634,7 @@ notificationSuppressionRouter.put('/:id', authMiddleware, (req: Request, res: Re
applies_to,
enabled,
expires_at,
schedule,
} = req.body;
if (name !== undefined && (typeof name !== 'string' || !name.trim())) {
@@ -626,12 +678,21 @@ notificationSuppressionRouter.put('/:id', authMiddleware, (req: Request, res: Re
validatedExpiresAt = result;
}
let validatedSchedule: NotificationSchedule | null | undefined;
if ('schedule' in req.body) {
const result = resolveScheduleField(schedule, { present: true, isCreate: false }, res);
if (result === false) return;
validatedSchedule = result;
}
if (enabled !== undefined && typeof enabled !== 'boolean') {
res.status(400).json({ error: 'enabled must be a boolean' });
return;
}
const updates: Partial<Omit<NotificationSuppressionRule, 'id' | 'created_at'>> = { updated_at: Date.now() };
const updates: Partial<Omit<NotificationSuppressionRule, 'id' | 'created_at' | 'scheduleInvalid'>> = {
updated_at: Date.now(),
};
if (name !== undefined) updates.name = name.trim();
if (validatedNodeId !== undefined) updates.node_id = validatedNodeId;
if (cleanedPatterns !== undefined) updates.stack_patterns = cleanedPatterns;
@@ -641,11 +702,12 @@ notificationSuppressionRouter.put('/:id', authMiddleware, (req: Request, res: Re
if (validatedAppliesTo !== undefined) updates.applies_to = validatedAppliesTo;
if (enabled !== undefined) updates.enabled = enabled;
if (validatedExpiresAt !== undefined) updates.expires_at = validatedExpiresAt;
if (validatedSchedule !== undefined) updates.schedule = validatedSchedule;
const db = DatabaseService.getInstance();
db.updateNotificationSuppressionRule(id, updates);
const updated = db.getNotificationSuppressionRule(id)!;
syncSuppressionRuleToFleet(updated);
syncSuppressionRuleUpdateToFleet(existing, updated);
console.log(`[Suppression] Rule ${id} updated`);
res.json(updated);
} catch (error) {
@@ -36,6 +36,7 @@ export const CAPABILITIES = [
'notifications',
'notification-routing',
'notification-suppression',
'notification-suppression-schedule',
'host-console',
'container-exec',
'audit-log',
@@ -70,6 +71,10 @@ export const CROSS_NODE_RBAC_CAPABILITY = 'cross-node-rbac';
export type Capability = (typeof CAPABILITIES)[number];
/** Remotes that evaluate weekly maintenance windows on mute/suppression replicas. */
export const NOTIFICATION_SUPPRESSION_SCHEDULE_CAPABILITY =
'notification-suppression-schedule' as const satisfies Capability;
/** Capability for optional `?removeVolumes=true` on POST /stacks/:name/down. */
export const STACK_DOWN_REMOVE_VOLUMES_CAPABILITY = 'stack-down-remove-volumes' as const satisfies Capability;
+31 -6
View File
@@ -10,6 +10,10 @@ import { EXPOSURE_INTENTS, type ExposureIntent } from './network/types';
import { HIGH_EPSS_THRESHOLD } from './securityPosture';
import type { BackendScheduledAction } from './scheduledActionRegistry';
import { stackPatternMatches } from '../helpers/stackPattern';
import {
parseStoredNotificationSchedule,
type NotificationSchedule,
} from '../helpers/notificationSchedule';
import { readSnapshotFileRow, type SnapshotFileReadResult, type SnapshotFileRow } from '../helpers/snapshotFileDecrypt';
export type { SnapshotFileReadResult } from '../helpers/snapshotFileDecrypt';
@@ -717,6 +721,10 @@ export interface NotificationSuppressionRule {
applies_to: NotificationSuppressionAppliesTo;
enabled: boolean;
expires_at: number | null;
/** Null = always in window (legacy). Ignored when scheduleInvalid. */
schedule: NotificationSchedule | null;
/** True when stored JSON is non-null but corrupt; must not suppress. */
scheduleInvalid: boolean;
created_at: number;
updated_at: number;
}
@@ -2114,6 +2122,7 @@ export class DatabaseService {
CREATE INDEX IF NOT EXISTS idx_notification_suppression_enabled
ON notification_suppression_rules(enabled, expires_at);
`);
this.tryAddColumn('notification_suppression_rules', 'schedule', 'TEXT NULL');
}
private migrateNotificationHistoryContext(): void {
@@ -2704,6 +2713,12 @@ export class DatabaseService {
// --- Notification Suppression Rules ---
private parseNotificationSuppressionRule(row: Record<string, unknown>): NotificationSuppressionRule {
const stored = parseStoredNotificationSchedule(row.schedule);
if (stored.kind === 'invalid') {
console.warn(
`[DatabaseService] Ignoring corrupt notification suppression schedule on rule id=${row.id as number}`,
);
}
return {
id: row.id as number,
name: row.name as string,
@@ -2715,6 +2730,8 @@ export class DatabaseService {
applies_to: row.applies_to as NotificationSuppressionAppliesTo,
enabled: row.enabled === 1,
expires_at: row.expires_at != null ? (row.expires_at as number) : null,
schedule: stored.kind === 'ok' ? stored.schedule : null,
scheduleInvalid: stored.kind === 'invalid',
created_at: row.created_at as number,
updated_at: row.updated_at as number,
};
@@ -2740,10 +2757,10 @@ export class DatabaseService {
}
public createNotificationSuppressionRule(
rule: Omit<NotificationSuppressionRule, 'id'>,
rule: Omit<NotificationSuppressionRule, 'id' | 'scheduleInvalid'>,
): NotificationSuppressionRule {
const result = this.db.prepare(
'INSERT INTO notification_suppression_rules (name, node_id, stack_patterns, label_ids, categories, levels, applies_to, enabled, expires_at, created_at, updated_at) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)',
'INSERT INTO notification_suppression_rules (name, node_id, stack_patterns, label_ids, categories, levels, applies_to, enabled, expires_at, schedule, created_at, updated_at) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)',
).run(
rule.name,
rule.node_id ?? null,
@@ -2754,6 +2771,7 @@ export class DatabaseService {
rule.applies_to,
rule.enabled ? 1 : 0,
rule.expires_at ?? null,
rule.schedule ? JSON.stringify(rule.schedule) : null,
rule.created_at,
rule.updated_at,
);
@@ -2761,12 +2779,13 @@ export class DatabaseService {
}
public upsertNotificationSuppressionRuleReplica(rule: NotificationSuppressionRule): void {
const scheduleJson = rule.schedule ? JSON.stringify(rule.schedule) : null;
const existing = this.getNotificationSuppressionRule(rule.id);
if (existing) {
this.db.prepare(
`UPDATE notification_suppression_rules SET
name = ?, node_id = ?, stack_patterns = ?, label_ids = ?, categories = ?, levels = ?,
applies_to = ?, enabled = ?, expires_at = ?, updated_at = ?
applies_to = ?, enabled = ?, expires_at = ?, schedule = ?, updated_at = ?
WHERE id = ?`,
).run(
rule.name,
@@ -2778,6 +2797,7 @@ export class DatabaseService {
rule.applies_to,
rule.enabled ? 1 : 0,
rule.expires_at ?? null,
scheduleJson,
rule.updated_at,
rule.id,
);
@@ -2785,8 +2805,8 @@ export class DatabaseService {
}
this.db.prepare(
`INSERT INTO notification_suppression_rules
(id, name, node_id, stack_patterns, label_ids, categories, levels, applies_to, enabled, expires_at, created_at, updated_at)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
(id, name, node_id, stack_patterns, label_ids, categories, levels, applies_to, enabled, expires_at, schedule, created_at, updated_at)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
).run(
rule.id,
rule.name,
@@ -2798,6 +2818,7 @@ export class DatabaseService {
rule.applies_to,
rule.enabled ? 1 : 0,
rule.expires_at ?? null,
scheduleJson,
rule.created_at,
rule.updated_at,
);
@@ -2805,7 +2826,7 @@ export class DatabaseService {
public updateNotificationSuppressionRule(
id: number,
updates: Partial<Omit<NotificationSuppressionRule, 'id' | 'created_at'>>,
updates: Partial<Omit<NotificationSuppressionRule, 'id' | 'created_at' | 'scheduleInvalid'>>,
): void {
const fields: string[] = [];
const values: unknown[] = [];
@@ -2819,6 +2840,10 @@ export class DatabaseService {
if (updates.applies_to !== undefined) { fields.push('applies_to = ?'); values.push(updates.applies_to); }
if (updates.enabled !== undefined) { fields.push('enabled = ?'); values.push(updates.enabled ? 1 : 0); }
if ('expires_at' in updates) { fields.push('expires_at = ?'); values.push(updates.expires_at ?? null); }
if ('schedule' in updates) {
fields.push('schedule = ?');
values.push(updates.schedule ? JSON.stringify(updates.schedule) : null);
}
if (updates.updated_at !== undefined) { fields.push('updated_at = ?'); values.push(updates.updated_at); }
if (fields.length === 0) return;
+7 -2
View File
@@ -12,6 +12,7 @@ import {
matchesNotificationFilters,
ruleNeedsStackLabels,
} from '../helpers/notificationMatchers';
import { scheduleAllowsSuppression } from '../helpers/notificationSchedule';
import {
type NotificationChannelType,
type ParsedAppriseConfig,
@@ -230,7 +231,8 @@ export class NotificationService {
});
}
const suppressionRules = this.dbService.getEnabledNotificationSuppressionRules();
const atMs = Date.now();
const suppressionRules = this.dbService.getEnabledNotificationSuppressionRules(atMs);
const routes = this.dbService.getEnabledNotificationRoutes();
const needsStackLabels = stackName !== undefined && (
ruleNeedsStackLabels(suppressionRules)
@@ -246,7 +248,10 @@ export class NotificationService {
level,
stackLabelIds,
};
const matchedSuppression = suppressionRules.filter((r) => matchesNotificationFilters(matchCtx, r));
const matchedSuppression = suppressionRules.filter((r) =>
matchesNotificationFilters(matchCtx, r)
&& scheduleAllowsSuppression(r.schedule, r.scheduleInvalid, atMs)
);
const suppressBell = matchedSuppression.some((r) => appliesToBell(r.applies_to));
const suppressExternal = matchedSuppression.some((r) => appliesToExternal(r.applies_to));
+4 -1
View File
@@ -123,11 +123,14 @@ Open **Settings · Notifications · Mute Rules** and click **+ Add mute rule**.
| **Severity** *(optional)* | One or more of info, warning, or error. Empty matches any severity. |
| **Apply to** | **Bell**, **External**, or **Both**. Bell skips the notification popover WebSocket push. External skips routing rules and global channels. |
| **Expiration** | Forever, 1 hour, 24 hours, or a custom date. Expired rules stop matching automatically. |
| **Weekly window (UTC)** *(optional)* | Recurring weekly maintenance window. Choose one or more start days and a UTC start/end time. The rule only mutes while the window is active. Same-day windows use an inclusive start and exclusive end. Cross-midnight windows (for example Sat 22:00 to Sun 02:00) list only the start day. Leave off for always-on mute (subject to expiration). |
| **Enabled** | Toggle the rule without deleting it. |
All non-empty matchers must match (AND). Suppressed alerts are still written to stack activity history; only delivery is affected. When a mute rule blocks delivery, the activity row shows a **Suppressed** badge with the matched rule name in a tooltip.
Rules you create on the control instance replicate to remote nodes so alerts emitted on a remote stack honor the same suppression. From the bell, admins can open a row menu and choose **Mute this category**, **Mute notifications like this**, or **Mute this stack** to create a quick rule with default **Both** targeting. The same presets are available from stack menus (sidebar, stack header, activity tab), fleet node cards, and label groups.
Rules you create on the control instance replicate to remote nodes so alerts emitted on a remote stack honor the same suppression. Replication is best-effort. Remotes that do not support weekly windows do not receive scheduled replicas as all-day mutes: Sencho skips the push and attempts to remove any prior replica. If a remote is unreachable, cleanup may stay pending until connectivity returns and you re-save or clear the rule. Before downgrading a node past a release that introduced weekly windows, clear every weekly window (or disable those rules) so an older binary does not treat them as always-on.
From the bell, admins can open a row menu and choose **Mute this category**, **Mute notifications like this**, or **Mute this stack** to create a quick rule with default **Both** targeting and no weekly window. The same presets are available from stack menus (sidebar, stack header, activity tab), fleet node cards, and label groups.
### Built-in bell quieting (not a mute rule)
+2 -1
View File
@@ -447,9 +447,10 @@ Create notification suppression rules that mute or drop matching alerts from the
| **Severity** | Info, warning, and/or error levels to suppress. |
| **Apply to** | Bell only, external channels only, or both. |
| **Expiration** | Forever, 1 hour, 24 hours, or a custom timestamp. |
| **Weekly window (UTC)** | Optional recurring weekly window. Off means no window (always eligible while enabled and unexpired). ACTIVE counts still reflect enabled and unexpired rules, not whether the window is currently open. |
| **Enabled** toggle | Disable a rule without deleting it. |
See [Mute Rules](/features/alerts-notifications#mute-rules) for the full walkthrough, compose-first shortcuts, and bell quick-mute.
See [Mute Rules](/features/alerts-notifications#mute-rules) for the full walkthrough, weekly-window semantics, best-effort fleet sync, and bell quick-mute.
---
@@ -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,