mirror of
https://github.com/Studio-Saelix/sencho.git
synced 2026-07-26 11:49:16 +00:00
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:
@@ -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();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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;
|
||||
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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));
|
||||
|
||||
|
||||
@@ -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)
|
||||
|
||||
|
||||
@@ -447,9 +447,10 @@ Create notification suppression rules that mute or drop matching alerts from the
|
||||
| **Severity** | Info, warning, and/or error levels to suppress. |
|
||||
| **Apply to** | Bell only, external channels only, or both. |
|
||||
| **Expiration** | Forever, 1 hour, 24 hours, or a custom timestamp. |
|
||||
| **Weekly window (UTC)** | Optional recurring weekly window. Off means no window (always eligible while enabled and unexpired). ACTIVE counts still reflect enabled and unexpired rules, not whether the window is currently open. |
|
||||
| **Enabled** toggle | Disable a rule without deleting it. |
|
||||
|
||||
See [Mute Rules](/features/alerts-notifications#mute-rules) for the full walkthrough, compose-first shortcuts, and bell quick-mute.
|
||||
See [Mute Rules](/features/alerts-notifications#mute-rules) for the full walkthrough, weekly-window semantics, best-effort fleet sync, and bell quick-mute.
|
||||
|
||||
---
|
||||
|
||||
|
||||
@@ -40,10 +40,41 @@ interface NotificationSuppressionRule {
|
||||
applies_to: AppliesTo;
|
||||
enabled: boolean;
|
||||
expires_at: number | null;
|
||||
schedule: MuteRuleSchedule | null;
|
||||
created_at: number;
|
||||
updated_at: number;
|
||||
}
|
||||
|
||||
type MuteRuleSchedule = {
|
||||
days: number[];
|
||||
start_minute: number;
|
||||
end_minute: number;
|
||||
tz: 'UTC';
|
||||
};
|
||||
|
||||
const DAY_LABELS = ['Sun', 'Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat'] as const;
|
||||
|
||||
function minuteToTimeInput(minute: number): string {
|
||||
const h = Math.floor(minute / 60);
|
||||
const m = minute % 60;
|
||||
return `${String(h).padStart(2, '0')}:${String(m).padStart(2, '0')}`;
|
||||
}
|
||||
|
||||
function timeInputToMinute(value: string): number | null {
|
||||
const match = /^(\d{2}):(\d{2})(?::\d{2})?$/.exec(value);
|
||||
if (!match) return null;
|
||||
const h = Number(match[1]);
|
||||
const m = Number(match[2]);
|
||||
if (h > 23 || m > 59) return null;
|
||||
return h * 60 + m;
|
||||
}
|
||||
|
||||
function formatScheduleSummary(schedule: MuteRuleSchedule | null): string | null {
|
||||
if (!schedule) return null;
|
||||
const days = schedule.days.map((d) => DAY_LABELS[d] ?? String(d)).join(', ');
|
||||
return `UTC ${days} ${minuteToTimeInput(schedule.start_minute)}-${minuteToTimeInput(schedule.end_minute)}`;
|
||||
}
|
||||
|
||||
const LEVEL_LABELS: Record<NotificationLevel, string> = {
|
||||
info: 'Info',
|
||||
warning: 'Warning',
|
||||
@@ -127,6 +158,10 @@ export function NotificationSuppressionSection({
|
||||
const [formEnabled, setFormEnabled] = useState(true);
|
||||
const [formExpirationPreset, setFormExpirationPreset] = useState<ExpirationPreset>('forever');
|
||||
const [formCustomExpiry, setFormCustomExpiry] = useState('');
|
||||
const [formScheduleEnabled, setFormScheduleEnabled] = useState(false);
|
||||
const [formScheduleDays, setFormScheduleDays] = useState<number[]>([]);
|
||||
const [formScheduleStart, setFormScheduleStart] = useState('02:00');
|
||||
const [formScheduleEnd, setFormScheduleEnd] = useState('06:00');
|
||||
|
||||
const fetchRules = useCallback(async () => {
|
||||
try {
|
||||
@@ -176,6 +211,10 @@ export function NotificationSuppressionSection({
|
||||
});
|
||||
setFormExpirationPreset('forever');
|
||||
setFormCustomExpiry('');
|
||||
setFormScheduleEnabled(false);
|
||||
setFormScheduleDays([]);
|
||||
setFormScheduleStart('02:00');
|
||||
setFormScheduleEnd('06:00');
|
||||
setEditingId(null);
|
||||
setShowForm(true);
|
||||
onPrefillConsumed?.();
|
||||
@@ -192,6 +231,10 @@ export function NotificationSuppressionSection({
|
||||
setFormEnabled(true);
|
||||
setFormExpirationPreset('forever');
|
||||
setFormCustomExpiry('');
|
||||
setFormScheduleEnabled(false);
|
||||
setFormScheduleDays([]);
|
||||
setFormScheduleStart('02:00');
|
||||
setFormScheduleEnd('06:00');
|
||||
setEditingId(null);
|
||||
setShowForm(false);
|
||||
};
|
||||
@@ -209,6 +252,17 @@ export function NotificationSuppressionSection({
|
||||
setFormEnabled(rule.enabled);
|
||||
setFormExpirationPreset(preset);
|
||||
setFormCustomExpiry(customMs != null ? new Date(customMs).toISOString().slice(0, 16) : '');
|
||||
if (rule.schedule) {
|
||||
setFormScheduleEnabled(true);
|
||||
setFormScheduleDays([...rule.schedule.days]);
|
||||
setFormScheduleStart(minuteToTimeInput(rule.schedule.start_minute));
|
||||
setFormScheduleEnd(minuteToTimeInput(rule.schedule.end_minute));
|
||||
} else {
|
||||
setFormScheduleEnabled(false);
|
||||
setFormScheduleDays([]);
|
||||
setFormScheduleStart('02:00');
|
||||
setFormScheduleEnd('06:00');
|
||||
}
|
||||
setShowForm(true);
|
||||
};
|
||||
|
||||
@@ -225,6 +279,30 @@ export function NotificationSuppressionSection({
|
||||
return;
|
||||
}
|
||||
|
||||
let schedule: MuteRuleSchedule | null = null;
|
||||
if (formScheduleEnabled) {
|
||||
if (formScheduleDays.length === 0) {
|
||||
toast.error('Select at least one day for the weekly window.');
|
||||
return;
|
||||
}
|
||||
const startMinute = timeInputToMinute(formScheduleStart);
|
||||
const endMinute = timeInputToMinute(formScheduleEnd);
|
||||
if (startMinute == null || endMinute == null) {
|
||||
toast.error('Enter valid UTC start and end times.');
|
||||
return;
|
||||
}
|
||||
if (startMinute === endMinute) {
|
||||
toast.error('Weekly window start and end must differ.');
|
||||
return;
|
||||
}
|
||||
schedule = {
|
||||
days: [...new Set(formScheduleDays)].sort((a, b) => a - b),
|
||||
start_minute: startMinute,
|
||||
end_minute: endMinute,
|
||||
tz: 'UTC',
|
||||
};
|
||||
}
|
||||
|
||||
setSaving(true);
|
||||
try {
|
||||
const body = {
|
||||
@@ -237,6 +315,7 @@ export function NotificationSuppressionSection({
|
||||
applies_to: formAppliesTo,
|
||||
enabled: formEnabled,
|
||||
expires_at: expirationFromPreset(formExpirationPreset, customMs),
|
||||
schedule,
|
||||
};
|
||||
|
||||
const url = editingId
|
||||
@@ -483,6 +562,57 @@ export function NotificationSuppressionSection({
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<div className="flex items-center gap-2">
|
||||
<TogglePill
|
||||
checked={formScheduleEnabled}
|
||||
onChange={setFormScheduleEnabled}
|
||||
id="mute-rule-schedule"
|
||||
/>
|
||||
<Label htmlFor="mute-rule-schedule" className="mb-0">Weekly window (UTC)</Label>
|
||||
</div>
|
||||
{formScheduleEnabled && (
|
||||
<div className="space-y-2 rounded-md border border-border/60 p-3">
|
||||
<div className="flex flex-wrap gap-1.5">
|
||||
{DAY_LABELS.map((label, day) => {
|
||||
const selected = formScheduleDays.includes(day);
|
||||
return (
|
||||
<Button
|
||||
key={label}
|
||||
type="button"
|
||||
size="sm"
|
||||
variant={selected ? 'default' : 'outline'}
|
||||
className="h-7 px-2 text-xs"
|
||||
onClick={() => {
|
||||
setFormScheduleDays((prev) =>
|
||||
selected
|
||||
? prev.filter((d) => d !== day)
|
||||
: [...prev, day].sort((a, b) => a - b),
|
||||
);
|
||||
}}
|
||||
>
|
||||
{label}
|
||||
</Button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
<div className="grid grid-cols-2 gap-2">
|
||||
<div className="space-y-1">
|
||||
<Label className="text-xs text-muted-foreground">Start (UTC)</Label>
|
||||
<Input type="time" value={formScheduleStart} onChange={(e) => setFormScheduleStart(e.target.value)} />
|
||||
</div>
|
||||
<div className="space-y-1">
|
||||
<Label className="text-xs text-muted-foreground">End (UTC)</Label>
|
||||
<Input type="time" value={formScheduleEnd} onChange={(e) => setFormScheduleEnd(e.target.value)} />
|
||||
</div>
|
||||
</div>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Outside this window the rule does not mute. Cross-midnight windows use the start day only.
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-2">
|
||||
<TogglePill checked={formEnabled} onChange={setFormEnabled} id="mute-rule-enabled" />
|
||||
<span className="text-sm text-stat-value select-none">
|
||||
@@ -553,6 +683,12 @@ export function NotificationSuppressionSection({
|
||||
)}
|
||||
<span className="text-muted-foreground/50">|</span>
|
||||
<span className="tabular-nums">Expires: {formatExpiry(rule.expires_at)}</span>
|
||||
{formatScheduleSummary(rule.schedule) && (
|
||||
<>
|
||||
<span className="text-muted-foreground/50">|</span>
|
||||
<span className="tabular-nums">{formatScheduleSummary(rule.schedule)}</span>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
|
||||
@@ -14,6 +14,7 @@ export const CAPABILITIES = [
|
||||
'notifications',
|
||||
'notification-routing',
|
||||
'notification-suppression',
|
||||
'notification-suppression-schedule',
|
||||
'host-console',
|
||||
'container-exec',
|
||||
'audit-log',
|
||||
|
||||
@@ -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'),
|
||||
}),
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user