mirror of
https://github.com/Studio-Saelix/sencho.git
synced 2026-08-27 18:57:09 +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(ok.status).toBe(200);
|
||||||
expect(DatabaseService.getInstance().getNotificationSuppressionRule(9001)?.stack_patterns).toEqual(['prod-*']);
|
expect(DatabaseService.getInstance().getNotificationSuppressionRule(9001)?.stack_patterns).toEqual(['prod-*']);
|
||||||
|
expect(DatabaseService.getInstance().getNotificationSuppressionRule(9001)?.schedule).toBeNull();
|
||||||
DatabaseService.getInstance().deleteNotificationSuppressionRule(9001);
|
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,
|
applies_to: 'both' as const,
|
||||||
enabled: true,
|
enabled: true,
|
||||||
expires_at: null as number | null,
|
expires_at: null as number | null,
|
||||||
|
schedule: null as null,
|
||||||
|
scheduleInvalid: false,
|
||||||
created_at: Date.now(),
|
created_at: Date.now(),
|
||||||
updated_at: Date.now(),
|
updated_at: Date.now(),
|
||||||
...overrides,
|
...overrides,
|
||||||
@@ -176,4 +178,74 @@ describe('NotificationService - suppression logic', () => {
|
|||||||
expect.objectContaining({ method: 'POST' }),
|
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 { NodeRegistry } from '../services/NodeRegistry';
|
||||||
import { LicenseService } from '../services/LicenseService';
|
import { LicenseService } from '../services/LicenseService';
|
||||||
import { PROXY_TIER_HEADER } from '../services/license-headers';
|
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';
|
import { getErrorMessage } from '../utils/errors';
|
||||||
|
|
||||||
const SYNC_TIMEOUT_MS = 15_000;
|
const SYNC_TIMEOUT_MS = 15_000;
|
||||||
@@ -16,7 +20,12 @@ function buildRemoteHeaders(apiToken: string): Record<string, string> {
|
|||||||
return headers;
|
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 db = DatabaseService.getInstance();
|
||||||
const remotes = db.getNodes().filter((n) => n.type === 'remote');
|
const remotes = db.getNodes().filter((n) => n.type === 'remote');
|
||||||
if (rule.node_id != null) {
|
if (rule.node_id != null) {
|
||||||
@@ -26,6 +35,10 @@ function replicationTargets(rule: NotificationSuppressionRule): Node[] {
|
|||||||
return remotes;
|
return remotes;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function replicaPayload(rule: NotificationSuppressionRule): NotificationSuppressionRule {
|
||||||
|
return { ...rule, node_id: null };
|
||||||
|
}
|
||||||
|
|
||||||
async function pushRuleToNode(node: Node, rule: NotificationSuppressionRule): Promise<void> {
|
async function pushRuleToNode(node: Node, rule: NotificationSuppressionRule): Promise<void> {
|
||||||
const target = NodeRegistry.getInstance().getProxyTarget(node.id);
|
const target = NodeRegistry.getInstance().getProxyTarget(node.id);
|
||||||
if (!target?.apiUrl) {
|
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`, {
|
const res = await fetch(`${baseUrl}/api/notification-suppression-rules/replica`, {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
headers: buildRemoteHeaders(target.apiToken),
|
headers: buildRemoteHeaders(target.apiToken),
|
||||||
body: JSON.stringify({ rule }),
|
body: JSON.stringify({ rule: replicaPayload(rule) }),
|
||||||
signal: AbortSignal.timeout(SYNC_TIMEOUT_MS),
|
signal: AbortSignal.timeout(SYNC_TIMEOUT_MS),
|
||||||
});
|
});
|
||||||
if (!res.ok) {
|
if (!res.ok) {
|
||||||
@@ -48,8 +61,7 @@ async function pushRuleToNode(node: Node, rule: NotificationSuppressionRule): Pr
|
|||||||
async function deleteRuleOnNode(node: Node, ruleId: number): Promise<void> {
|
async function deleteRuleOnNode(node: Node, ruleId: number): Promise<void> {
|
||||||
const target = NodeRegistry.getInstance().getProxyTarget(node.id);
|
const target = NodeRegistry.getInstance().getProxyTarget(node.id);
|
||||||
if (!target?.apiUrl) {
|
if (!target?.apiUrl) {
|
||||||
console.warn(`[SuppressionSync] Skipping node "${node.name}": no proxy target`);
|
throw new Error(`no proxy target for node "${node.name}" (id=${node.id})`);
|
||||||
return;
|
|
||||||
}
|
}
|
||||||
const baseUrl = target.apiUrl.replace(/\/$/, '');
|
const baseUrl = target.apiUrl.replace(/\/$/, '');
|
||||||
const res = await fetch(`${baseUrl}/api/notification-suppression-rules/replica/${ruleId}`, {
|
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. */
|
/** Best-effort push of a suppression rule to fleet nodes that should evaluate it. */
|
||||||
export function syncSuppressionRuleToFleet(rule: NotificationSuppressionRule): void {
|
export function syncSuppressionRuleToFleet(rule: NotificationSuppressionRule): void {
|
||||||
const targets = replicationTargets(rule);
|
const targets = replicationTargets(rule);
|
||||||
@@ -70,7 +123,7 @@ export function syncSuppressionRuleToFleet(rule: NotificationSuppressionRule): v
|
|||||||
void Promise.allSettled(
|
void Promise.allSettled(
|
||||||
targets.map(async (node) => {
|
targets.map(async (node) => {
|
||||||
try {
|
try {
|
||||||
await pushRuleToNode(node, rule);
|
await syncRuleToNode(node, rule);
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error(
|
console.error(
|
||||||
`[SuppressionSync] Failed to push rule ${rule.id} to node "${node.name}":`,
|
`[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. */
|
/** Best-effort delete of a replicated rule on fleet nodes. */
|
||||||
export function deleteSuppressionRuleFromFleet(rule: NotificationSuppressionRule): void {
|
export function deleteSuppressionRuleFromFleet(rule: NotificationSuppressionRule): void {
|
||||||
const targets = replicationTargets(rule);
|
const targets = replicationTargets(rule);
|
||||||
|
|||||||
@@ -17,9 +17,14 @@ import {
|
|||||||
storedAppriseToWriteConfig,
|
storedAppriseToWriteConfig,
|
||||||
} from '../helpers/notificationChannels';
|
} from '../helpers/notificationChannels';
|
||||||
import { parseStackPatternsInput } from '../helpers/stackPattern';
|
import { parseStackPatternsInput } from '../helpers/stackPattern';
|
||||||
|
import {
|
||||||
|
parseNotificationSchedule,
|
||||||
|
type NotificationSchedule,
|
||||||
|
} from '../helpers/notificationSchedule';
|
||||||
import {
|
import {
|
||||||
deleteSuppressionRuleFromFleet,
|
deleteSuppressionRuleFromFleet,
|
||||||
syncSuppressionRuleToFleet,
|
syncSuppressionRuleToFleet,
|
||||||
|
syncSuppressionRuleUpdateToFleet,
|
||||||
} from '../helpers/notificationSuppressionSync';
|
} from '../helpers/notificationSuppressionSync';
|
||||||
import { isDebugEnabled } from '../utils/debug';
|
import { isDebugEnabled } from '../utils/debug';
|
||||||
import { logDebugTiming } from '../utils/requestTiming';
|
import { logDebugTiming } from '../utils/requestTiming';
|
||||||
@@ -109,6 +114,27 @@ function validateExpiresAt(expires_at: unknown, res: Response): number | null |
|
|||||||
return expires_at;
|
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 {
|
function normalizeStoredLevels(levels: unknown): ('info' | 'warning' | 'error')[] | null {
|
||||||
if (!Array.isArray(levels) || levels.length === 0) return null;
|
if (!Array.isArray(levels) || levels.length === 0) return null;
|
||||||
return levels as ('info' | 'warning' | 'error')[];
|
return levels as ('info' | 'warning' | 'error')[];
|
||||||
@@ -135,7 +161,7 @@ function parseSuppressionRuleBody(
|
|||||||
req: Request,
|
req: Request,
|
||||||
res: Response,
|
res: Response,
|
||||||
isCreate: boolean,
|
isCreate: boolean,
|
||||||
): Omit<NotificationSuppressionRule, 'id' | 'created_at' | 'updated_at'> | null {
|
): Omit<NotificationSuppressionRule, 'id' | 'created_at' | 'updated_at' | 'scheduleInvalid'> | null {
|
||||||
const {
|
const {
|
||||||
name,
|
name,
|
||||||
node_id: rawNodeId,
|
node_id: rawNodeId,
|
||||||
@@ -146,6 +172,7 @@ function parseSuppressionRuleBody(
|
|||||||
applies_to,
|
applies_to,
|
||||||
enabled,
|
enabled,
|
||||||
expires_at,
|
expires_at,
|
||||||
|
schedule,
|
||||||
} = req.body;
|
} = req.body;
|
||||||
|
|
||||||
if (isCreate && (!name || typeof name !== 'string' || !name.trim())) {
|
if (isCreate && (!name || typeof name !== 'string' || !name.trim())) {
|
||||||
@@ -183,6 +210,12 @@ function parseSuppressionRuleBody(
|
|||||||
const expiresAtResult = validateExpiresAt(expires_at, res);
|
const expiresAtResult = validateExpiresAt(expires_at, res);
|
||||||
if (expiresAtResult === false) return null;
|
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') {
|
if (enabled !== undefined && typeof enabled !== 'boolean') {
|
||||||
res.status(400).json({ error: 'enabled must be a boolean' });
|
res.status(400).json({ error: 'enabled must be a boolean' });
|
||||||
return null;
|
return null;
|
||||||
@@ -198,6 +231,7 @@ function parseSuppressionRuleBody(
|
|||||||
applies_to: (appliesToResult ?? 'both') as NotificationSuppressionAppliesTo,
|
applies_to: (appliesToResult ?? 'both') as NotificationSuppressionAppliesTo,
|
||||||
enabled: enabled !== false,
|
enabled: enabled !== false,
|
||||||
expires_at: expiresAtResult ?? null,
|
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 });
|
res.status(400).json({ error: patterns.error });
|
||||||
return;
|
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({
|
DatabaseService.getInstance().upsertNotificationSuppressionRuleReplica({
|
||||||
...rule,
|
...rule,
|
||||||
stack_patterns: patterns.patterns,
|
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 });
|
res.json({ success: true });
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
@@ -583,6 +634,7 @@ notificationSuppressionRouter.put('/:id', authMiddleware, (req: Request, res: Re
|
|||||||
applies_to,
|
applies_to,
|
||||||
enabled,
|
enabled,
|
||||||
expires_at,
|
expires_at,
|
||||||
|
schedule,
|
||||||
} = req.body;
|
} = req.body;
|
||||||
|
|
||||||
if (name !== undefined && (typeof name !== 'string' || !name.trim())) {
|
if (name !== undefined && (typeof name !== 'string' || !name.trim())) {
|
||||||
@@ -626,12 +678,21 @@ notificationSuppressionRouter.put('/:id', authMiddleware, (req: Request, res: Re
|
|||||||
validatedExpiresAt = result;
|
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') {
|
if (enabled !== undefined && typeof enabled !== 'boolean') {
|
||||||
res.status(400).json({ error: 'enabled must be a boolean' });
|
res.status(400).json({ error: 'enabled must be a boolean' });
|
||||||
return;
|
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 (name !== undefined) updates.name = name.trim();
|
||||||
if (validatedNodeId !== undefined) updates.node_id = validatedNodeId;
|
if (validatedNodeId !== undefined) updates.node_id = validatedNodeId;
|
||||||
if (cleanedPatterns !== undefined) updates.stack_patterns = cleanedPatterns;
|
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 (validatedAppliesTo !== undefined) updates.applies_to = validatedAppliesTo;
|
||||||
if (enabled !== undefined) updates.enabled = enabled;
|
if (enabled !== undefined) updates.enabled = enabled;
|
||||||
if (validatedExpiresAt !== undefined) updates.expires_at = validatedExpiresAt;
|
if (validatedExpiresAt !== undefined) updates.expires_at = validatedExpiresAt;
|
||||||
|
if (validatedSchedule !== undefined) updates.schedule = validatedSchedule;
|
||||||
|
|
||||||
const db = DatabaseService.getInstance();
|
const db = DatabaseService.getInstance();
|
||||||
db.updateNotificationSuppressionRule(id, updates);
|
db.updateNotificationSuppressionRule(id, updates);
|
||||||
const updated = db.getNotificationSuppressionRule(id)!;
|
const updated = db.getNotificationSuppressionRule(id)!;
|
||||||
syncSuppressionRuleToFleet(updated);
|
syncSuppressionRuleUpdateToFleet(existing, updated);
|
||||||
console.log(`[Suppression] Rule ${id} updated`);
|
console.log(`[Suppression] Rule ${id} updated`);
|
||||||
res.json(updated);
|
res.json(updated);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
|
|||||||
@@ -36,6 +36,7 @@ export const CAPABILITIES = [
|
|||||||
'notifications',
|
'notifications',
|
||||||
'notification-routing',
|
'notification-routing',
|
||||||
'notification-suppression',
|
'notification-suppression',
|
||||||
|
'notification-suppression-schedule',
|
||||||
'host-console',
|
'host-console',
|
||||||
'container-exec',
|
'container-exec',
|
||||||
'audit-log',
|
'audit-log',
|
||||||
@@ -70,6 +71,10 @@ export const CROSS_NODE_RBAC_CAPABILITY = 'cross-node-rbac';
|
|||||||
|
|
||||||
export type Capability = (typeof CAPABILITIES)[number];
|
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. */
|
/** 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;
|
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 { HIGH_EPSS_THRESHOLD } from './securityPosture';
|
||||||
import type { BackendScheduledAction } from './scheduledActionRegistry';
|
import type { BackendScheduledAction } from './scheduledActionRegistry';
|
||||||
import { stackPatternMatches } from '../helpers/stackPattern';
|
import { stackPatternMatches } from '../helpers/stackPattern';
|
||||||
|
import {
|
||||||
|
parseStoredNotificationSchedule,
|
||||||
|
type NotificationSchedule,
|
||||||
|
} from '../helpers/notificationSchedule';
|
||||||
import { readSnapshotFileRow, type SnapshotFileReadResult, type SnapshotFileRow } from '../helpers/snapshotFileDecrypt';
|
import { readSnapshotFileRow, type SnapshotFileReadResult, type SnapshotFileRow } from '../helpers/snapshotFileDecrypt';
|
||||||
|
|
||||||
export type { SnapshotFileReadResult } from '../helpers/snapshotFileDecrypt';
|
export type { SnapshotFileReadResult } from '../helpers/snapshotFileDecrypt';
|
||||||
@@ -717,6 +721,10 @@ export interface NotificationSuppressionRule {
|
|||||||
applies_to: NotificationSuppressionAppliesTo;
|
applies_to: NotificationSuppressionAppliesTo;
|
||||||
enabled: boolean;
|
enabled: boolean;
|
||||||
expires_at: number | null;
|
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;
|
created_at: number;
|
||||||
updated_at: number;
|
updated_at: number;
|
||||||
}
|
}
|
||||||
@@ -2114,6 +2122,7 @@ export class DatabaseService {
|
|||||||
CREATE INDEX IF NOT EXISTS idx_notification_suppression_enabled
|
CREATE INDEX IF NOT EXISTS idx_notification_suppression_enabled
|
||||||
ON notification_suppression_rules(enabled, expires_at);
|
ON notification_suppression_rules(enabled, expires_at);
|
||||||
`);
|
`);
|
||||||
|
this.tryAddColumn('notification_suppression_rules', 'schedule', 'TEXT NULL');
|
||||||
}
|
}
|
||||||
|
|
||||||
private migrateNotificationHistoryContext(): void {
|
private migrateNotificationHistoryContext(): void {
|
||||||
@@ -2704,6 +2713,12 @@ export class DatabaseService {
|
|||||||
// --- Notification Suppression Rules ---
|
// --- Notification Suppression Rules ---
|
||||||
|
|
||||||
private parseNotificationSuppressionRule(row: Record<string, unknown>): NotificationSuppressionRule {
|
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 {
|
return {
|
||||||
id: row.id as number,
|
id: row.id as number,
|
||||||
name: row.name as string,
|
name: row.name as string,
|
||||||
@@ -2715,6 +2730,8 @@ export class DatabaseService {
|
|||||||
applies_to: row.applies_to as NotificationSuppressionAppliesTo,
|
applies_to: row.applies_to as NotificationSuppressionAppliesTo,
|
||||||
enabled: row.enabled === 1,
|
enabled: row.enabled === 1,
|
||||||
expires_at: row.expires_at != null ? (row.expires_at as number) : null,
|
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,
|
created_at: row.created_at as number,
|
||||||
updated_at: row.updated_at as number,
|
updated_at: row.updated_at as number,
|
||||||
};
|
};
|
||||||
@@ -2740,10 +2757,10 @@ export class DatabaseService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
public createNotificationSuppressionRule(
|
public createNotificationSuppressionRule(
|
||||||
rule: Omit<NotificationSuppressionRule, 'id'>,
|
rule: Omit<NotificationSuppressionRule, 'id' | 'scheduleInvalid'>,
|
||||||
): NotificationSuppressionRule {
|
): NotificationSuppressionRule {
|
||||||
const result = this.db.prepare(
|
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(
|
).run(
|
||||||
rule.name,
|
rule.name,
|
||||||
rule.node_id ?? null,
|
rule.node_id ?? null,
|
||||||
@@ -2754,6 +2771,7 @@ export class DatabaseService {
|
|||||||
rule.applies_to,
|
rule.applies_to,
|
||||||
rule.enabled ? 1 : 0,
|
rule.enabled ? 1 : 0,
|
||||||
rule.expires_at ?? null,
|
rule.expires_at ?? null,
|
||||||
|
rule.schedule ? JSON.stringify(rule.schedule) : null,
|
||||||
rule.created_at,
|
rule.created_at,
|
||||||
rule.updated_at,
|
rule.updated_at,
|
||||||
);
|
);
|
||||||
@@ -2761,12 +2779,13 @@ export class DatabaseService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
public upsertNotificationSuppressionRuleReplica(rule: NotificationSuppressionRule): void {
|
public upsertNotificationSuppressionRuleReplica(rule: NotificationSuppressionRule): void {
|
||||||
|
const scheduleJson = rule.schedule ? JSON.stringify(rule.schedule) : null;
|
||||||
const existing = this.getNotificationSuppressionRule(rule.id);
|
const existing = this.getNotificationSuppressionRule(rule.id);
|
||||||
if (existing) {
|
if (existing) {
|
||||||
this.db.prepare(
|
this.db.prepare(
|
||||||
`UPDATE notification_suppression_rules SET
|
`UPDATE notification_suppression_rules SET
|
||||||
name = ?, node_id = ?, stack_patterns = ?, label_ids = ?, categories = ?, levels = ?,
|
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 = ?`,
|
WHERE id = ?`,
|
||||||
).run(
|
).run(
|
||||||
rule.name,
|
rule.name,
|
||||||
@@ -2778,6 +2797,7 @@ export class DatabaseService {
|
|||||||
rule.applies_to,
|
rule.applies_to,
|
||||||
rule.enabled ? 1 : 0,
|
rule.enabled ? 1 : 0,
|
||||||
rule.expires_at ?? null,
|
rule.expires_at ?? null,
|
||||||
|
scheduleJson,
|
||||||
rule.updated_at,
|
rule.updated_at,
|
||||||
rule.id,
|
rule.id,
|
||||||
);
|
);
|
||||||
@@ -2785,8 +2805,8 @@ export class DatabaseService {
|
|||||||
}
|
}
|
||||||
this.db.prepare(
|
this.db.prepare(
|
||||||
`INSERT INTO notification_suppression_rules
|
`INSERT INTO notification_suppression_rules
|
||||||
(id, name, node_id, stack_patterns, label_ids, categories, levels, applies_to, enabled, expires_at, created_at, updated_at)
|
(id, name, node_id, stack_patterns, label_ids, categories, levels, applies_to, enabled, expires_at, schedule, created_at, updated_at)
|
||||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
|
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
|
||||||
).run(
|
).run(
|
||||||
rule.id,
|
rule.id,
|
||||||
rule.name,
|
rule.name,
|
||||||
@@ -2798,6 +2818,7 @@ export class DatabaseService {
|
|||||||
rule.applies_to,
|
rule.applies_to,
|
||||||
rule.enabled ? 1 : 0,
|
rule.enabled ? 1 : 0,
|
||||||
rule.expires_at ?? null,
|
rule.expires_at ?? null,
|
||||||
|
scheduleJson,
|
||||||
rule.created_at,
|
rule.created_at,
|
||||||
rule.updated_at,
|
rule.updated_at,
|
||||||
);
|
);
|
||||||
@@ -2805,7 +2826,7 @@ export class DatabaseService {
|
|||||||
|
|
||||||
public updateNotificationSuppressionRule(
|
public updateNotificationSuppressionRule(
|
||||||
id: number,
|
id: number,
|
||||||
updates: Partial<Omit<NotificationSuppressionRule, 'id' | 'created_at'>>,
|
updates: Partial<Omit<NotificationSuppressionRule, 'id' | 'created_at' | 'scheduleInvalid'>>,
|
||||||
): void {
|
): void {
|
||||||
const fields: string[] = [];
|
const fields: string[] = [];
|
||||||
const values: unknown[] = [];
|
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.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 (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 ('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 (updates.updated_at !== undefined) { fields.push('updated_at = ?'); values.push(updates.updated_at); }
|
||||||
|
|
||||||
if (fields.length === 0) return;
|
if (fields.length === 0) return;
|
||||||
|
|||||||
@@ -12,6 +12,7 @@ import {
|
|||||||
matchesNotificationFilters,
|
matchesNotificationFilters,
|
||||||
ruleNeedsStackLabels,
|
ruleNeedsStackLabels,
|
||||||
} from '../helpers/notificationMatchers';
|
} from '../helpers/notificationMatchers';
|
||||||
|
import { scheduleAllowsSuppression } from '../helpers/notificationSchedule';
|
||||||
import {
|
import {
|
||||||
type NotificationChannelType,
|
type NotificationChannelType,
|
||||||
type ParsedAppriseConfig,
|
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 routes = this.dbService.getEnabledNotificationRoutes();
|
||||||
const needsStackLabels = stackName !== undefined && (
|
const needsStackLabels = stackName !== undefined && (
|
||||||
ruleNeedsStackLabels(suppressionRules)
|
ruleNeedsStackLabels(suppressionRules)
|
||||||
@@ -246,7 +248,10 @@ export class NotificationService {
|
|||||||
level,
|
level,
|
||||||
stackLabelIds,
|
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 suppressBell = matchedSuppression.some((r) => appliesToBell(r.applies_to));
|
||||||
const suppressExternal = matchedSuppression.some((r) => appliesToExternal(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. |
|
| **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. |
|
| **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. |
|
| **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. |
|
| **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.
|
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)
|
### 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. |
|
| **Severity** | Info, warning, and/or error levels to suppress. |
|
||||||
| **Apply to** | Bell only, external channels only, or both. |
|
| **Apply to** | Bell only, external channels only, or both. |
|
||||||
| **Expiration** | Forever, 1 hour, 24 hours, or a custom timestamp. |
|
| **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. |
|
| **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;
|
applies_to: AppliesTo;
|
||||||
enabled: boolean;
|
enabled: boolean;
|
||||||
expires_at: number | null;
|
expires_at: number | null;
|
||||||
|
schedule: MuteRuleSchedule | null;
|
||||||
created_at: number;
|
created_at: number;
|
||||||
updated_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> = {
|
const LEVEL_LABELS: Record<NotificationLevel, string> = {
|
||||||
info: 'Info',
|
info: 'Info',
|
||||||
warning: 'Warning',
|
warning: 'Warning',
|
||||||
@@ -127,6 +158,10 @@ export function NotificationSuppressionSection({
|
|||||||
const [formEnabled, setFormEnabled] = useState(true);
|
const [formEnabled, setFormEnabled] = useState(true);
|
||||||
const [formExpirationPreset, setFormExpirationPreset] = useState<ExpirationPreset>('forever');
|
const [formExpirationPreset, setFormExpirationPreset] = useState<ExpirationPreset>('forever');
|
||||||
const [formCustomExpiry, setFormCustomExpiry] = useState('');
|
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 () => {
|
const fetchRules = useCallback(async () => {
|
||||||
try {
|
try {
|
||||||
@@ -176,6 +211,10 @@ export function NotificationSuppressionSection({
|
|||||||
});
|
});
|
||||||
setFormExpirationPreset('forever');
|
setFormExpirationPreset('forever');
|
||||||
setFormCustomExpiry('');
|
setFormCustomExpiry('');
|
||||||
|
setFormScheduleEnabled(false);
|
||||||
|
setFormScheduleDays([]);
|
||||||
|
setFormScheduleStart('02:00');
|
||||||
|
setFormScheduleEnd('06:00');
|
||||||
setEditingId(null);
|
setEditingId(null);
|
||||||
setShowForm(true);
|
setShowForm(true);
|
||||||
onPrefillConsumed?.();
|
onPrefillConsumed?.();
|
||||||
@@ -192,6 +231,10 @@ export function NotificationSuppressionSection({
|
|||||||
setFormEnabled(true);
|
setFormEnabled(true);
|
||||||
setFormExpirationPreset('forever');
|
setFormExpirationPreset('forever');
|
||||||
setFormCustomExpiry('');
|
setFormCustomExpiry('');
|
||||||
|
setFormScheduleEnabled(false);
|
||||||
|
setFormScheduleDays([]);
|
||||||
|
setFormScheduleStart('02:00');
|
||||||
|
setFormScheduleEnd('06:00');
|
||||||
setEditingId(null);
|
setEditingId(null);
|
||||||
setShowForm(false);
|
setShowForm(false);
|
||||||
};
|
};
|
||||||
@@ -209,6 +252,17 @@ export function NotificationSuppressionSection({
|
|||||||
setFormEnabled(rule.enabled);
|
setFormEnabled(rule.enabled);
|
||||||
setFormExpirationPreset(preset);
|
setFormExpirationPreset(preset);
|
||||||
setFormCustomExpiry(customMs != null ? new Date(customMs).toISOString().slice(0, 16) : '');
|
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);
|
setShowForm(true);
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -225,6 +279,30 @@ export function NotificationSuppressionSection({
|
|||||||
return;
|
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);
|
setSaving(true);
|
||||||
try {
|
try {
|
||||||
const body = {
|
const body = {
|
||||||
@@ -237,6 +315,7 @@ export function NotificationSuppressionSection({
|
|||||||
applies_to: formAppliesTo,
|
applies_to: formAppliesTo,
|
||||||
enabled: formEnabled,
|
enabled: formEnabled,
|
||||||
expires_at: expirationFromPreset(formExpirationPreset, customMs),
|
expires_at: expirationFromPreset(formExpirationPreset, customMs),
|
||||||
|
schedule,
|
||||||
};
|
};
|
||||||
|
|
||||||
const url = editingId
|
const url = editingId
|
||||||
@@ -483,6 +562,57 @@ export function NotificationSuppressionSection({
|
|||||||
)}
|
)}
|
||||||
</div>
|
</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">
|
<div className="flex items-center gap-2">
|
||||||
<TogglePill checked={formEnabled} onChange={setFormEnabled} id="mute-rule-enabled" />
|
<TogglePill checked={formEnabled} onChange={setFormEnabled} id="mute-rule-enabled" />
|
||||||
<span className="text-sm text-stat-value select-none">
|
<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="text-muted-foreground/50">|</span>
|
||||||
<span className="tabular-nums">Expires: {formatExpiry(rule.expires_at)}</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>
|
||||||
</div>
|
</div>
|
||||||
))}
|
))}
|
||||||
|
|||||||
@@ -14,6 +14,7 @@ export const CAPABILITIES = [
|
|||||||
'notifications',
|
'notifications',
|
||||||
'notification-routing',
|
'notification-routing',
|
||||||
'notification-suppression',
|
'notification-suppression',
|
||||||
|
'notification-suppression-schedule',
|
||||||
'host-console',
|
'host-console',
|
||||||
'container-exec',
|
'container-exec',
|
||||||
'audit-log',
|
'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;
|
applies_to?: MuteRuleAppliesTo;
|
||||||
enabled?: boolean;
|
enabled?: boolean;
|
||||||
expires_at?: number | null;
|
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';
|
export const MUTE_RULES_CHANGED_EVENT = 'sencho:mute-rules-changed';
|
||||||
@@ -28,6 +36,7 @@ const DEFAULT_BODY = {
|
|||||||
applies_to: 'both' as MuteRuleAppliesTo,
|
applies_to: 'both' as MuteRuleAppliesTo,
|
||||||
enabled: true,
|
enabled: true,
|
||||||
expires_at: null as number | null,
|
expires_at: null as number | null,
|
||||||
|
schedule: null as MuteRuleSchedule | null,
|
||||||
node_id: null as number | null,
|
node_id: null as number | null,
|
||||||
stack_patterns: [] as string[],
|
stack_patterns: [] as string[],
|
||||||
label_ids: null as number[] | null,
|
label_ids: null as number[] | null,
|
||||||
|
|||||||
Reference in New Issue
Block a user