From 48a2662f5ea0dff01514a103ae42e7d29dac211a Mon Sep 17 00:00:00 2001 From: SaelixCode Date: Mon, 20 Jul 2026 23:46:15 -0400 Subject: [PATCH] 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. --- .../__tests__/notification-schedule.test.ts | 107 +++++++++ ...otification-suppression-routes-api.test.ts | 132 +++++++++++ ...ion-suppression-schedule-migration.test.ts | 92 +++++++ .../notification-suppression-sync.test.ts | 224 ++++++++++++++++++ .../notification-suppression.test.ts | 72 ++++++ backend/src/helpers/notificationSchedule.ts | 119 ++++++++++ .../helpers/notificationSuppressionSync.ts | 102 +++++++- backend/src/routes/notifications.ts | 68 +++++- backend/src/services/CapabilityRegistry.ts | 5 + backend/src/services/DatabaseService.ts | 37 ++- backend/src/services/NotificationService.ts | 9 +- docs/features/alerts-notifications.mdx | 5 +- docs/reference/settings.mdx | 3 +- .../NotificationSuppressionSection.tsx | 136 +++++++++++ frontend/src/lib/capabilities.ts | 1 + frontend/src/lib/muteRules.test.ts | 30 +++ frontend/src/lib/muteRules.ts | 9 + 17 files changed, 1133 insertions(+), 18 deletions(-) create mode 100644 backend/src/__tests__/notification-schedule.test.ts create mode 100644 backend/src/__tests__/notification-suppression-schedule-migration.test.ts create mode 100644 backend/src/__tests__/notification-suppression-sync.test.ts create mode 100644 backend/src/helpers/notificationSchedule.ts create mode 100644 frontend/src/lib/muteRules.test.ts diff --git a/backend/src/__tests__/notification-schedule.test.ts b/backend/src/__tests__/notification-schedule.test.ts new file mode 100644 index 00000000..ea9eaaca --- /dev/null +++ b/backend/src/__tests__/notification-schedule.test.ts @@ -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' }); + }); +}); diff --git a/backend/src/__tests__/notification-suppression-routes-api.test.ts b/backend/src/__tests__/notification-suppression-routes-api.test.ts index 75d344a8..06ffca61 100644 --- a/backend/src/__tests__/notification-suppression-routes-api.test.ts +++ b/backend/src/__tests__/notification-suppression-routes-api.test.ts @@ -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); }); }); diff --git a/backend/src/__tests__/notification-suppression-schedule-migration.test.ts b/backend/src/__tests__/notification-suppression-schedule-migration.test.ts new file mode 100644 index 00000000..23f80003 --- /dev/null +++ b/backend/src/__tests__/notification-suppression-schedule-migration.test.ts @@ -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); + }); +}); diff --git a/backend/src/__tests__/notification-suppression-sync.test.ts b/backend/src/__tests__/notification-suppression-sync.test.ts new file mode 100644 index 00000000..6240222b --- /dev/null +++ b/backend/src/__tests__/notification-suppression-sync.test.ts @@ -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 { + 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); + }); +}); diff --git a/backend/src/__tests__/notification-suppression.test.ts b/backend/src/__tests__/notification-suppression.test.ts index 5c03d0da..0e172f97 100644 --- a/backend/src/__tests__/notification-suppression.test.ts +++ b/backend/src/__tests__/notification-suppression.test.ts @@ -73,6 +73,8 @@ function makeSuppressionRule(overrides: Record = {}) { 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(); + }); }); diff --git a/backend/src/helpers/notificationSchedule.ts b/backend/src/helpers/notificationSchedule.ts new file mode 100644 index 00000000..c506c3b4 --- /dev/null +++ b/backend/src/helpers/notificationSchedule.ts @@ -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 { + 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); +} diff --git a/backend/src/helpers/notificationSuppressionSync.ts b/backend/src/helpers/notificationSuppressionSync.ts index 5085a4ca..1387f1ad 100644 --- a/backend/src/helpers/notificationSuppressionSync.ts +++ b/backend/src/helpers/notificationSuppressionSync.ts @@ -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 { 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 { 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 { 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 { } } +async function pushOrCleanupScheduled(node: Node, rule: NotificationSuppressionRule): Promise { + 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 { + 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); diff --git a/backend/src/routes/notifications.ts b/backend/src/routes/notifications.ts index fc7b8b60..04d80e25 100644 --- a/backend/src/routes/notifications.ts +++ b/backend/src/routes/notifications.ts @@ -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 | null { +): Omit | 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> = { updated_at: Date.now() }; + const updates: Partial> = { + 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) { diff --git a/backend/src/services/CapabilityRegistry.ts b/backend/src/services/CapabilityRegistry.ts index 3b9461fa..1ce65b55 100644 --- a/backend/src/services/CapabilityRegistry.ts +++ b/backend/src/services/CapabilityRegistry.ts @@ -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; diff --git a/backend/src/services/DatabaseService.ts b/backend/src/services/DatabaseService.ts index 5f7cce45..3b48f7df 100644 --- a/backend/src/services/DatabaseService.ts +++ b/backend/src/services/DatabaseService.ts @@ -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): 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, + rule: Omit, ): 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>, + updates: Partial>, ): 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; diff --git a/backend/src/services/NotificationService.ts b/backend/src/services/NotificationService.ts index b1f139ac..2a6ebbee 100644 --- a/backend/src/services/NotificationService.ts +++ b/backend/src/services/NotificationService.ts @@ -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)); diff --git a/docs/features/alerts-notifications.mdx b/docs/features/alerts-notifications.mdx index ff612474..efa0acc8 100644 --- a/docs/features/alerts-notifications.mdx +++ b/docs/features/alerts-notifications.mdx @@ -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) diff --git a/docs/reference/settings.mdx b/docs/reference/settings.mdx index a7aae0bb..978a24bb 100644 --- a/docs/reference/settings.mdx +++ b/docs/reference/settings.mdx @@ -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. --- diff --git a/frontend/src/components/settings/NotificationSuppressionSection.tsx b/frontend/src/components/settings/NotificationSuppressionSection.tsx index a32e8d1c..1855b011 100644 --- a/frontend/src/components/settings/NotificationSuppressionSection.tsx +++ b/frontend/src/components/settings/NotificationSuppressionSection.tsx @@ -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 = { info: 'Info', warning: 'Warning', @@ -127,6 +158,10 @@ export function NotificationSuppressionSection({ const [formEnabled, setFormEnabled] = useState(true); const [formExpirationPreset, setFormExpirationPreset] = useState('forever'); const [formCustomExpiry, setFormCustomExpiry] = useState(''); + const [formScheduleEnabled, setFormScheduleEnabled] = useState(false); + const [formScheduleDays, setFormScheduleDays] = useState([]); + 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({ )} +
+
+ + +
+ {formScheduleEnabled && ( +
+
+ {DAY_LABELS.map((label, day) => { + const selected = formScheduleDays.includes(day); + return ( + + ); + })} +
+
+
+ + setFormScheduleStart(e.target.value)} /> +
+
+ + setFormScheduleEnd(e.target.value)} /> +
+
+

+ Outside this window the rule does not mute. Cross-midnight windows use the start day only. +

+
+ )} +
+
@@ -553,6 +683,12 @@ export function NotificationSuppressionSection({ )} | Expires: {formatExpiry(rule.expires_at)} + {formatScheduleSummary(rule.schedule) && ( + <> + | + {formatScheduleSummary(rule.schedule)} + + )}
))} diff --git a/frontend/src/lib/capabilities.ts b/frontend/src/lib/capabilities.ts index ea62818b..850581e5 100644 --- a/frontend/src/lib/capabilities.ts +++ b/frontend/src/lib/capabilities.ts @@ -14,6 +14,7 @@ export const CAPABILITIES = [ 'notifications', 'notification-routing', 'notification-suppression', + 'notification-suppression-schedule', 'host-console', 'container-exec', 'audit-log', diff --git a/frontend/src/lib/muteRules.test.ts b/frontend/src/lib/muteRules.test.ts new file mode 100644 index 00000000..47dfb0e3 --- /dev/null +++ b/frontend/src/lib/muteRules.test.ts @@ -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'), + }), + ); + }); +}); diff --git a/frontend/src/lib/muteRules.ts b/frontend/src/lib/muteRules.ts index dba9ba93..41051b4e 100644 --- a/frontend/src/lib/muteRules.ts +++ b/frontend/src/lib/muteRules.ts @@ -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,