feat: add weekly UTC maintenance windows to mute rules

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