diff --git a/backend/src/__tests__/notification-schedule.test.ts b/backend/src/__tests__/notification-schedule.test.ts new file mode 100644 index 00000000..c9b8da67 --- /dev/null +++ b/backend/src/__tests__/notification-schedule.test.ts @@ -0,0 +1,114 @@ +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' }); + expect(parseStoredNotificationSchedule(undefined)).toEqual({ kind: 'null' }); + }); + + it('treats empty and whitespace strings as invalid, not legacy null', () => { + expect(parseStoredNotificationSchedule('')).toEqual({ kind: 'invalid' }); + expect(parseStoredNotificationSchedule(' ')).toEqual({ kind: 'invalid' }); + expect(parseStoredNotificationSchedule('null')).toEqual({ kind: 'invalid' }); + }); +}); diff --git a/backend/src/__tests__/notification-suppression-routes-api.test.ts b/backend/src/__tests__/notification-suppression-routes-api.test.ts index 75d344a8..ef6c54ce 100644 --- a/backend/src/__tests__/notification-suppression-routes-api.test.ts +++ b/backend/src/__tests__/notification-suppression-routes-api.test.ts @@ -235,7 +235,7 @@ describe('Notification suppression - CRUD', () => { .set('Authorization', `Bearer ${token}`) .send({ rule: { - id: 9001, + id: 910001, name: 'replica', applies_to: 'both', node_id: null, @@ -255,7 +255,7 @@ describe('Notification suppression - CRUD', () => { .set('Authorization', `Bearer ${token}`) .send({ rule: { - id: 9001, + id: 910001, name: 'replica', applies_to: 'both', stack_patterns: ['****'], @@ -270,14 +270,14 @@ describe('Notification suppression - CRUD', () => { }, }); expect(redos.status).toBe(400); - expect(DatabaseService.getInstance().getNotificationSuppressionRule(9001)).toBeUndefined(); + expect(DatabaseService.getInstance().getNotificationSuppressionRule(910001)).toBeUndefined(); const ok = await request(app) .post('/api/notification-suppression-rules/replica') .set('Authorization', `Bearer ${token}`) .send({ rule: { - id: 9001, + id: 910001, name: 'replica', applies_to: 'both', stack_patterns: ['prod-*'], @@ -292,7 +292,341 @@ describe('Notification suppression - CRUD', () => { }, }); expect(ok.status).toBe(200); - expect(DatabaseService.getInstance().getNotificationSuppressionRule(9001)?.stack_patterns).toEqual(['prod-*']); - DatabaseService.getInstance().deleteNotificationSuppressionRule(9001); + expect(DatabaseService.getInstance().getNotificationSuppressionRule(910001)?.stack_patterns).toEqual(['prod-*']); + expect(DatabaseService.getInstance().getNotificationSuppressionRule(910001)?.schedule).toBeNull(); + DatabaseService.getInstance().deleteNotificationSuppressionRule(910001); + + const omitSched = await request(app) + .post('/api/notification-suppression-rules/replica') + .set('Authorization', `Bearer ${token}`) + .send({ + rule: { + id: 910003, + 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(910003)?.schedule).toBeNull(); + DatabaseService.getInstance().deleteNotificationSuppressionRule(910003); + }); + + 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: 920002, + 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: 920002, + 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(920002)?.schedule).toEqual({ + days: [6], + start_minute: 1320, + end_minute: 120, + tz: 'UTC', + }); + DatabaseService.getInstance().deleteNotificationSuppressionRule(920002); + }); + + it('replica forces node_id to null regardless of the payload value', 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 res = await request(app) + .post('/api/notification-suppression-rules/replica') + .set('Authorization', `Bearer ${token}`) + .send({ + rule: { + id: 930004, + name: 'replica-scoped', + applies_to: 'both', + stack_patterns: [], + node_id: 5, + label_ids: null, + categories: null, + levels: null, + enabled: true, + expires_at: null, + created_at: 1, + updated_at: 1, + }, + }); + expect(res.status).toBe(200); + expect(DatabaseService.getInstance().getNotificationSuppressionRule(930004)?.node_id).toBeNull(); + DatabaseService.getInstance().deleteNotificationSuppressionRule(930004); + }); + + it('replica ignores a stale write with an older updated_at than the stored row', 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 replicaRule = (overrides: Record) => ({ + id: 940005, + name: 'replica-race', + applies_to: 'both', + stack_patterns: [], + node_id: null, + label_ids: null, + categories: null, + levels: null, + enabled: true, + expires_at: null, + created_at: 1, + ...overrides, + }); + + const first = await request(app) + .post('/api/notification-suppression-rules/replica') + .set('Authorization', `Bearer ${token}`) + .send({ rule: replicaRule({ name: 'v2-newer', updated_at: 2000 }) }); + expect(first.status).toBe(200); + + const stale = await request(app) + .post('/api/notification-suppression-rules/replica') + .set('Authorization', `Bearer ${token}`) + .send({ rule: replicaRule({ name: 'v1-delayed-stale', updated_at: 1000 }) }); + expect(stale.status).toBe(200); + expect(DatabaseService.getInstance().getNotificationSuppressionRule(940005)?.name).toBe('v2-newer'); + + const tie = await request(app) + .post('/api/notification-suppression-rules/replica') + .set('Authorization', `Bearer ${token}`) + .send({ rule: replicaRule({ name: 'v2-tie-same-timestamp', updated_at: 2000 }) }); + expect(tie.status).toBe(200); + expect(DatabaseService.getInstance().getNotificationSuppressionRule(940005)?.name).toBe('v2-newer'); + + const newer = await request(app) + .post('/api/notification-suppression-rules/replica') + .set('Authorization', `Bearer ${token}`) + .send({ rule: replicaRule({ name: 'v3-newest', updated_at: 3000 }) }); + expect(newer.status).toBe(200); + expect(DatabaseService.getInstance().getNotificationSuppressionRule(940005)?.name).toBe('v3-newest'); + + DatabaseService.getInstance().deleteNotificationSuppressionRule(940005); + }); + + it('replica does not resurrect a rule after it was deleted, even with a newer updated_at', 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 replicaRule = (overrides: Record) => ({ + id: 950006, + name: 'replica-delete-race', + applies_to: 'both', + stack_patterns: [], + node_id: null, + label_ids: null, + categories: null, + levels: null, + enabled: true, + expires_at: null, + created_at: 1, + ...overrides, + }); + + const first = await request(app) + .post('/api/notification-suppression-rules/replica') + .set('Authorization', `Bearer ${token}`) + .send({ rule: replicaRule({ updated_at: 1000 }) }); + expect(first.status).toBe(200); + expect(DatabaseService.getInstance().getNotificationSuppressionRule(950006)).not.toBeUndefined(); + + const del = await request(app) + .delete('/api/notification-suppression-rules/replica/950006') + .set('Authorization', `Bearer ${token}`); + expect(del.status).toBe(200); + expect(DatabaseService.getInstance().getNotificationSuppressionRule(950006)).toBeUndefined(); + + // A delayed POST arrives after the DELETE, reordered by the network. Even + // though its updated_at is newer than anything the sender ever sent before + // the delete, the delete is authoritative: this id must stay gone. + const delayed = await request(app) + .post('/api/notification-suppression-rules/replica') + .set('Authorization', `Bearer ${token}`) + .send({ rule: replicaRule({ updated_at: 2000 }) }); + expect(delayed.status).toBe(200); + expect(DatabaseService.getInstance().getNotificationSuppressionRule(950006)).toBeUndefined(); + }); + + it('replica DELETE tombstones an id even when the remote never had that row', 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' }); + + // This remote never received rule 960007 (e.g. it just enrolled, or the rule + // failed capability probing before its first push). A cleanup DELETE still + // arrives unconditionally from deleteRuleOnNode. A POST reordered behind it + // must not be allowed to create the rule for the first time. + const del = await request(app) + .delete('/api/notification-suppression-rules/replica/960007') + .set('Authorization', `Bearer ${token}`); + expect(del.status).toBe(200); + expect(DatabaseService.getInstance().getNotificationSuppressionRule(960007)).toBeUndefined(); + + const delayed = await request(app) + .post('/api/notification-suppression-rules/replica') + .set('Authorization', `Bearer ${token}`) + .send({ + rule: { + id: 960007, + name: 'replica-delete-before-first-post', + 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(delayed.status).toBe(200); + expect(DatabaseService.getInstance().getNotificationSuppressionRule(960007)).toBeUndefined(); + }); + + it('replica does not resurrect a deleted rule with a 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 replicaRule = (overrides: Record) => ({ + id: 970008, + name: 'replica-scheduled-delete-race', + 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: 120, end_minute: 360, tz: 'UTC' }, + created_at: 1, + ...overrides, + }); + + const first = await request(app) + .post('/api/notification-suppression-rules/replica') + .set('Authorization', `Bearer ${token}`) + .send({ rule: replicaRule({ updated_at: 1000 }) }); + expect(first.status).toBe(200); + expect(DatabaseService.getInstance().getNotificationSuppressionRule(970008)?.schedule).not.toBeNull(); + + // This is the worst case the fix protects: a capability-cleanup DELETE + // retracts an all-day/scheduled mute from a node that stopped supporting + // it. A delayed re-push of the scheduled rule must not undo that cleanup. + const del = await request(app) + .delete('/api/notification-suppression-rules/replica/970008') + .set('Authorization', `Bearer ${token}`); + expect(del.status).toBe(200); + + const delayed = await request(app) + .post('/api/notification-suppression-rules/replica') + .set('Authorization', `Bearer ${token}`) + .send({ rule: replicaRule({ updated_at: 2000 }) }); + expect(delayed.status).toBe(200); + expect(DatabaseService.getInstance().getNotificationSuppressionRule(970008)).toBeUndefined(); }); }); 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..dec4d541 --- /dev/null +++ b/backend/src/__tests__/notification-suppression-schedule-migration.test.ts @@ -0,0 +1,135 @@ +/** + * 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'; +import { scheduleAllowsSuppression } from '../helpers/notificationSchedule'; + +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); + }); + + it('empty-string schedule column is invalid and does not suppress via enabled load', { timeout: 60_000 }, () => { + scratchDir = fs.mkdtempSync(path.join(os.tmpdir(), 'sencho-supp-sched-empty-')); + 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, + schedule TEXT 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, schedule, created_at, updated_at) + VALUES ('Empty schedule mute', NULL, '[]', NULL, '["monitor_alert"]', 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 rule = db.getEnabledNotificationSuppressionRules().find((r) => r.name === 'Empty schedule mute'); + expect(rule).toBeDefined(); + expect(rule!.schedule).toBeNull(); + expect(rule!.scheduleInvalid).toBe(true); + + expect(scheduleAllowsSuppression(rule!.schedule, rule!.scheduleInvalid, Date.now())).toBe(false); + }); +}); 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..3a401972 --- /dev/null +++ b/backend/src/__tests__/notification-suppression-sync.test.ts @@ -0,0 +1,275 @@ +/** + * 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('scheduleInvalid: DELETE success, no POST', async () => { + const warn = vi.spyOn(console, 'warn').mockImplementation(() => {}); + const error = vi.spyOn(console, 'error').mockImplementation(() => {}); + mockFetch.mockResolvedValue({ ok: true, status: 200, text: async () => '' }); + + syncSuppressionRuleToFleet(makeRule({ + node_id: 10, + schedule: null, + scheduleInvalid: true, + })); + await vi.waitFor(() => { + expect(warn.mock.calls.some((c) => String(c[0]).includes('replica removed'))).toBe(true); + }); + + 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(false); + }); + + it('scheduleInvalid: DELETE 404 counts as cleanup success, no POST', async () => { + const warn = vi.spyOn(console, 'warn').mockImplementation(() => {}); + mockFetch.mockResolvedValue({ ok: false, status: 404, text: async () => 'gone' }); + + syncSuppressionRuleToFleet(makeRule({ + node_id: 10, + schedule: null, + scheduleInvalid: true, + })); + await vi.waitFor(() => { + expect(warn.mock.calls.some((c) => String(c[0]).includes('replica removed'))).toBe(true); + }); + expect(mockFetch.mock.calls.every((c) => (c[1] as { method: string }).method === 'DELETE')).toBe(true); + }); + + it('scheduleInvalid: DELETE failure logs pending cleanup, no POST', async () => { + const error = vi.spyOn(console, 'error').mockImplementation(() => {}); + mockFetch.mockResolvedValue({ ok: false, status: 503, text: async () => 'down' }); + + syncSuppressionRuleToFleet(makeRule({ + node_id: 10, + schedule: null, + scheduleInvalid: true, + })); + 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); + }); + + it('scheduleInvalid: no proxy target logs pending, no POST', async () => { + const warn = vi.spyOn(console, 'warn').mockImplementation(() => {}); + const error = vi.spyOn(console, 'error').mockImplementation(() => {}); + mockGetProxyTarget.mockReturnValue(null); + + syncSuppressionRuleToFleet(makeRule({ + node_id: 10, + schedule: null, + scheduleInvalid: true, + })); + await vi.waitFor(() => expect(error).toHaveBeenCalled()); + expect(mockFetch).not.toHaveBeenCalled(); + expect(warn.mock.calls.some((c) => String(c[0]).includes('replica removed'))).toBe(false); + expect(error.mock.calls.some((c) => String(c[0]).includes('cleanup pending'))).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..5f7465bb --- /dev/null +++ b/backend/src/helpers/notificationSchedule.ts @@ -0,0 +1,126 @@ +/** + * 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. Only SQL null / undefined → legacy null (always in window). + * Empty string, whitespace, or any non-null corrupt value → invalid (must not suppress). + */ +export function parseStoredNotificationSchedule( + raw: unknown, +): { kind: 'null' } | { kind: 'ok'; schedule: NotificationSchedule } | { kind: 'invalid' } { + if (raw == null) return { kind: 'null' }; + if (typeof raw === 'string') { + const trimmed = raw.trim(); + if (trimmed === '') return { kind: 'invalid' }; + let parsed: unknown; + try { + parsed = JSON.parse(trimmed); + } catch { + return { kind: 'invalid' }; + } + // JSON null is not SQL NULL; treat as corrupt rather than legacy always-on. + if (parsed == null) return { kind: 'invalid' }; + const result = parseNotificationSchedule(parsed); + if (!result.ok) return { kind: 'invalid' }; + return { kind: 'ok', schedule: result.schedule }; + } + const result = parseNotificationSchedule(raw); + 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..a5f641c8 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,61 @@ 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 cleanupInvalidScheduleReplica(node: Node, rule: NotificationSuppressionRule): Promise { + // Never POST an invalid schedule (would mute all day on remotes that ignore the field). + // Attempt DELETE so a prior valid/unscheduled replica cannot keep muting. + try { + await deleteRuleOnNode(node, rule.id); + console.warn( + `[SuppressionSync] Corrupt schedule on rule ${rule.id}: replica removed on node "${node.name}" (id=${node.id}); not posting`, + ); + } catch (err) { + console.error( + `[SuppressionSync] Corrupt schedule on rule ${rule.id}: cleanup pending on node "${node.name}" (id=${node.id}); ` + + `DELETE failed (${getErrorMessage(err, String(err))}). Prior replica may remain until connectivity returns`, + ); + } +} + +async function syncRuleToNode(node: Node, rule: NotificationSuppressionRule): Promise { + if (rule.scheduleInvalid) { + await cleanupInvalidScheduleReplica(node, rule); + 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 +137,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 +148,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..d253dcbc 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,28 @@ 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, + // Replicas are always node-agnostic on the receiving node: the hub's node_id + // is a hub-local scoping concept and never trustworthy as a foreign key here. + node_id: null, }); res.json({ success: true }); } catch (error) { @@ -583,6 +636,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 +680,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 +704,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 406bf374..03663fc0 100644 --- a/backend/src/services/DatabaseService.ts +++ b/backend/src/services/DatabaseService.ts @@ -10,7 +10,12 @@ 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'; +import { sanitizeForLog } from '../utils/safeLog'; export type { SnapshotFileReadResult } from '../helpers/snapshotFileDecrypt'; @@ -753,6 +758,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; } @@ -2195,7 +2204,12 @@ export class DatabaseService { ); CREATE INDEX IF NOT EXISTS idx_notification_suppression_enabled ON notification_suppression_rules(enabled, expires_at); + CREATE TABLE IF NOT EXISTS notification_suppression_rule_tombstones ( + id INTEGER PRIMARY KEY, + deleted_at INTEGER NOT NULL + ); `); + this.tryAddColumn('notification_suppression_rules', 'schedule', 'TEXT NULL'); } private migrateNotificationHistoryContext(): void { @@ -2786,6 +2800,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, @@ -2797,6 +2817,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, }; @@ -2822,10 +2844,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, @@ -2836,6 +2858,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, ); @@ -2843,12 +2866,22 @@ 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) { + // Fleet sync is fire-and-forget with no delivery ordering guarantee: a + // push that isn't strictly newer than what's stored must never overwrite it. + if (existing.updated_at >= rule.updated_at) { + console.warn( + `[DatabaseService] Ignoring stale suppression replica write for rule id=${sanitizeForLog(rule.id)} ` + + `(incoming updated_at=${sanitizeForLog(rule.updated_at)} <= stored updated_at=${sanitizeForLog(existing.updated_at)})`, + ); + return; + } 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, @@ -2860,15 +2893,26 @@ export class DatabaseService { rule.applies_to, rule.enabled ? 1 : 0, rule.expires_at ?? null, + scheduleJson, rule.updated_at, rule.id, ); return; } + const tombstone = this.db.prepare( + 'SELECT deleted_at FROM notification_suppression_rule_tombstones WHERE id = ?', + ).get(rule.id) as { deleted_at: number } | undefined; + if (tombstone) { + console.warn( + `[DatabaseService] Ignoring suppression replica write for rule id=${sanitizeForLog(rule.id)}: ` + + `this id was deleted at ${sanitizeForLog(tombstone.deleted_at)} and must not be recreated`, + ); + return; + } 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, @@ -2880,6 +2924,7 @@ export class DatabaseService { rule.applies_to, rule.enabled ? 1 : 0, rule.expires_at ?? null, + scheduleJson, rule.created_at, rule.updated_at, ); @@ -2887,7 +2932,7 @@ export class DatabaseService { public updateNotificationSuppressionRule( id: number, - updates: Partial>, + updates: Partial>, ): void { const fields: string[] = []; const values: unknown[] = []; @@ -2901,6 +2946,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; @@ -2909,7 +2958,18 @@ export class DatabaseService { } public deleteNotificationSuppressionRule(id: number): number { - return this.db.prepare('DELETE FROM notification_suppression_rules WHERE id = ?').run(id).changes; + const changes = this.db.prepare('DELETE FROM notification_suppression_rules WHERE id = ?').run(id).changes; + // Fleet sync has no delivery ordering guarantee: a replica POST for this id can + // still be in flight. Record the delete permanently (ids are AUTOINCREMENT and + // never reused) so upsertNotificationSuppressionRuleReplica refuses to resurrect it. + // Tombstone unconditionally, even when changes is 0: deleteRuleOnNode issues this + // same DELETE for capability/invalid-schedule cleanup against remotes that may never + // have received the rule yet, and a reordered POST behind that DELETE must not create it. + this.db.prepare( + `INSERT INTO notification_suppression_rule_tombstones (id, deleted_at) VALUES (?, ?) + ON CONFLICT(id) DO UPDATE SET deleted_at = excluded.deleted_at`, + ).run(id, Date.now()); + return changes; } // --- Global Settings --- 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 6bde31c4..f9427856 100644 --- a/docs/reference/settings.mdx +++ b/docs/reference/settings.mdx @@ -468,9 +468,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..67ee170c 100644 --- a/frontend/src/components/settings/NotificationSuppressionSection.tsx +++ b/frontend/src/components/settings/NotificationSuppressionSection.tsx @@ -40,10 +40,42 @@ interface NotificationSuppressionRule { applies_to: AppliesTo; enabled: boolean; expires_at: number | null; + schedule: MuteRuleSchedule | null; + scheduleInvalid: boolean; 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 +159,15 @@ 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'); + // True when editing a rule whose stored schedule could not be read. Its window + // must not be silently cleared: the operator has to touch the controls first. + const [formScheduleInvalid, setFormScheduleInvalid] = useState(false); + const [formScheduleTouched, setFormScheduleTouched] = useState(false); + const markScheduleTouched = () => setFormScheduleTouched(true); const fetchRules = useCallback(async () => { try { @@ -176,6 +217,12 @@ export function NotificationSuppressionSection({ }); setFormExpirationPreset('forever'); setFormCustomExpiry(''); + setFormScheduleEnabled(false); + setFormScheduleDays([]); + setFormScheduleStart('02:00'); + setFormScheduleEnd('06:00'); + setFormScheduleInvalid(false); + setFormScheduleTouched(false); setEditingId(null); setShowForm(true); onPrefillConsumed?.(); @@ -192,6 +239,12 @@ export function NotificationSuppressionSection({ setFormEnabled(true); setFormExpirationPreset('forever'); setFormCustomExpiry(''); + setFormScheduleEnabled(false); + setFormScheduleDays([]); + setFormScheduleStart('02:00'); + setFormScheduleEnd('06:00'); + setFormScheduleInvalid(false); + setFormScheduleTouched(false); setEditingId(null); setShowForm(false); }; @@ -209,11 +262,30 @@ 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'); + } + setFormScheduleInvalid(rule.scheduleInvalid); + setFormScheduleTouched(false); setShowForm(true); }; const handleSave = async () => { if (!formName.trim()) { toast.error('Name is required.'); return; } + if (formScheduleInvalid && !formScheduleTouched) { + toast.error( + "This rule's stored weekly window could not be read. Turn the weekly window on to set a new schedule, or toggle it on then off to confirm clearing it.", + ); + return; + } const preparedPatterns = patternChipsRef.current?.prepareSave(); if (!preparedPatterns?.ok) { toast.error('Fix invalid stack patterns before saving.'); @@ -225,6 +297,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 +333,7 @@ export function NotificationSuppressionSection({ applies_to: formAppliesTo, enabled: formEnabled, expires_at: expirationFromPreset(formExpirationPreset, customMs), + schedule, }; const url = editingId @@ -483,6 +580,77 @@ export function NotificationSuppressionSection({ )} +
+
+ { setFormScheduleEnabled(v); markScheduleTouched(); }} + id="mute-rule-schedule" + /> + +
+ {formScheduleInvalid && !formScheduleTouched && ( +

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

+ )} + {formScheduleEnabled && ( +
+
+ {DAY_LABELS.map((label, day) => { + const selected = formScheduleDays.includes(day); + return ( + + ); + })} +
+
+
+ + { setFormScheduleStart(e.target.value); markScheduleTouched(); }} + /> +
+
+ + { setFormScheduleEnd(e.target.value); markScheduleTouched(); }} + /> +
+
+

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

+
+ )} +
+
@@ -531,6 +699,11 @@ export function NotificationSuppressionSection({ {rule.expires_at != null && rule.expires_at <= Date.now() && ( Expired )} + {rule.scheduleInvalid && ( + + Invalid schedule + + )}
handleToggleEnabled(rule)} className="scale-75" /> @@ -553,6 +726,12 @@ export function NotificationSuppressionSection({ )} | Expires: {formatExpiry(rule.expires_at)} + {formatScheduleSummary(rule.schedule) && ( + <> + | + {formatScheduleSummary(rule.schedule)} + + )}
))} diff --git a/frontend/src/components/settings/__tests__/NotificationSuppressionSection.test.tsx b/frontend/src/components/settings/__tests__/NotificationSuppressionSection.test.tsx index c1fea07c..d52875cc 100644 --- a/frontend/src/components/settings/__tests__/NotificationSuppressionSection.test.tsx +++ b/frontend/src/components/settings/__tests__/NotificationSuppressionSection.test.tsx @@ -1,8 +1,8 @@ /** - * NotificationSuppressionSection stack pattern chips. + * NotificationSuppressionSection stack pattern chips and weekly schedule UI. */ import { describe, it, expect, vi, beforeEach } from 'vitest'; -import { render, screen, waitFor } from '@testing-library/react'; +import { fireEvent, render, screen, waitFor } from '@testing-library/react'; import userEvent from '@testing-library/user-event'; vi.mock('@/lib/api', () => ({ apiFetch: vi.fn() })); @@ -38,10 +38,67 @@ vi.mock('@/lib/muteRules', () => ({ })); import { apiFetch } from '@/lib/api'; +import { toast } from '@/components/ui/toast-store'; import { NotificationSuppressionSection } from '../NotificationSuppressionSection'; const mockedFetch = apiFetch as unknown as ReturnType; +const scheduledRule = { + id: 42, + name: 'Weekend mute', + node_id: null, + stack_patterns: ['prod-*'], + label_ids: null, + categories: null, + levels: null, + applies_to: 'both', + enabled: true, + expires_at: null, + schedule: { + days: [6], + start_minute: 120, + end_minute: 360, + tz: 'UTC', + }, + scheduleInvalid: false, + created_at: 1, + updated_at: 1, +}; + +const corruptScheduleRule = { + id: 43, + name: 'Corrupt window', + node_id: null, + stack_patterns: [], + label_ids: null, + categories: null, + levels: null, + applies_to: 'both', + enabled: true, + expires_at: null, + schedule: null, + scheduleInvalid: true, + created_at: 1, + updated_at: 1, +}; + +function mockListRules(rules: unknown[] = []) { + mockedFetch.mockImplementation(async (url: string, opts?: { method?: string }) => { + if (url === '/notification-suppression-rules' && !opts?.method) { + return { ok: true, json: async () => rules }; + } + if (url === '/stacks') return { ok: true, json: async () => ['staging'] }; + if (url === '/labels') return { ok: true, json: async () => [] }; + if (url === '/notification-suppression-rules' && opts?.method === 'POST') { + return { ok: true, json: async () => ({ id: 1 }) }; + } + if (typeof url === 'string' && url.startsWith('/notification-suppression-rules/') && opts?.method === 'PUT') { + return { ok: true, json: async () => ({ id: 42 }) }; + } + return { ok: true, json: async () => ([]) }; + }); +} + async function openMuteForm() { render(); await waitFor(() => expect(screen.getByRole('button', { name: /Add mute rule|Add rule/i })).toBeInTheDocument()); @@ -49,20 +106,16 @@ async function openMuteForm() { await waitFor(() => expect(screen.getByRole('dialog', { name: /New mute rule/i })).toBeInTheDocument()); } +async function enableWeeklyWindow() { + await userEvent.click(screen.getByLabelText(/Weekly window \(UTC\)/i)); + await waitFor(() => expect(screen.getByRole('button', { name: 'Sat' })).toBeInTheDocument()); +} + describe('NotificationSuppressionSection', () => { beforeEach(() => { mockedFetch.mockReset(); - mockedFetch.mockImplementation(async (url: string, opts?: { method?: string }) => { - if (url === '/notification-suppression-rules' && !opts?.method) { - return { ok: true, json: async () => [] }; - } - if (url === '/stacks') return { ok: true, json: async () => ['staging'] }; - if (url === '/labels') return { ok: true, json: async () => [] }; - if (url === '/notification-suppression-rules' && opts?.method === 'POST') { - return { ok: true, json: async () => ({ id: 1 }) }; - } - return { ok: true, json: async () => ([]) }; - }); + vi.mocked(toast.error).mockClear(); + mockListRules([]); }); it('posts normalized stack patterns and null levels', async () => { @@ -129,4 +182,215 @@ describe('NotificationSuppressionSection', () => { expect(posts).toHaveLength(0); }); }); + + it('posts a weekly UTC schedule with accessible day and time controls', async () => { + await openMuteForm(); + + await userEvent.type(screen.getByPlaceholderText(/Mute staging/i), 'Sat window'); + await userEvent.type(screen.getByPlaceholderText(/Type a pattern/i), 'prod-*{Enter}'); + await enableWeeklyWindow(); + + const sat = screen.getByRole('button', { name: 'Sat' }); + expect(sat).toHaveAttribute('aria-pressed', 'false'); + await userEvent.click(sat); + expect(sat).toHaveAttribute('aria-pressed', 'true'); + + expect(screen.getByLabelText('Start (UTC)')).toHaveAttribute('id', 'mute-schedule-start'); + expect(screen.getByLabelText('End (UTC)')).toHaveAttribute('id', 'mute-schedule-end'); + fireEvent.change(screen.getByLabelText('Start (UTC)'), { target: { value: '02:00' } }); + fireEvent.change(screen.getByLabelText('End (UTC)'), { target: { value: '06:00' } }); + + await userEvent.click(screen.getByRole('button', { name: /Create|Update/i })); + await waitFor(() => { + const post = mockedFetch.mock.calls.find( + ([url, opts]) => url === '/notification-suppression-rules' && (opts as { method?: string })?.method === 'POST', + ); + expect(post).toBeTruthy(); + const body = JSON.parse((post![1] as { body: string }).body); + expect(body.schedule).toEqual({ + days: [6], + start_minute: 120, + end_minute: 360, + tz: 'UTC', + }); + }); + }); + + it('blocks scheduled create when no weekday is selected', async () => { + await openMuteForm(); + + await userEvent.type(screen.getByPlaceholderText(/Mute staging/i), 'No days'); + await userEvent.type(screen.getByPlaceholderText(/Type a pattern/i), 'prod-*{Enter}'); + await enableWeeklyWindow(); + await userEvent.click(screen.getByRole('button', { name: /Create|Update/i })); + + await waitFor(() => { + expect(toast.error).toHaveBeenCalledWith('Select at least one day for the weekly window.'); + }); + const posts = mockedFetch.mock.calls.filter( + ([url, opts]) => url === '/notification-suppression-rules' && (opts as { method?: string })?.method === 'POST', + ); + expect(posts).toHaveLength(0); + }); + + it('hydrates edit form from an existing schedule and keeps it on PUT', async () => { + mockListRules([scheduledRule]); + render(); + await waitFor(() => expect(screen.getByText('Weekend mute')).toBeInTheDocument()); + expect(screen.getByText(/UTC Sat 02:00-06:00/)).toBeInTheDocument(); + + await userEvent.click(screen.getByTitle('Edit')); + await waitFor(() => expect(screen.getByRole('dialog', { name: /Edit mute rule/i })).toBeInTheDocument()); + + expect(screen.getByLabelText(/Weekly window \(UTC\)/i)).toHaveAttribute('aria-checked', 'true'); + expect(screen.getByRole('button', { name: 'Sat' })).toHaveAttribute('aria-pressed', 'true'); + expect(screen.getByLabelText('Start (UTC)')).toHaveValue('02:00'); + expect(screen.getByLabelText('End (UTC)')).toHaveValue('06:00'); + + await userEvent.click(screen.getByRole('button', { name: /Create|Update/i })); + await waitFor(() => { + const put = mockedFetch.mock.calls.find( + ([url, opts]) => + url === '/notification-suppression-rules/42' && (opts as { method?: string })?.method === 'PUT', + ); + expect(put).toBeTruthy(); + const body = JSON.parse((put![1] as { body: string }).body); + expect(body.schedule).toEqual({ + days: [6], + start_minute: 120, + end_minute: 360, + tz: 'UTC', + }); + }); + }); + + it('clears schedule to null when weekly window is turned off on edit', async () => { + mockListRules([scheduledRule]); + render(); + await waitFor(() => expect(screen.getByText('Weekend mute')).toBeInTheDocument()); + + await userEvent.click(screen.getByTitle('Edit')); + await waitFor(() => expect(screen.getByRole('dialog', { name: /Edit mute rule/i })).toBeInTheDocument()); + await userEvent.click(screen.getByLabelText(/Weekly window \(UTC\)/i)); + await waitFor(() => { + expect(screen.queryByRole('button', { name: 'Sat' })).not.toBeInTheDocument(); + }); + + await userEvent.click(screen.getByRole('button', { name: /Create|Update/i })); + await waitFor(() => { + const put = mockedFetch.mock.calls.find( + ([url, opts]) => + url === '/notification-suppression-rules/42' && (opts as { method?: string })?.method === 'PUT', + ); + expect(put).toBeTruthy(); + const body = JSON.parse((put![1] as { body: string }).body); + expect(body.schedule).toBeNull(); + }); + }); + + it('marks a corrupt stored schedule as invalid instead of an ordinary unscheduled rule', async () => { + mockListRules([corruptScheduleRule]); + render(); + await waitFor(() => expect(screen.getByText('Corrupt window')).toBeInTheDocument()); + expect(screen.getByText('Invalid schedule')).toBeInTheDocument(); + }); + + it('blocks saving a corrupt-schedule rule until the operator touches the weekly window', async () => { + mockListRules([corruptScheduleRule]); + render(); + await waitFor(() => expect(screen.getByText('Corrupt window')).toBeInTheDocument()); + + await userEvent.click(screen.getByTitle('Edit')); + await waitFor(() => expect(screen.getByRole('dialog', { name: /Edit mute rule/i })).toBeInTheDocument()); + expect(screen.getByLabelText(/Weekly window \(UTC\)/i)).toHaveAttribute('aria-checked', 'false'); + + await userEvent.click(screen.getByRole('button', { name: /Create|Update/i })); + await waitFor(() => { + expect(toast.error).toHaveBeenCalledWith( + expect.stringContaining('could not be read'), + ); + }); + const puts = mockedFetch.mock.calls.filter( + ([url, opts]) => url === '/notification-suppression-rules/43' && (opts as { method?: string })?.method === 'PUT', + ); + expect(puts).toHaveLength(0); + }); + + it('allows saving a corrupt-schedule rule once the operator explicitly clears it', async () => { + mockListRules([corruptScheduleRule]); + render(); + await waitFor(() => expect(screen.getByText('Corrupt window')).toBeInTheDocument()); + + await userEvent.click(screen.getByTitle('Edit')); + await waitFor(() => expect(screen.getByRole('dialog', { name: /Edit mute rule/i })).toBeInTheDocument()); + + // Explicit acknowledgement: turn the window on, then off again, to intentionally clear it. + await userEvent.click(screen.getByLabelText(/Weekly window \(UTC\)/i)); + await userEvent.click(screen.getByLabelText(/Weekly window \(UTC\)/i)); + + await userEvent.click(screen.getByRole('button', { name: /Create|Update/i })); + await waitFor(() => { + const put = mockedFetch.mock.calls.find( + ([url, opts]) => + url === '/notification-suppression-rules/43' && (opts as { method?: string })?.method === 'PUT', + ); + expect(put).toBeTruthy(); + const body = JSON.parse((put![1] as { body: string }).body); + expect(body.schedule).toBeNull(); + }); + }); + + it('allows saving a corrupt-schedule rule once the operator configures a new valid schedule', async () => { + mockListRules([corruptScheduleRule]); + render(); + await waitFor(() => expect(screen.getByText('Corrupt window')).toBeInTheDocument()); + + await userEvent.click(screen.getByTitle('Edit')); + await waitFor(() => expect(screen.getByRole('dialog', { name: /Edit mute rule/i })).toBeInTheDocument()); + await enableWeeklyWindow(); + await userEvent.click(screen.getByRole('button', { name: 'Sat' })); + fireEvent.change(screen.getByLabelText('Start (UTC)'), { target: { value: '02:00' } }); + fireEvent.change(screen.getByLabelText('End (UTC)'), { target: { value: '06:00' } }); + + await userEvent.click(screen.getByRole('button', { name: /Create|Update/i })); + await waitFor(() => { + const put = mockedFetch.mock.calls.find( + ([url, opts]) => + url === '/notification-suppression-rules/43' && (opts as { method?: string })?.method === 'PUT', + ); + expect(put).toBeTruthy(); + const body = JSON.parse((put![1] as { body: string }).body); + expect(body.schedule).toEqual({ + days: [6], + start_minute: 120, + end_minute: 360, + tz: 'UTC', + }); + }); + }); + + it('does not carry the invalid-schedule save gate over to a later edit of a different, valid rule', async () => { + mockListRules([corruptScheduleRule, scheduledRule]); + render(); + await waitFor(() => expect(screen.getByText('Corrupt window')).toBeInTheDocument()); + + const editButtons = screen.getAllByTitle('Edit'); + await userEvent.click(editButtons[0]); + await waitFor(() => expect(screen.getByRole('dialog', { name: /Edit mute rule/i })).toBeInTheDocument()); + await userEvent.click(screen.getByRole('button', { name: 'Cancel' })); + await waitFor(() => expect(screen.queryByRole('dialog')).not.toBeInTheDocument()); + + await userEvent.click(screen.getAllByTitle('Edit')[1]); + await waitFor(() => expect(screen.getByRole('dialog', { name: /Edit mute rule/i })).toBeInTheDocument()); + await userEvent.click(screen.getByRole('button', { name: /Create|Update/i })); + + await waitFor(() => { + const put = mockedFetch.mock.calls.find( + ([url, opts]) => + url === '/notification-suppression-rules/42' && (opts as { method?: string })?.method === 'PUT', + ); + expect(put).toBeTruthy(); + }); + expect(toast.error).not.toHaveBeenCalledWith(expect.stringContaining('could not be read')); + }); }); 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,