mirror of
https://github.com/Studio-Saelix/sencho.git
synced 2026-07-26 11:49:16 +00:00
feat: add notification suppression rules (#1525)
* feat: add notification suppression rules * fix: restore label routing and routing test mocks for suppression * fix: allow bell mute shortcuts for history-only notification categories Suppression rule validation used the routable category whitelist, which rejected history-only categories such as update_started that appear in the bell during stack updates. * feat: expand Mute Rules UX with compose-first entry points and activity badges * fix: add missing NodeContext mocks for notification suppression tests
This commit is contained in:
@@ -120,6 +120,26 @@ describe('hubOnlyGuard', () => {
|
||||
expect(res.body?.code).toBe('HUB_ONLY_ENDPOINT');
|
||||
});
|
||||
|
||||
it('rejects /api/notification-suppression-rules with 403 when nodeId targets a remote node', async () => {
|
||||
const res = await request(app)
|
||||
.get('/api/notification-suppression-rules/')
|
||||
.set('Authorization', authHeader)
|
||||
.set('x-node-id', String(remoteNodeId));
|
||||
|
||||
expect(res.status).toBe(403);
|
||||
expect(res.body?.code).toBe('HUB_ONLY_ENDPOINT');
|
||||
});
|
||||
|
||||
it('rejects /api/notification-suppression-rules (no trailing slash) with 403 when nodeId targets a remote node', async () => {
|
||||
const res = await request(app)
|
||||
.get('/api/notification-suppression-rules')
|
||||
.set('Authorization', authHeader)
|
||||
.set('x-node-id', String(remoteNodeId));
|
||||
|
||||
expect(res.status).toBe(403);
|
||||
expect(res.body?.code).toBe('HUB_ONLY_ENDPOINT');
|
||||
});
|
||||
|
||||
// Regression for the Global Observability admin gate: the logs feed's
|
||||
// `requireAdmin` lives in the local route handler, which the proxy skips when
|
||||
// forwarding a remote nodeId. Without these prefixes the guard would let the
|
||||
|
||||
@@ -0,0 +1,89 @@
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import {
|
||||
matchesNotificationFilters,
|
||||
ruleNeedsStackLabels,
|
||||
appliesToBell,
|
||||
appliesToExternal,
|
||||
} from '../helpers/notificationMatchers';
|
||||
import type { NotificationMatchContext } from '../helpers/notificationMatchers';
|
||||
|
||||
const baseCtx: NotificationMatchContext = {
|
||||
localNodeId: 1,
|
||||
stackName: 'my-app',
|
||||
category: 'monitor_alert',
|
||||
level: 'error',
|
||||
stackLabelIds: [10],
|
||||
};
|
||||
|
||||
describe('notificationMatchers', () => {
|
||||
it('matches when all non-empty filters pass', () => {
|
||||
expect(matchesNotificationFilters(baseCtx, {
|
||||
node_id: null,
|
||||
stack_patterns: ['my-app'],
|
||||
label_ids: [10],
|
||||
categories: ['monitor_alert'],
|
||||
levels: ['error'],
|
||||
})).toBe(true);
|
||||
});
|
||||
|
||||
it('rejects when node_id does not match', () => {
|
||||
expect(matchesNotificationFilters(baseCtx, {
|
||||
node_id: 2,
|
||||
stack_patterns: [],
|
||||
label_ids: null,
|
||||
categories: null,
|
||||
})).toBe(false);
|
||||
});
|
||||
|
||||
it('rejects when stack pattern does not match', () => {
|
||||
expect(matchesNotificationFilters(baseCtx, {
|
||||
node_id: null,
|
||||
stack_patterns: ['other'],
|
||||
label_ids: null,
|
||||
categories: null,
|
||||
})).toBe(false);
|
||||
});
|
||||
|
||||
it('rejects when category does not match', () => {
|
||||
expect(matchesNotificationFilters(baseCtx, {
|
||||
node_id: null,
|
||||
stack_patterns: [],
|
||||
label_ids: null,
|
||||
categories: ['deploy_success'],
|
||||
})).toBe(false);
|
||||
});
|
||||
|
||||
it('rejects when level does not match', () => {
|
||||
expect(matchesNotificationFilters(baseCtx, {
|
||||
node_id: null,
|
||||
stack_patterns: [],
|
||||
label_ids: null,
|
||||
categories: null,
|
||||
levels: ['info'],
|
||||
})).toBe(false);
|
||||
});
|
||||
|
||||
it('matches any when all matchers empty', () => {
|
||||
expect(matchesNotificationFilters(baseCtx, {
|
||||
node_id: null,
|
||||
stack_patterns: [],
|
||||
label_ids: null,
|
||||
categories: null,
|
||||
levels: null,
|
||||
})).toBe(true);
|
||||
});
|
||||
|
||||
it('detects when stack labels are needed', () => {
|
||||
expect(ruleNeedsStackLabels([{ node_id: null, stack_patterns: [], label_ids: [1], categories: null }])).toBe(true);
|
||||
expect(ruleNeedsStackLabels([{ node_id: null, stack_patterns: [], label_ids: null, categories: null }])).toBe(false);
|
||||
});
|
||||
|
||||
it('applies_to helpers', () => {
|
||||
expect(appliesToBell('bell')).toBe(true);
|
||||
expect(appliesToBell('external')).toBe(false);
|
||||
expect(appliesToExternal('external')).toBe(true);
|
||||
expect(appliesToExternal('bell')).toBe(false);
|
||||
expect(appliesToBell('both')).toBe(true);
|
||||
expect(appliesToExternal('both')).toBe(true);
|
||||
});
|
||||
});
|
||||
@@ -8,12 +8,14 @@ import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
|
||||
|
||||
const {
|
||||
mockGetEnabledNotificationRoutes,
|
||||
mockGetEnabledNotificationSuppressionRules,
|
||||
mockGetEnabledAgents,
|
||||
mockGetStackLabelIds,
|
||||
mockAddNotificationHistory,
|
||||
mockUpdateNotificationDispatchError,
|
||||
} = vi.hoisted(() => ({
|
||||
mockGetEnabledNotificationRoutes: vi.fn().mockReturnValue([]),
|
||||
mockGetEnabledNotificationSuppressionRules: vi.fn().mockReturnValue([]),
|
||||
mockGetEnabledAgents: vi.fn().mockReturnValue([]),
|
||||
mockGetStackLabelIds: vi.fn().mockReturnValue([]),
|
||||
mockAddNotificationHistory: vi.fn().mockReturnValue({
|
||||
@@ -30,6 +32,7 @@ vi.mock('../services/DatabaseService', () => ({
|
||||
DatabaseService: {
|
||||
getInstance: () => ({
|
||||
getEnabledNotificationRoutes: mockGetEnabledNotificationRoutes,
|
||||
getEnabledNotificationSuppressionRules: mockGetEnabledNotificationSuppressionRules,
|
||||
getEnabledAgents: mockGetEnabledAgents,
|
||||
getStackLabelIds: mockGetStackLabelIds,
|
||||
addNotificationHistory: mockAddNotificationHistory,
|
||||
|
||||
@@ -0,0 +1,176 @@
|
||||
/**
|
||||
* Integration tests for notification suppression rules CRUD.
|
||||
*/
|
||||
import { describe, it, expect, beforeAll, afterAll, vi } from 'vitest';
|
||||
import request from 'supertest';
|
||||
import bcrypt from 'bcrypt';
|
||||
import { setupTestDb, cleanupTestDb, loginAsTestAdmin } from './helpers/setupTestDb';
|
||||
|
||||
let tmpDir: string;
|
||||
let app: import('express').Express;
|
||||
let DatabaseService: typeof import('../services/DatabaseService').DatabaseService;
|
||||
let authCookie: string;
|
||||
let viewerCookie: string;
|
||||
|
||||
const validBody = {
|
||||
name: 'Mute staging',
|
||||
stack_patterns: ['staging'],
|
||||
categories: ['monitor_alert'],
|
||||
levels: ['warning'],
|
||||
applies_to: 'both',
|
||||
enabled: true,
|
||||
expires_at: null,
|
||||
};
|
||||
|
||||
beforeAll(async () => {
|
||||
tmpDir = await setupTestDb();
|
||||
({ DatabaseService } = await import('../services/DatabaseService'));
|
||||
|
||||
const { LicenseService } = await import('../services/LicenseService');
|
||||
vi.spyOn(LicenseService.getInstance(), 'getTier').mockReturnValue('community');
|
||||
|
||||
({ app } = await import('../index'));
|
||||
authCookie = await loginAsTestAdmin(app);
|
||||
|
||||
const viewerHash = await bcrypt.hash('viewerpass', 1);
|
||||
DatabaseService.getInstance().addUser({ username: 'viewer', password_hash: viewerHash, role: 'viewer' });
|
||||
const viewerRes = await request(app)
|
||||
.post('/api/auth/login')
|
||||
.send({ username: 'viewer', password: 'viewerpass' });
|
||||
const cookies = viewerRes.headers['set-cookie'] as string | string[];
|
||||
viewerCookie = Array.isArray(cookies) ? cookies[0] : cookies;
|
||||
});
|
||||
|
||||
afterAll(() => {
|
||||
cleanupTestDb(tmpDir);
|
||||
});
|
||||
|
||||
describe('Notification suppression - auth enforcement', () => {
|
||||
it('GET returns 401 without auth', async () => {
|
||||
const res = await request(app).get('/api/notification-suppression-rules');
|
||||
expect(res.status).toBe(401);
|
||||
});
|
||||
|
||||
it('GET returns 403 for viewer', async () => {
|
||||
const res = await request(app)
|
||||
.get('/api/notification-suppression-rules')
|
||||
.set('Cookie', viewerCookie);
|
||||
expect(res.status).toBe(403);
|
||||
});
|
||||
|
||||
it('POST returns 403 for viewer', async () => {
|
||||
const res = await request(app)
|
||||
.post('/api/notification-suppression-rules')
|
||||
.set('Cookie', viewerCookie)
|
||||
.send(validBody);
|
||||
expect(res.status).toBe(403);
|
||||
});
|
||||
});
|
||||
|
||||
describe('Notification suppression - CRUD', () => {
|
||||
it('POST creates a rule on Community tier', async () => {
|
||||
const res = await request(app)
|
||||
.post('/api/notification-suppression-rules')
|
||||
.set('Cookie', authCookie)
|
||||
.send(validBody);
|
||||
expect(res.status).toBe(201);
|
||||
expect(res.body.name).toBe('Mute staging');
|
||||
expect(res.body.applies_to).toBe('both');
|
||||
if (typeof res.body?.id === 'number') {
|
||||
DatabaseService.getInstance().deleteNotificationSuppressionRule(res.body.id);
|
||||
}
|
||||
});
|
||||
|
||||
it('GET lists rules', async () => {
|
||||
const res = await request(app)
|
||||
.get('/api/notification-suppression-rules')
|
||||
.set('Cookie', authCookie);
|
||||
expect(res.status).toBe(200);
|
||||
expect(Array.isArray(res.body)).toBe(true);
|
||||
});
|
||||
|
||||
it('POST rejects invalid applies_to', async () => {
|
||||
const res = await request(app)
|
||||
.post('/api/notification-suppression-rules')
|
||||
.set('Cookie', authCookie)
|
||||
.send({ ...validBody, applies_to: 'invalid' });
|
||||
expect(res.status).toBe(400);
|
||||
});
|
||||
|
||||
it('POST rejects invalid levels', async () => {
|
||||
const res = await request(app)
|
||||
.post('/api/notification-suppression-rules')
|
||||
.set('Cookie', authCookie)
|
||||
.send({ ...validBody, levels: ['critical'] });
|
||||
expect(res.status).toBe(400);
|
||||
});
|
||||
|
||||
it('POST accepts history-only category update_started (bell-visible)', async () => {
|
||||
const res = await request(app)
|
||||
.post('/api/notification-suppression-rules')
|
||||
.set('Cookie', authCookie)
|
||||
.send({
|
||||
name: 'Mute stack updates',
|
||||
stack_patterns: [],
|
||||
categories: ['update_started'],
|
||||
levels: null,
|
||||
applies_to: 'both',
|
||||
enabled: true,
|
||||
expires_at: null,
|
||||
});
|
||||
expect(res.status).toBe(201);
|
||||
expect(res.body.categories).toEqual(['update_started']);
|
||||
if (typeof res.body?.id === 'number') {
|
||||
DatabaseService.getInstance().deleteNotificationSuppressionRule(res.body.id);
|
||||
}
|
||||
});
|
||||
|
||||
it('POST accepts routable category image_update_available', async () => {
|
||||
const res = await request(app)
|
||||
.post('/api/notification-suppression-rules')
|
||||
.set('Cookie', authCookie)
|
||||
.send({
|
||||
name: 'Mute image updates',
|
||||
stack_patterns: [],
|
||||
categories: ['image_update_available'],
|
||||
levels: null,
|
||||
applies_to: 'both',
|
||||
enabled: true,
|
||||
expires_at: null,
|
||||
});
|
||||
expect(res.status).toBe(201);
|
||||
if (typeof res.body?.id === 'number') {
|
||||
DatabaseService.getInstance().deleteNotificationSuppressionRule(res.body.id);
|
||||
}
|
||||
});
|
||||
|
||||
it('PUT updates a rule', async () => {
|
||||
const created = await request(app)
|
||||
.post('/api/notification-suppression-rules')
|
||||
.set('Cookie', authCookie)
|
||||
.send(validBody);
|
||||
const id = created.body.id as number;
|
||||
|
||||
const res = await request(app)
|
||||
.put(`/api/notification-suppression-rules/${id}`)
|
||||
.set('Cookie', authCookie)
|
||||
.send({ enabled: false });
|
||||
expect(res.status).toBe(200);
|
||||
expect(res.body.enabled).toBe(false);
|
||||
|
||||
DatabaseService.getInstance().deleteNotificationSuppressionRule(id);
|
||||
});
|
||||
|
||||
it('DELETE removes a rule', async () => {
|
||||
const created = await request(app)
|
||||
.post('/api/notification-suppression-rules')
|
||||
.set('Cookie', authCookie)
|
||||
.send(validBody);
|
||||
const id = created.body.id as number;
|
||||
|
||||
const res = await request(app)
|
||||
.delete(`/api/notification-suppression-rules/${id}`)
|
||||
.set('Cookie', authCookie);
|
||||
expect(res.status).toBe(200);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,175 @@
|
||||
/**
|
||||
* Unit tests for notification suppression in NotificationService.dispatchAlert.
|
||||
*/
|
||||
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
||||
|
||||
const {
|
||||
mockGetEnabledNotificationRoutes,
|
||||
mockGetEnabledAgents,
|
||||
mockGetStackLabelIds,
|
||||
mockAddNotificationHistory,
|
||||
mockUpdateNotificationDispatchError,
|
||||
mockGetEnabledNotificationSuppressionRules,
|
||||
mockUpdateNotificationSuppressionMatch,
|
||||
mockBroadcast,
|
||||
} = vi.hoisted(() => ({
|
||||
mockGetEnabledNotificationRoutes: vi.fn().mockReturnValue([]),
|
||||
mockGetEnabledAgents: vi.fn().mockReturnValue([]),
|
||||
mockGetStackLabelIds: vi.fn().mockReturnValue([]),
|
||||
mockAddNotificationHistory: vi.fn().mockReturnValue({
|
||||
id: 1,
|
||||
level: 'error',
|
||||
message: 'test',
|
||||
timestamp: Date.now(),
|
||||
is_read: 0,
|
||||
}),
|
||||
mockUpdateNotificationDispatchError: vi.fn(),
|
||||
mockGetEnabledNotificationSuppressionRules: vi.fn().mockReturnValue([]),
|
||||
mockUpdateNotificationSuppressionMatch: vi.fn(),
|
||||
mockBroadcast: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock('../services/DatabaseService', () => ({
|
||||
DatabaseService: {
|
||||
getInstance: () => ({
|
||||
getEnabledNotificationRoutes: mockGetEnabledNotificationRoutes,
|
||||
getEnabledAgents: mockGetEnabledAgents,
|
||||
getStackLabelIds: mockGetStackLabelIds,
|
||||
addNotificationHistory: mockAddNotificationHistory,
|
||||
updateNotificationDispatchError: mockUpdateNotificationDispatchError,
|
||||
getEnabledNotificationSuppressionRules: mockGetEnabledNotificationSuppressionRules,
|
||||
updateNotificationSuppressionMatch: mockUpdateNotificationSuppressionMatch,
|
||||
}),
|
||||
},
|
||||
}));
|
||||
|
||||
vi.mock('../services/NodeRegistry', () => ({
|
||||
NodeRegistry: {
|
||||
getInstance: () => ({
|
||||
getDefaultNodeId: () => 1,
|
||||
getComposeDir: () => '/app/compose',
|
||||
}),
|
||||
},
|
||||
}));
|
||||
|
||||
const mockFetch = vi.fn().mockResolvedValue({ ok: true });
|
||||
vi.stubGlobal('fetch', mockFetch);
|
||||
|
||||
import { NotificationService } from '../services/NotificationService';
|
||||
import { StackActivityMetricsService } from '../services/StackActivityMetricsService';
|
||||
|
||||
function makeSuppressionRule(overrides: Record<string, unknown> = {}) {
|
||||
return {
|
||||
id: 1,
|
||||
name: 'Mute crashes',
|
||||
node_id: null as number | null,
|
||||
stack_patterns: [] as string[],
|
||||
label_ids: null as number[] | null,
|
||||
categories: ['monitor_alert'] as string[] | null,
|
||||
levels: null as string[] | null,
|
||||
applies_to: 'both' as const,
|
||||
enabled: true,
|
||||
expires_at: null as number | null,
|
||||
created_at: Date.now(),
|
||||
updated_at: Date.now(),
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
function makeRoute() {
|
||||
return {
|
||||
id: 1,
|
||||
name: 'Prod Discord',
|
||||
node_id: null,
|
||||
stack_patterns: ['my-app'],
|
||||
label_ids: null,
|
||||
categories: null,
|
||||
channel_type: 'discord' as const,
|
||||
channel_url: 'https://discord.com/api/webhooks/123/abc',
|
||||
priority: 0,
|
||||
enabled: true,
|
||||
created_at: Date.now(),
|
||||
updated_at: Date.now(),
|
||||
};
|
||||
}
|
||||
|
||||
describe('NotificationService - suppression logic', () => {
|
||||
let svc: NotificationService;
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
(NotificationService as unknown as { instance?: NotificationService }).instance = undefined;
|
||||
svc = NotificationService.getInstance();
|
||||
vi.spyOn(
|
||||
svc as unknown as { broadcastToSubscribers: (n: unknown) => void },
|
||||
'broadcastToSubscribers',
|
||||
).mockImplementation(mockBroadcast);
|
||||
vi.spyOn(StackActivityMetricsService.getInstance(), 'record').mockImplementation(() => {});
|
||||
});
|
||||
|
||||
it('suppresses category via external dispatch', async () => {
|
||||
mockGetEnabledNotificationSuppressionRules.mockReturnValue([
|
||||
makeSuppressionRule({ categories: ['monitor_alert'], applies_to: 'external' }),
|
||||
]);
|
||||
mockGetEnabledAgents.mockReturnValue([{ type: 'slack', url: 'https://hooks.slack.com/x', enabled: true }]);
|
||||
|
||||
await svc.dispatchAlert('error', 'monitor_alert', 'Container crashed', { stackName: 'my-app' });
|
||||
|
||||
expect(mockBroadcast).toHaveBeenCalled();
|
||||
expect(mockFetch).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('suppresses severity via bell only', async () => {
|
||||
mockGetEnabledNotificationSuppressionRules.mockReturnValue([
|
||||
makeSuppressionRule({ levels: ['error'], applies_to: 'bell', categories: null }),
|
||||
]);
|
||||
mockGetEnabledAgents.mockReturnValue([{ type: 'slack', url: 'https://hooks.slack.com/x', enabled: true }]);
|
||||
|
||||
await svc.dispatchAlert('error', 'monitor_alert', 'Container crashed', { stackName: 'my-app' });
|
||||
|
||||
expect(mockBroadcast).not.toHaveBeenCalled();
|
||||
expect(mockFetch).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('suppresses both bell and external', async () => {
|
||||
mockGetEnabledNotificationSuppressionRules.mockReturnValue([
|
||||
makeSuppressionRule({ categories: ['monitor_alert'], applies_to: 'both' }),
|
||||
]);
|
||||
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).toHaveBeenCalledWith(1, {
|
||||
rules: [{ id: 1, name: 'Mute crashes' }],
|
||||
bellSuppressed: true,
|
||||
externalSuppressed: true,
|
||||
});
|
||||
});
|
||||
|
||||
it('allows dispatch when no suppression rules match', async () => {
|
||||
mockGetEnabledNotificationSuppressionRules.mockReturnValue([]);
|
||||
mockGetEnabledAgents.mockReturnValue([{ type: 'slack', url: 'https://hooks.slack.com/x', enabled: true }]);
|
||||
|
||||
await svc.dispatchAlert('error', 'monitor_alert', 'Crash');
|
||||
|
||||
expect(mockBroadcast).toHaveBeenCalled();
|
||||
expect(mockFetch).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('routing still works when suppression does not match', async () => {
|
||||
mockGetEnabledNotificationSuppressionRules.mockReturnValue([
|
||||
makeSuppressionRule({ categories: ['deploy_success'], applies_to: 'both' }),
|
||||
]);
|
||||
mockGetEnabledNotificationRoutes.mockReturnValue([makeRoute()]);
|
||||
|
||||
await svc.dispatchAlert('error', 'monitor_alert', 'Crash', { stackName: 'my-app' });
|
||||
|
||||
expect(mockBroadcast).toHaveBeenCalled();
|
||||
expect(mockFetch).toHaveBeenCalledWith(
|
||||
'https://discord.com/api/webhooks/123/abc',
|
||||
expect.objectContaining({ method: 'POST' }),
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,68 @@
|
||||
import type { NotificationCategory } from '../services/NotificationService';
|
||||
|
||||
export type NotificationLevel = 'info' | 'warning' | 'error';
|
||||
export type NotificationAppliesTo = 'bell' | 'external' | 'both';
|
||||
|
||||
export interface NotificationFilterRule {
|
||||
node_id: number | null;
|
||||
stack_patterns: string[];
|
||||
label_ids: number[] | null;
|
||||
categories: string[] | null;
|
||||
levels?: NotificationLevel[] | null;
|
||||
}
|
||||
|
||||
export interface NotificationMatchContext {
|
||||
localNodeId: number;
|
||||
stackName?: string;
|
||||
category: NotificationCategory;
|
||||
level: NotificationLevel;
|
||||
stackLabelIds: number[];
|
||||
}
|
||||
|
||||
/** True when all non-empty matchers on the rule match the alert context (AND). */
|
||||
export function matchesNotificationFilters(
|
||||
ctx: NotificationMatchContext,
|
||||
rule: NotificationFilterRule,
|
||||
): boolean {
|
||||
if (rule.node_id != null && rule.node_id !== ctx.localNodeId) return false;
|
||||
if (
|
||||
rule.stack_patterns.length > 0
|
||||
&& (ctx.stackName === undefined || !rule.stack_patterns.includes(ctx.stackName))
|
||||
) {
|
||||
return false;
|
||||
}
|
||||
if (
|
||||
rule.label_ids != null
|
||||
&& rule.label_ids.length > 0
|
||||
&& !rule.label_ids.some((id) => ctx.stackLabelIds.includes(id))
|
||||
) {
|
||||
return false;
|
||||
}
|
||||
if (
|
||||
rule.categories != null
|
||||
&& rule.categories.length > 0
|
||||
&& !rule.categories.includes(ctx.category)
|
||||
) {
|
||||
return false;
|
||||
}
|
||||
if (
|
||||
rule.levels != null
|
||||
&& rule.levels.length > 0
|
||||
&& !rule.levels.includes(ctx.level)
|
||||
) {
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
export function ruleNeedsStackLabels(rules: NotificationFilterRule[]): boolean {
|
||||
return rules.some((r) => r.label_ids != null && r.label_ids.length > 0);
|
||||
}
|
||||
|
||||
export function appliesToBell(appliesTo: NotificationAppliesTo): boolean {
|
||||
return appliesTo === 'bell' || appliesTo === 'both';
|
||||
}
|
||||
|
||||
export function appliesToExternal(appliesTo: NotificationAppliesTo): boolean {
|
||||
return appliesTo === 'external' || appliesTo === 'both';
|
||||
}
|
||||
@@ -0,0 +1,100 @@
|
||||
import { DatabaseService, type NotificationSuppressionRule, type Node } from '../services/DatabaseService';
|
||||
import { NodeRegistry } from '../services/NodeRegistry';
|
||||
import { LicenseService } from '../services/LicenseService';
|
||||
import { PROXY_TIER_HEADER } from '../services/license-headers';
|
||||
import { getErrorMessage } from '../utils/errors';
|
||||
|
||||
const SYNC_TIMEOUT_MS = 15_000;
|
||||
|
||||
function buildRemoteHeaders(apiToken: string): Record<string, string> {
|
||||
const proxyHeaders = LicenseService.getInstance().getProxyHeaders();
|
||||
const headers: Record<string, string> = {
|
||||
'Content-Type': 'application/json',
|
||||
[PROXY_TIER_HEADER]: proxyHeaders.tier,
|
||||
};
|
||||
if (apiToken) headers.Authorization = `Bearer ${apiToken}`;
|
||||
return headers;
|
||||
}
|
||||
|
||||
function replicationTargets(rule: NotificationSuppressionRule): Node[] {
|
||||
const db = DatabaseService.getInstance();
|
||||
const remotes = db.getNodes().filter((n) => n.type === 'remote');
|
||||
if (rule.node_id != null) {
|
||||
const target = remotes.find((n) => n.id === rule.node_id);
|
||||
return target ? [target] : [];
|
||||
}
|
||||
return remotes;
|
||||
}
|
||||
|
||||
async function pushRuleToNode(node: Node, rule: NotificationSuppressionRule): Promise<void> {
|
||||
const target = NodeRegistry.getInstance().getProxyTarget(node.id);
|
||||
if (!target?.apiUrl) {
|
||||
console.warn(`[SuppressionSync] Skipping node "${node.name}": no proxy target`);
|
||||
return;
|
||||
}
|
||||
const baseUrl = target.apiUrl.replace(/\/$/, '');
|
||||
const res = await fetch(`${baseUrl}/api/notification-suppression-rules/replica`, {
|
||||
method: 'POST',
|
||||
headers: buildRemoteHeaders(target.apiToken),
|
||||
body: JSON.stringify({ rule }),
|
||||
signal: AbortSignal.timeout(SYNC_TIMEOUT_MS),
|
||||
});
|
||||
if (!res.ok) {
|
||||
const body = await res.text().catch(() => '');
|
||||
throw new Error(`HTTP ${res.status}${body ? `: ${body.slice(0, 200)}` : ''}`);
|
||||
}
|
||||
}
|
||||
|
||||
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;
|
||||
}
|
||||
const baseUrl = target.apiUrl.replace(/\/$/, '');
|
||||
const res = await fetch(`${baseUrl}/api/notification-suppression-rules/replica/${ruleId}`, {
|
||||
method: 'DELETE',
|
||||
headers: buildRemoteHeaders(target.apiToken),
|
||||
signal: AbortSignal.timeout(SYNC_TIMEOUT_MS),
|
||||
});
|
||||
if (!res.ok && res.status !== 404) {
|
||||
const body = await res.text().catch(() => '');
|
||||
throw new Error(`HTTP ${res.status}${body ? `: ${body.slice(0, 200)}` : ''}`);
|
||||
}
|
||||
}
|
||||
|
||||
/** Best-effort push of a suppression rule to fleet nodes that should evaluate it. */
|
||||
export function syncSuppressionRuleToFleet(rule: NotificationSuppressionRule): void {
|
||||
const targets = replicationTargets(rule);
|
||||
if (targets.length === 0) return;
|
||||
void Promise.allSettled(
|
||||
targets.map(async (node) => {
|
||||
try {
|
||||
await pushRuleToNode(node, rule);
|
||||
} catch (err) {
|
||||
console.error(
|
||||
`[SuppressionSync] Failed to push rule ${rule.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);
|
||||
if (targets.length === 0) return;
|
||||
void Promise.allSettled(
|
||||
targets.map(async (node) => {
|
||||
try {
|
||||
await deleteRuleOnNode(node, rule.id);
|
||||
} catch (err) {
|
||||
console.error(
|
||||
`[SuppressionSync] Failed to delete rule ${rule.id} on node "${node.name}":`,
|
||||
getErrorMessage(err, String(err)),
|
||||
);
|
||||
}
|
||||
}),
|
||||
);
|
||||
}
|
||||
@@ -52,6 +52,7 @@ export const HUB_ONLY_PREFIXES: readonly string[] = [
|
||||
'/api/scheduled-tasks/',
|
||||
'/api/audit-log/',
|
||||
'/api/notification-routes/',
|
||||
'/api/notification-suppression-rules/',
|
||||
'/api/logs/global/',
|
||||
'/api/system/log-stream-metrics/',
|
||||
'/api/registries/',
|
||||
|
||||
@@ -36,7 +36,7 @@ import { agentsRouter } from './routes/agents';
|
||||
import { metricsRouter } from './routes/metrics';
|
||||
import { imageUpdatesRouter, autoUpdateRouter } from './routes/imageUpdates';
|
||||
import { autoHealRouter } from './routes/autoHeal';
|
||||
import { notificationsRouter, notificationRoutesRouter } from './routes/notifications';
|
||||
import { notificationsRouter, notificationRoutesRouter, notificationSuppressionRouter } from './routes/notifications';
|
||||
import { consoleRouter } from './routes/console';
|
||||
import { ssoConfigRouter } from './routes/ssoConfig';
|
||||
import { registriesRouter } from './routes/registries';
|
||||
@@ -134,6 +134,7 @@ app.use('/api/auto-update', autoUpdateRouter);
|
||||
app.use('/api/auto-heal', autoHealRouter);
|
||||
app.use('/api/notifications', notificationsRouter);
|
||||
app.use('/api/notification-routes', notificationRoutesRouter);
|
||||
app.use('/api/notification-suppression-rules', notificationSuppressionRouter);
|
||||
app.use('/api/system', consoleRouter);
|
||||
app.use('/api/sso/config', ssoConfigRouter);
|
||||
app.use('/api/registries', registriesRouter);
|
||||
|
||||
@@ -1,22 +1,29 @@
|
||||
import { Router, type Request, type Response } from 'express';
|
||||
import { DatabaseService } from '../services/DatabaseService';
|
||||
import { NotificationService, ALL_NOTIFICATION_CATEGORIES } from '../services/NotificationService';
|
||||
import { DatabaseService, type NotificationSuppressionAppliesTo, type NotificationSuppressionRule } from '../services/DatabaseService';
|
||||
import { NotificationService, ALL_NOTIFICATION_CATEGORIES, ALL_SUPPRESSIBLE_CATEGORIES } from '../services/NotificationService';
|
||||
import type { NotificationCategory } from '../services/NotificationService';
|
||||
import { NodeRegistry } from '../services/NodeRegistry';
|
||||
import { authMiddleware } from '../middleware/auth';
|
||||
import { requireAdmin } from '../middleware/tierGates';
|
||||
import { requireAdmin, requireNodeProxy } from '../middleware/tierGates';
|
||||
import {
|
||||
NOTIFICATION_CHANNEL_TYPES,
|
||||
validateHttpsUrl,
|
||||
cleanStackPatterns,
|
||||
maskWebhookUrl,
|
||||
} from '../helpers/notificationChannels';
|
||||
import {
|
||||
deleteSuppressionRuleFromFleet,
|
||||
syncSuppressionRuleToFleet,
|
||||
} from '../helpers/notificationSuppressionSync';
|
||||
import { isDebugEnabled } from '../utils/debug';
|
||||
import { getErrorMessage } from '../utils/errors';
|
||||
import { sanitizeForLog } from '../utils/safeLog';
|
||||
import { parseIntParam } from '../utils/parseIntParam';
|
||||
|
||||
const VALID_CATEGORIES: ReadonlySet<NotificationCategory> = new Set(ALL_NOTIFICATION_CATEGORIES);
|
||||
const VALID_SUPPRESSION_CATEGORIES: ReadonlySet<NotificationCategory> = new Set(ALL_SUPPRESSIBLE_CATEGORIES);
|
||||
const VALID_LEVELS = new Set(['info', 'warning', 'error']);
|
||||
const VALID_APPLIES_TO = new Set<NotificationSuppressionAppliesTo>(['bell', 'external', 'both']);
|
||||
|
||||
function validateNodeId(nodeId: unknown, res: Response): number | null | false {
|
||||
if (nodeId === undefined || nodeId === null) return null;
|
||||
@@ -41,15 +48,138 @@ function validateLabelIds(label_ids: unknown, res: Response): boolean {
|
||||
return true;
|
||||
}
|
||||
|
||||
function validateCategories(categories: unknown, res: Response): boolean {
|
||||
function validateCategories(
|
||||
categories: unknown,
|
||||
res: Response,
|
||||
allowed: ReadonlySet<NotificationCategory> = VALID_CATEGORIES,
|
||||
): boolean {
|
||||
if (categories === undefined || categories === null) return true;
|
||||
if (!Array.isArray(categories) || categories.some((c: unknown) => typeof c !== 'string' || !VALID_CATEGORIES.has(c as NotificationCategory))) {
|
||||
if (!Array.isArray(categories) || categories.some((c: unknown) => typeof c !== 'string' || !allowed.has(c as NotificationCategory))) {
|
||||
res.status(400).json({ error: 'categories must be an array of valid category names' });
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
function validateSuppressionNodeId(nodeId: unknown, res: Response): number | null | false {
|
||||
if (nodeId === undefined || nodeId === null) return null;
|
||||
if (typeof nodeId !== 'number' || !Number.isInteger(nodeId)) {
|
||||
res.status(400).json({ error: 'node_id must be an integer or null' });
|
||||
return false;
|
||||
}
|
||||
const node = DatabaseService.getInstance().getNode(nodeId);
|
||||
if (!node) {
|
||||
res.status(400).json({ error: 'node_id must reference a registered node or be null' });
|
||||
return false;
|
||||
}
|
||||
return nodeId;
|
||||
}
|
||||
|
||||
function validateLevels(levels: unknown, res: Response): boolean {
|
||||
if (levels === undefined || levels === null) return true;
|
||||
if (!Array.isArray(levels) || levels.some((l: unknown) => typeof l !== 'string' || !VALID_LEVELS.has(l))) {
|
||||
res.status(400).json({ error: 'levels must be an array of info, warning, or error' });
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
function validateAppliesTo(applies_to: unknown, res: Response): NotificationSuppressionAppliesTo | false {
|
||||
if (typeof applies_to !== 'string' || !VALID_APPLIES_TO.has(applies_to as NotificationSuppressionAppliesTo)) {
|
||||
res.status(400).json({ error: 'applies_to must be bell, external, or both' });
|
||||
return false;
|
||||
}
|
||||
return applies_to as NotificationSuppressionAppliesTo;
|
||||
}
|
||||
|
||||
function validateExpiresAt(expires_at: unknown, res: Response): number | null | false | undefined {
|
||||
if (expires_at === undefined) return undefined;
|
||||
if (expires_at === null) return null;
|
||||
if (typeof expires_at !== 'number' || !Number.isFinite(expires_at)) {
|
||||
res.status(400).json({ error: 'expires_at must be a finite timestamp or null' });
|
||||
return false;
|
||||
}
|
||||
return expires_at;
|
||||
}
|
||||
|
||||
function parseSuppressionRuleBody(
|
||||
req: Request,
|
||||
res: Response,
|
||||
isCreate: boolean,
|
||||
): Omit<NotificationSuppressionRule, 'id' | 'created_at' | 'updated_at'> | null {
|
||||
const {
|
||||
name,
|
||||
node_id: rawNodeId,
|
||||
stack_patterns,
|
||||
label_ids,
|
||||
categories,
|
||||
levels,
|
||||
applies_to,
|
||||
enabled,
|
||||
expires_at,
|
||||
} = req.body;
|
||||
|
||||
if (isCreate && (!name || typeof name !== 'string' || !name.trim())) {
|
||||
res.status(400).json({ error: 'Name is required' });
|
||||
return null;
|
||||
}
|
||||
if (name !== undefined && (typeof name !== 'string' || !name.trim())) {
|
||||
res.status(400).json({ error: 'Name must be a non-empty string' });
|
||||
return null;
|
||||
}
|
||||
if (name !== undefined && name.trim().length > 100) {
|
||||
res.status(400).json({ error: 'Name must be 100 characters or fewer' });
|
||||
return null;
|
||||
}
|
||||
|
||||
const nodeIdResult = isCreate || 'node_id' in req.body
|
||||
? validateSuppressionNodeId(rawNodeId, res)
|
||||
: undefined;
|
||||
if (nodeIdResult === false) return null;
|
||||
|
||||
let cleanedPatterns: string[] | undefined;
|
||||
if (stack_patterns !== undefined) {
|
||||
if (!Array.isArray(stack_patterns) || stack_patterns.some((p: unknown) => typeof p !== 'string')) {
|
||||
res.status(400).json({ error: 'stack_patterns must be an array of strings' });
|
||||
return null;
|
||||
}
|
||||
cleanedPatterns = cleanStackPatterns(stack_patterns);
|
||||
} else if (isCreate) {
|
||||
cleanedPatterns = [];
|
||||
}
|
||||
|
||||
if (!validateLabelIds(label_ids, res)) return null;
|
||||
if (!validateCategories(categories, res, VALID_SUPPRESSION_CATEGORIES)) return null;
|
||||
if (!validateLevels(levels, res)) return null;
|
||||
|
||||
const appliesToResult = isCreate
|
||||
? validateAppliesTo(applies_to, res)
|
||||
: applies_to !== undefined
|
||||
? validateAppliesTo(applies_to, res)
|
||||
: undefined;
|
||||
if (appliesToResult === false) return null;
|
||||
|
||||
const expiresAtResult = validateExpiresAt(expires_at, res);
|
||||
if (expiresAtResult === false) return null;
|
||||
|
||||
if (enabled !== undefined && typeof enabled !== 'boolean') {
|
||||
res.status(400).json({ error: 'enabled must be a boolean' });
|
||||
return null;
|
||||
}
|
||||
|
||||
return {
|
||||
name: (name as string).trim(),
|
||||
node_id: nodeIdResult ?? null,
|
||||
stack_patterns: cleanedPatterns ?? [],
|
||||
label_ids: Array.isArray(label_ids) && label_ids.length > 0 ? label_ids : null,
|
||||
categories: Array.isArray(categories) && categories.length > 0 ? categories : null,
|
||||
levels: Array.isArray(levels) && levels.length > 0 ? levels : null,
|
||||
applies_to: (appliesToResult ?? 'both') as NotificationSuppressionAppliesTo,
|
||||
enabled: enabled !== false,
|
||||
expires_at: expiresAtResult ?? null,
|
||||
};
|
||||
}
|
||||
|
||||
export const notificationsRouter = Router();
|
||||
|
||||
notificationsRouter.get('/', authMiddleware, async (req: Request, res: Response): Promise<void> => {
|
||||
@@ -293,3 +423,181 @@ notificationRoutesRouter.post('/:id/test', authMiddleware, async (req: Request,
|
||||
}
|
||||
});
|
||||
|
||||
export const notificationSuppressionRouter = Router();
|
||||
|
||||
notificationSuppressionRouter.post('/replica', authMiddleware, (req: Request, res: Response): void => {
|
||||
if (!requireNodeProxy(req, res)) return;
|
||||
try {
|
||||
const rule = req.body?.rule as NotificationSuppressionRule | undefined;
|
||||
if (!rule || typeof rule.id !== 'number' || typeof rule.name !== 'string') {
|
||||
res.status(400).json({ error: 'rule object with id and name is required' });
|
||||
return;
|
||||
}
|
||||
if (!VALID_APPLIES_TO.has(rule.applies_to)) {
|
||||
res.status(400).json({ error: 'Invalid applies_to on rule' });
|
||||
return;
|
||||
}
|
||||
DatabaseService.getInstance().upsertNotificationSuppressionRuleReplica(rule);
|
||||
res.json({ success: true });
|
||||
} catch (error) {
|
||||
console.error('Failed to apply suppression rule replica:', error);
|
||||
res.status(500).json({ error: 'Failed to apply suppression rule replica' });
|
||||
}
|
||||
});
|
||||
|
||||
notificationSuppressionRouter.delete('/replica/:id', authMiddleware, (req: Request, res: Response): void => {
|
||||
if (!requireNodeProxy(req, res)) return;
|
||||
try {
|
||||
const id = parseIntParam(req, res, 'id', 'suppression rule ID');
|
||||
if (id === null) return;
|
||||
DatabaseService.getInstance().deleteNotificationSuppressionRule(id);
|
||||
res.json({ success: true });
|
||||
} catch (error) {
|
||||
console.error('Failed to delete suppression rule replica:', error);
|
||||
res.status(500).json({ error: 'Failed to delete suppression rule replica' });
|
||||
}
|
||||
});
|
||||
|
||||
notificationSuppressionRouter.get('/', authMiddleware, (req: Request, res: Response): void => {
|
||||
if (!requireAdmin(req, res)) return;
|
||||
try {
|
||||
const rules = DatabaseService.getInstance().getNotificationSuppressionRules();
|
||||
res.json(rules);
|
||||
} catch (error) {
|
||||
console.error('Failed to fetch notification suppression rules:', error);
|
||||
res.status(500).json({ error: 'Failed to fetch notification suppression rules' });
|
||||
}
|
||||
});
|
||||
|
||||
notificationSuppressionRouter.post('/', authMiddleware, (req: Request, res: Response): void => {
|
||||
if (!requireAdmin(req, res)) return;
|
||||
try {
|
||||
const parsed = parseSuppressionRuleBody(req, res, true);
|
||||
if (!parsed) return;
|
||||
|
||||
const now = Date.now();
|
||||
const rule = DatabaseService.getInstance().createNotificationSuppressionRule({
|
||||
...parsed,
|
||||
created_at: now,
|
||||
updated_at: now,
|
||||
});
|
||||
syncSuppressionRuleToFleet(rule);
|
||||
console.log(`[Suppression] Rule "${sanitizeForLog(rule.name)}" created (id=${rule.id})`);
|
||||
res.status(201).json(rule);
|
||||
} catch (error) {
|
||||
console.error('Failed to create notification suppression rule:', error);
|
||||
res.status(500).json({ error: 'Failed to create notification suppression rule' });
|
||||
}
|
||||
});
|
||||
|
||||
notificationSuppressionRouter.put('/:id', authMiddleware, (req: Request, res: Response): void => {
|
||||
if (!requireAdmin(req, res)) return;
|
||||
try {
|
||||
const id = parseIntParam(req, res, 'id', 'suppression rule ID');
|
||||
if (id === null) return;
|
||||
|
||||
const existing = DatabaseService.getInstance().getNotificationSuppressionRule(id);
|
||||
if (!existing) { res.status(404).json({ error: 'Suppression rule not found' }); return; }
|
||||
|
||||
const {
|
||||
name,
|
||||
node_id: rawNodeId,
|
||||
stack_patterns,
|
||||
label_ids,
|
||||
categories,
|
||||
levels,
|
||||
applies_to,
|
||||
enabled,
|
||||
expires_at,
|
||||
} = req.body;
|
||||
|
||||
if (name !== undefined && (typeof name !== 'string' || !name.trim())) {
|
||||
res.status(400).json({ error: 'Name must be a non-empty string' });
|
||||
return;
|
||||
}
|
||||
if (name !== undefined && name.trim().length > 100) {
|
||||
res.status(400).json({ error: 'Name must be 100 characters or fewer' });
|
||||
return;
|
||||
}
|
||||
|
||||
let validatedNodeId: number | null | undefined;
|
||||
if ('node_id' in req.body) {
|
||||
const result = validateSuppressionNodeId(rawNodeId, res);
|
||||
if (result === false) return;
|
||||
validatedNodeId = result;
|
||||
}
|
||||
|
||||
let cleanedPatterns: string[] | undefined;
|
||||
if (stack_patterns !== undefined) {
|
||||
if (!Array.isArray(stack_patterns) || stack_patterns.some((p: unknown) => typeof p !== 'string')) {
|
||||
res.status(400).json({ error: 'stack_patterns must be an array of strings' });
|
||||
return;
|
||||
}
|
||||
cleanedPatterns = cleanStackPatterns(stack_patterns);
|
||||
}
|
||||
|
||||
if (!validateLabelIds(label_ids, res)) return;
|
||||
if (!validateCategories(categories, res, VALID_SUPPRESSION_CATEGORIES)) return;
|
||||
if (!validateLevels(levels, res)) return;
|
||||
|
||||
let validatedAppliesTo: NotificationSuppressionAppliesTo | undefined;
|
||||
if (applies_to !== undefined) {
|
||||
const result = validateAppliesTo(applies_to, res);
|
||||
if (result === false) return;
|
||||
validatedAppliesTo = result;
|
||||
}
|
||||
|
||||
let validatedExpiresAt: number | null | undefined;
|
||||
if ('expires_at' in req.body) {
|
||||
const result = validateExpiresAt(expires_at, res);
|
||||
if (result === false) return;
|
||||
validatedExpiresAt = 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() };
|
||||
if (name !== undefined) updates.name = name.trim();
|
||||
if (validatedNodeId !== undefined) updates.node_id = validatedNodeId;
|
||||
if (cleanedPatterns !== undefined) updates.stack_patterns = cleanedPatterns;
|
||||
if ('label_ids' in req.body) updates.label_ids = Array.isArray(label_ids) && label_ids.length > 0 ? label_ids : null;
|
||||
if ('categories' in req.body) updates.categories = Array.isArray(categories) && categories.length > 0 ? categories : null;
|
||||
if ('levels' in req.body) updates.levels = Array.isArray(levels) && levels.length > 0 ? levels : null;
|
||||
if (validatedAppliesTo !== undefined) updates.applies_to = validatedAppliesTo;
|
||||
if (enabled !== undefined) updates.enabled = enabled;
|
||||
if (validatedExpiresAt !== undefined) updates.expires_at = validatedExpiresAt;
|
||||
|
||||
const db = DatabaseService.getInstance();
|
||||
db.updateNotificationSuppressionRule(id, updates);
|
||||
const updated = db.getNotificationSuppressionRule(id)!;
|
||||
syncSuppressionRuleToFleet(updated);
|
||||
console.log(`[Suppression] Rule ${id} updated`);
|
||||
res.json(updated);
|
||||
} catch (error) {
|
||||
console.error('Failed to update notification suppression rule:', error);
|
||||
res.status(500).json({ error: 'Failed to update notification suppression rule' });
|
||||
}
|
||||
});
|
||||
|
||||
notificationSuppressionRouter.delete('/:id', authMiddleware, (req: Request, res: Response): void => {
|
||||
if (!requireAdmin(req, res)) return;
|
||||
try {
|
||||
const id = parseIntParam(req, res, 'id', 'suppression rule ID');
|
||||
if (id === null) return;
|
||||
|
||||
const existing = DatabaseService.getInstance().getNotificationSuppressionRule(id);
|
||||
if (!existing) { res.status(404).json({ error: 'Suppression rule not found' }); return; }
|
||||
|
||||
DatabaseService.getInstance().deleteNotificationSuppressionRule(id);
|
||||
deleteSuppressionRuleFromFleet(existing);
|
||||
console.log(`[Suppression] Rule ${id} deleted`);
|
||||
res.json({ success: true });
|
||||
} catch (error) {
|
||||
console.error('Failed to delete notification suppression rule:', error);
|
||||
res.status(500).json({ error: 'Failed to delete notification suppression rule' });
|
||||
}
|
||||
});
|
||||
|
||||
|
||||
@@ -25,6 +25,7 @@ export const CAPABILITIES = [
|
||||
'network-topology',
|
||||
'notifications',
|
||||
'notification-routing',
|
||||
'notification-suppression',
|
||||
'host-console',
|
||||
'container-exec',
|
||||
'audit-log',
|
||||
|
||||
@@ -377,6 +377,7 @@ export interface NotificationHistory {
|
||||
stack_name?: string;
|
||||
container_name?: string;
|
||||
actor_username?: string | null;
|
||||
suppression_match?: string | null;
|
||||
}
|
||||
|
||||
export interface FleetSnapshot {
|
||||
@@ -600,6 +601,23 @@ export interface NotificationRoute {
|
||||
updated_at: number;
|
||||
}
|
||||
|
||||
export type NotificationSuppressionAppliesTo = 'bell' | 'external' | 'both';
|
||||
|
||||
export interface NotificationSuppressionRule {
|
||||
id: number;
|
||||
name: string;
|
||||
node_id: number | null;
|
||||
stack_patterns: string[];
|
||||
label_ids: number[] | null;
|
||||
categories: string[] | null;
|
||||
levels: ('info' | 'warning' | 'error')[] | null;
|
||||
applies_to: NotificationSuppressionAppliesTo;
|
||||
enabled: boolean;
|
||||
expires_at: number | null;
|
||||
created_at: number;
|
||||
updated_at: number;
|
||||
}
|
||||
|
||||
export type VulnSeverity = 'CRITICAL' | 'HIGH' | 'MEDIUM' | 'LOW' | 'UNKNOWN';
|
||||
export type VulnScanStatus = 'in_progress' | 'completed' | 'failed';
|
||||
export type VulnScanTrigger = 'manual' | 'scheduled' | 'deploy' | 'deploy-preflight';
|
||||
@@ -871,6 +889,7 @@ export class DatabaseService {
|
||||
this.migrateNotificationRoutes();
|
||||
this.migrateNotificationRoutesNodeId();
|
||||
this.migrateNotificationRoutesMatchers();
|
||||
this.migrateNotificationSuppressionRules();
|
||||
this.migrateNotificationHistoryContext();
|
||||
this.migrateScanPolicyFleetColumns();
|
||||
this.migrateScanPolicyRiskColumns();
|
||||
@@ -1814,9 +1833,31 @@ export class DatabaseService {
|
||||
this.tryAddColumn('notification_routes', 'categories', 'TEXT NULL');
|
||||
}
|
||||
|
||||
private migrateNotificationSuppressionRules(): void {
|
||||
this.db.exec(`
|
||||
CREATE TABLE IF NOT EXISTS 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
|
||||
);
|
||||
CREATE INDEX IF NOT EXISTS idx_notification_suppression_enabled
|
||||
ON notification_suppression_rules(enabled, expires_at);
|
||||
`);
|
||||
}
|
||||
|
||||
private migrateNotificationHistoryContext(): void {
|
||||
this.tryAddColumn('notification_history', 'stack_name', 'TEXT');
|
||||
this.tryAddColumn('notification_history', 'container_name', 'TEXT');
|
||||
this.tryAddColumn('notification_history', 'suppression_match', 'TEXT');
|
||||
}
|
||||
|
||||
private migrateStackDossierHashes(): void {
|
||||
@@ -2293,6 +2334,135 @@ export class DatabaseService {
|
||||
return this.db.prepare('DELETE FROM notification_routes WHERE id = ?').run(id).changes;
|
||||
}
|
||||
|
||||
// --- Notification Suppression Rules ---
|
||||
|
||||
private parseNotificationSuppressionRule(row: Record<string, unknown>): NotificationSuppressionRule {
|
||||
return {
|
||||
id: row.id as number,
|
||||
name: row.name as string,
|
||||
node_id: row.node_id != null ? (row.node_id as number) : null,
|
||||
stack_patterns: JSON.parse(row.stack_patterns as string) as string[],
|
||||
label_ids: row.label_ids ? JSON.parse(row.label_ids as string) as number[] : null,
|
||||
categories: row.categories ? JSON.parse(row.categories as string) as string[] : null,
|
||||
levels: row.levels ? JSON.parse(row.levels as string) as ('info' | 'warning' | 'error')[] : null,
|
||||
applies_to: row.applies_to as NotificationSuppressionAppliesTo,
|
||||
enabled: row.enabled === 1,
|
||||
expires_at: row.expires_at != null ? (row.expires_at as number) : null,
|
||||
created_at: row.created_at as number,
|
||||
updated_at: row.updated_at as number,
|
||||
};
|
||||
}
|
||||
|
||||
public getNotificationSuppressionRules(): NotificationSuppressionRule[] {
|
||||
return this.db.prepare('SELECT * FROM notification_suppression_rules ORDER BY created_at ASC')
|
||||
.all()
|
||||
.map((row) => this.parseNotificationSuppressionRule(row as Record<string, unknown>));
|
||||
}
|
||||
|
||||
public getEnabledNotificationSuppressionRules(now = Date.now()): NotificationSuppressionRule[] {
|
||||
return this.db.prepare(
|
||||
'SELECT * FROM notification_suppression_rules WHERE enabled = 1 AND (expires_at IS NULL OR expires_at > ?) ORDER BY created_at ASC',
|
||||
)
|
||||
.all(now)
|
||||
.map((row) => this.parseNotificationSuppressionRule(row as Record<string, unknown>));
|
||||
}
|
||||
|
||||
public getNotificationSuppressionRule(id: number): NotificationSuppressionRule | undefined {
|
||||
const row = this.db.prepare('SELECT * FROM notification_suppression_rules WHERE id = ?').get(id) as Record<string, unknown> | undefined;
|
||||
return row ? this.parseNotificationSuppressionRule(row) : undefined;
|
||||
}
|
||||
|
||||
public createNotificationSuppressionRule(
|
||||
rule: Omit<NotificationSuppressionRule, 'id'>,
|
||||
): 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 (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)',
|
||||
).run(
|
||||
rule.name,
|
||||
rule.node_id ?? null,
|
||||
JSON.stringify(rule.stack_patterns),
|
||||
rule.label_ids ? JSON.stringify(rule.label_ids) : null,
|
||||
rule.categories ? JSON.stringify(rule.categories) : null,
|
||||
rule.levels ? JSON.stringify(rule.levels) : null,
|
||||
rule.applies_to,
|
||||
rule.enabled ? 1 : 0,
|
||||
rule.expires_at ?? null,
|
||||
rule.created_at,
|
||||
rule.updated_at,
|
||||
);
|
||||
return this.getNotificationSuppressionRule(result.lastInsertRowid as number)!;
|
||||
}
|
||||
|
||||
public upsertNotificationSuppressionRuleReplica(rule: NotificationSuppressionRule): void {
|
||||
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 = ?
|
||||
WHERE id = ?`,
|
||||
).run(
|
||||
rule.name,
|
||||
rule.node_id ?? null,
|
||||
JSON.stringify(rule.stack_patterns),
|
||||
rule.label_ids ? JSON.stringify(rule.label_ids) : null,
|
||||
rule.categories ? JSON.stringify(rule.categories) : null,
|
||||
rule.levels ? JSON.stringify(rule.levels) : null,
|
||||
rule.applies_to,
|
||||
rule.enabled ? 1 : 0,
|
||||
rule.expires_at ?? null,
|
||||
rule.updated_at,
|
||||
rule.id,
|
||||
);
|
||||
return;
|
||||
}
|
||||
this.db.prepare(
|
||||
`INSERT INTO notification_suppression_rules
|
||||
(id, name, node_id, stack_patterns, label_ids, categories, levels, applies_to, enabled, expires_at, created_at, updated_at)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
|
||||
).run(
|
||||
rule.id,
|
||||
rule.name,
|
||||
rule.node_id ?? null,
|
||||
JSON.stringify(rule.stack_patterns),
|
||||
rule.label_ids ? JSON.stringify(rule.label_ids) : null,
|
||||
rule.categories ? JSON.stringify(rule.categories) : null,
|
||||
rule.levels ? JSON.stringify(rule.levels) : null,
|
||||
rule.applies_to,
|
||||
rule.enabled ? 1 : 0,
|
||||
rule.expires_at ?? null,
|
||||
rule.created_at,
|
||||
rule.updated_at,
|
||||
);
|
||||
}
|
||||
|
||||
public updateNotificationSuppressionRule(
|
||||
id: number,
|
||||
updates: Partial<Omit<NotificationSuppressionRule, 'id' | 'created_at'>>,
|
||||
): void {
|
||||
const fields: string[] = [];
|
||||
const values: unknown[] = [];
|
||||
|
||||
if (updates.name !== undefined) { fields.push('name = ?'); values.push(updates.name); }
|
||||
if ('node_id' in updates) { fields.push('node_id = ?'); values.push(updates.node_id ?? null); }
|
||||
if (updates.stack_patterns !== undefined) { fields.push('stack_patterns = ?'); values.push(JSON.stringify(updates.stack_patterns)); }
|
||||
if ('label_ids' in updates) { fields.push('label_ids = ?'); values.push(updates.label_ids ? JSON.stringify(updates.label_ids) : null); }
|
||||
if ('categories' in updates) { fields.push('categories = ?'); values.push(updates.categories ? JSON.stringify(updates.categories) : null); }
|
||||
if ('levels' in updates) { fields.push('levels = ?'); values.push(updates.levels ? JSON.stringify(updates.levels) : null); }
|
||||
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 (updates.updated_at !== undefined) { fields.push('updated_at = ?'); values.push(updates.updated_at); }
|
||||
|
||||
if (fields.length === 0) return;
|
||||
values.push(id);
|
||||
this.db.prepare(`UPDATE notification_suppression_rules SET ${fields.join(', ')} WHERE id = ?`).run(...values);
|
||||
}
|
||||
|
||||
public deleteNotificationSuppressionRule(id: number): number {
|
||||
return this.db.prepare('DELETE FROM notification_suppression_rules WHERE id = ?').run(id).changes;
|
||||
}
|
||||
|
||||
// --- Global Settings ---
|
||||
|
||||
public getGlobalSettings(): Readonly<Record<string, string>> {
|
||||
@@ -2806,6 +2976,7 @@ export class DatabaseService {
|
||||
container_name: row.container_name ?? undefined,
|
||||
category: row.category ?? undefined,
|
||||
actor_username: row.actor_username ?? null,
|
||||
suppression_match: row.suppression_match ?? null,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -2914,6 +3085,13 @@ export class DatabaseService {
|
||||
this.db.prepare('UPDATE notification_history SET dispatch_error = ? WHERE id = ?').run(error, id);
|
||||
}
|
||||
|
||||
public updateNotificationSuppressionMatch(
|
||||
id: number,
|
||||
snapshot: { rules: { id: number; name: string }[]; bellSuppressed: boolean; externalSuppressed: boolean },
|
||||
): void {
|
||||
this.db.prepare('UPDATE notification_history SET suppression_match = ? WHERE id = ?').run(JSON.stringify(snapshot), id);
|
||||
}
|
||||
|
||||
public getStackRestartSummary(nodeId: number, days: number): StackRestartSummary[] {
|
||||
const since = Date.now() - days * 86400 * 1000;
|
||||
return this.db.prepare(`
|
||||
|
||||
@@ -6,6 +6,12 @@ import { getErrorMessage } from '../utils/errors';
|
||||
import { sanitizeForLog } from '../utils/safeLog';
|
||||
import { sanitizeNotificationMessage } from '../utils/notificationMessage';
|
||||
import { StackActivityMetricsService } from './StackActivityMetricsService';
|
||||
import {
|
||||
appliesToBell,
|
||||
appliesToExternal,
|
||||
matchesNotificationFilters,
|
||||
ruleNeedsStackLabels,
|
||||
} from '../helpers/notificationMatchers';
|
||||
|
||||
export type NotificationCategory =
|
||||
| 'deploy_success'
|
||||
@@ -44,6 +50,13 @@ export const ALL_NOTIFICATION_CATEGORIES: readonly NotificationCategory[] = [
|
||||
'node_update_available', 'system',
|
||||
];
|
||||
|
||||
/** Every category that can appear in notification history / the bell panel. */
|
||||
export const ALL_SUPPRESSIBLE_CATEGORIES: readonly NotificationCategory[] = [
|
||||
...ALL_NOTIFICATION_CATEGORIES,
|
||||
'drift_detected', 'drift_resolved',
|
||||
'update_started', 'health_gate_passed', 'health_gate_failed',
|
||||
];
|
||||
|
||||
/** Webhook timeout: 10 seconds per external dispatch call. */
|
||||
const WEBHOOK_TIMEOUT_MS = 10_000;
|
||||
|
||||
@@ -185,23 +198,51 @@ export class NotificationService {
|
||||
});
|
||||
}
|
||||
|
||||
const suppressionRules = this.dbService.getEnabledNotificationSuppressionRules();
|
||||
const routes = this.dbService.getEnabledNotificationRoutes();
|
||||
const needsStackLabels = stackName !== undefined && (
|
||||
ruleNeedsStackLabels(suppressionRules)
|
||||
|| routes.some((r) => r.label_ids != null && r.label_ids.length > 0)
|
||||
);
|
||||
const stackLabelIds = needsStackLabels
|
||||
? this.dbService.getStackLabelIds(localNodeId, stackName!)
|
||||
: [];
|
||||
const matchCtx = {
|
||||
localNodeId,
|
||||
stackName,
|
||||
category,
|
||||
level,
|
||||
stackLabelIds,
|
||||
};
|
||||
const matchedSuppression = suppressionRules.filter((r) => matchesNotificationFilters(matchCtx, r));
|
||||
const suppressBell = matchedSuppression.some((r) => appliesToBell(r.applies_to));
|
||||
const suppressExternal = matchedSuppression.some((r) => appliesToExternal(r.applies_to));
|
||||
|
||||
if (isDebugEnabled() && matchedSuppression.length > 0) {
|
||||
console.log(`[Notify:diag] Suppression matched ${matchedSuppression.length} rule(s); bell=${suppressBell}, external=${suppressExternal}`);
|
||||
}
|
||||
|
||||
if (matchedSuppression.length > 0 && notification.id != null) {
|
||||
this.dbService.updateNotificationSuppressionMatch(notification.id, {
|
||||
rules: matchedSuppression.map((r) => ({ id: r.id, name: r.name })),
|
||||
bellSuppressed: suppressBell,
|
||||
externalSuppressed: suppressExternal,
|
||||
});
|
||||
}
|
||||
|
||||
// 2. Push to connected browser clients via WebSocket
|
||||
this.broadcastToSubscribers(notification);
|
||||
if (!suppressBell) {
|
||||
this.broadcastToSubscribers(notification);
|
||||
}
|
||||
|
||||
if (suppressExternal) {
|
||||
return;
|
||||
}
|
||||
|
||||
// 3. Check notification routing rules — always evaluated, matchers compose AND
|
||||
const errors: string[] = [];
|
||||
|
||||
const routes = this.dbService.getEnabledNotificationRoutes();
|
||||
const needsLabels = stackName !== undefined && routes.some(r => r.label_ids != null && r.label_ids.length > 0);
|
||||
const stackLabelIds = needsLabels ? this.dbService.getStackLabelIds(localNodeId, stackName!) : [];
|
||||
|
||||
const matched = routes.filter(r => {
|
||||
if (r.node_id != null && r.node_id !== localNodeId) return false;
|
||||
if (r.stack_patterns.length > 0 && (stackName === undefined || !r.stack_patterns.includes(stackName))) return false;
|
||||
if (r.label_ids != null && r.label_ids.length > 0 && !r.label_ids.some(id => stackLabelIds.includes(id))) return false;
|
||||
if (r.categories != null && r.categories.length > 0 && !r.categories.includes(category)) return false;
|
||||
return true;
|
||||
});
|
||||
const matched = routes.filter(r => matchesNotificationFilters(matchCtx, r));
|
||||
if (matched.length > 0) {
|
||||
if (isDebugEnabled()) console.log(`[Notify:diag] Matched ${matched.length} route(s) for stack "${sanitizeForLog(stackName ?? '(none)')}", category="${sanitizeForLog(category)}"`);
|
||||
await Promise.allSettled(
|
||||
|
||||
@@ -72,6 +72,9 @@ export const AUDIT_ROUTE_SUMMARIES: Record<string, string> = {
|
||||
'PUT /notification-routes': 'Updated notification route',
|
||||
'DELETE /notification-routes': 'Deleted notification route',
|
||||
'POST /notification-routes/*/test': 'Tested notification route',
|
||||
'POST /notification-suppression-rules': 'Created notification suppression rule',
|
||||
'PUT /notification-suppression-rules': 'Updated notification suppression rule',
|
||||
'DELETE /notification-suppression-rules': 'Deleted notification suppression rule',
|
||||
|
||||
// Webhooks
|
||||
'POST /webhooks': 'Created webhook',
|
||||
|
||||
Reference in New Issue
Block a user