From a3edee5e6a6c6319bb292f2e37d96aa3260acf3f Mon Sep 17 00:00:00 2001 From: Anso Date: Tue, 21 Jul 2026 23:17:52 -0400 Subject: [PATCH] feat: weekly UTC maintenance windows for mute rules (#1661) * 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. * fix: fail closed on corrupt mute schedules and clean invalid replicas Empty or whitespace stored schedules no longer act as all-day mutes. Invalid schedules trigger remote DELETE cleanup, and the weekly-window form gains accessibility attributes plus component coverage. * fix: require explicit repair before clearing a corrupt mute schedule The suppression engine already fails closed on an unreadable stored schedule (scheduleInvalid), but the frontend never surfaced that flag: a corrupt rule looked identical to an ordinary unscheduled one, and opening Edit then clicking Update sent an explicit schedule: null, silently turning the corruption into a valid all-day mute. Add the flag to the rule type, show an Invalid schedule badge on the card, and block saving in the edit form until the operator explicitly touches the weekly window (configures a new one, or toggles it to confirm the clear). * fix: correct contradictory toggle-sequence copy in schedule-repair toast The blocking toast told operators to toggle the weekly window "off then on" to confirm clearing a corrupt schedule, but the toggle starts off for a corrupt rule, so that sequence leaves it on and trips the no-selected-day validation instead. The correct, tested sequence is on then off, matching the inline hint below the toggle. Also add a regression test confirming the invalid-schedule save gate resets cleanly across edit sessions on different rules. * fix: enforce replica node_id and guard fleet sync against stale writes Two hardenings to the suppression-rule fleet sync path found during review: the /replica endpoint trusted the payload's node_id instead of forcing it to null server-side, so a direct proxy-authenticated caller could persist a scoped replica; and upsertNotificationSuppressionRuleReplica overwrote unconditionally with no ordering check, so a delayed older POST arriving after a newer one could downgrade the stored rule. Force node_id to null on every replica write, and skip (with a warning log) any incoming write whose updated_at is not newer than what's stored. * test: assert the exact-tie updated_at case in the fleet sync stale-write guard The staleness guard added in c31458a1 uses >= (ties are ignored, not just strictly older writes); add the missing assertion for that boundary and make the comment explicit about it. * fix: bump vulnerable transitive backend dependencies npm audit flagged body-parser, fast-uri, and protobufjs (one high severity: fast-uri host confusion via failed IDN canonicalization). All three have patch/minor fixes within existing semver ranges; npm audit fix resolves all three with no package.json changes. * fix: sanitize suppression replica fields before logging Log entries built from fleet-sync replica payloads embedded rule id and timestamp values directly, allowing a compromised peer to forge log lines via control characters. * fix: prevent delayed replica writes from resurrecting deleted mute rules A network-reordered replica POST arriving after a DELETE fell into the insert-when-absent branch with no protection, since the staleness guard only compares against a row that still exists. Add a permanent per-id tombstone (safe because rule ids are AUTOINCREMENT and never reused): every delete records one, and the replica upsert refuses to recreate a tombstoned id regardless of the incoming updated_at. --- .../__tests__/notification-schedule.test.ts | 114 ++++++ ...otification-suppression-routes-api.test.ts | 346 +++++++++++++++++- ...ion-suppression-schedule-migration.test.ts | 135 +++++++ .../notification-suppression-sync.test.ts | 275 ++++++++++++++ .../notification-suppression.test.ts | 72 ++++ backend/src/helpers/notificationSchedule.ts | 126 +++++++ .../helpers/notificationSuppressionSync.ts | 116 +++++- backend/src/routes/notifications.ts | 70 +++- backend/src/services/CapabilityRegistry.ts | 5 + backend/src/services/DatabaseService.ts | 74 +++- backend/src/services/NotificationService.ts | 9 +- docs/features/alerts-notifications.mdx | 5 +- docs/reference/settings.mdx | 3 +- .../NotificationSuppressionSection.tsx | 179 +++++++++ .../NotificationSuppressionSection.test.tsx | 290 ++++++++++++++- frontend/src/lib/capabilities.ts | 1 + frontend/src/lib/muteRules.test.ts | 30 ++ frontend/src/lib/muteRules.ts | 9 + 18 files changed, 1821 insertions(+), 38 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..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,