diff --git a/backend/src/__tests__/notification-routes-api.test.ts b/backend/src/__tests__/notification-routes-api.test.ts index ebbe194b..6a8daa3b 100644 --- a/backend/src/__tests__/notification-routes-api.test.ts +++ b/backend/src/__tests__/notification-routes-api.test.ts @@ -282,20 +282,21 @@ describe('POST /api/notification-routes - validation', () => { expect(res.body.error).toContain('100'); }); - it('rejects empty stack_patterns array', async () => { + it('accepts empty stack_patterns array', async () => { const res = await request(app) .post('/api/notification-routes') .set('Cookie', authCookie) .send({ name: 'test', stack_patterns: [], channel_type: 'discord', channel_url: 'https://discord.com/api/webhooks/123/abc' }); - expect(res.status).toBe(400); + expect(res.status).toBe(201); }); - it('rejects whitespace-only stack patterns', async () => { + it('accepts whitespace-only stack patterns, cleaning them to an empty array', async () => { const res = await request(app) .post('/api/notification-routes') .set('Cookie', authCookie) .send({ name: 'test', stack_patterns: [' ', ''], channel_type: 'discord', channel_url: 'https://discord.com/api/webhooks/123/abc' }); - expect(res.status).toBe(400); + expect(res.status).toBe(201); + expect(res.body.stack_patterns).toEqual([]); }); it('rejects invalid channel_type', async () => { diff --git a/backend/src/__tests__/notification-routing.test.ts b/backend/src/__tests__/notification-routing.test.ts index e2ca8294..69697604 100644 --- a/backend/src/__tests__/notification-routing.test.ts +++ b/backend/src/__tests__/notification-routing.test.ts @@ -9,11 +9,13 @@ import { describe, it, expect, vi, beforeEach } from 'vitest'; const { mockGetEnabledNotificationRoutes, mockGetEnabledAgents, + mockGetStackLabelIds, mockAddNotificationHistory, mockUpdateNotificationDispatchError, } = vi.hoisted(() => ({ mockGetEnabledNotificationRoutes: vi.fn().mockReturnValue([]), mockGetEnabledAgents: vi.fn().mockReturnValue([]), + mockGetStackLabelIds: vi.fn().mockReturnValue([]), mockAddNotificationHistory: vi.fn().mockReturnValue({ id: 1, level: 'info', @@ -29,6 +31,7 @@ vi.mock('../services/DatabaseService', () => ({ getInstance: () => ({ getEnabledNotificationRoutes: mockGetEnabledNotificationRoutes, getEnabledAgents: mockGetEnabledAgents, + getStackLabelIds: mockGetStackLabelIds, addNotificationHistory: mockAddNotificationHistory, updateNotificationDispatchError: mockUpdateNotificationDispatchError, }), @@ -60,6 +63,8 @@ function makeRoute(overrides: Record = {}) { name: 'Prod Discord', node_id: null as number | null, stack_patterns: ['my-app'], + label_ids: null as number[] | null, + categories: null as string[] | null, channel_type: 'discord' as const, channel_url: 'https://discord.com/api/webhooks/123/abc', priority: 0, @@ -129,13 +134,13 @@ describe('NotificationService - routing logic', () => { ); }); - it('falls back to global agents when no stackName provided', async () => { - mockGetEnabledNotificationRoutes.mockReturnValue([makeRoute()]); + it('falls back to global agents when no stackName provided and route requires a specific stack', async () => { + // Route has a stack_patterns filter, so it won't match an alert with no stackName + mockGetEnabledNotificationRoutes.mockReturnValue([makeRoute({ stack_patterns: ['my-app'] })]); mockGetEnabledAgents.mockReturnValue([makeAgent()]); await svc.dispatchAlert('warning', 'monitor_alert', 'Host CPU high'); - // Should have called global agent (no stackName means skip routing) expect(mockFetch).toHaveBeenCalledWith( 'https://hooks.slack.com/services/global', expect.objectContaining({ method: 'POST' }) @@ -319,7 +324,7 @@ describe('NotificationService - routing logic', () => { mockGetEnabledNotificationRoutes.mockReturnValue([makeRoute({ node_id: 1 })]); mockGetEnabledAgents.mockReturnValue([]); - await svc.dispatchAlert('info', 'Test', 'my-app'); + await svc.dispatchAlert('info', 'system', 'Test', { stackName: 'my-app' }); expect(mockFetch).toHaveBeenCalledWith( 'https://discord.com/api/webhooks/123/abc', @@ -332,7 +337,7 @@ describe('NotificationService - routing logic', () => { mockGetEnabledNotificationRoutes.mockReturnValue([makeRoute({ node_id: 99 })]); mockGetEnabledAgents.mockReturnValue([makeAgent()]); - await svc.dispatchAlert('info', 'Test', 'my-app'); + await svc.dispatchAlert('info', 'system', 'Test', { stackName: 'my-app' }); // Route should be skipped; falls back to global agent expect(mockFetch).not.toHaveBeenCalledWith( @@ -350,7 +355,114 @@ describe('NotificationService - routing logic', () => { mockGetEnabledNotificationRoutes.mockReturnValue([makeRoute({ node_id: null })]); mockGetEnabledAgents.mockReturnValue([]); - await svc.dispatchAlert('warning', 'Global alert', 'my-app'); + await svc.dispatchAlert('warning', 'monitor_alert', 'Global alert', { stackName: 'my-app' }); + + expect(mockFetch).toHaveBeenCalledWith( + 'https://discord.com/api/webhooks/123/abc', + expect.objectContaining({ method: 'POST' }) + ); + }); + + it('fires a category-only route when category matches', async () => { + mockGetEnabledNotificationRoutes.mockReturnValue([ + makeRoute({ stack_patterns: [], categories: ['deploy_failure'] }), + ]); + mockGetEnabledAgents.mockReturnValue([]); + + await svc.dispatchAlert('error', 'deploy_failure', 'Deploy failed', { stackName: 'my-app' }); + + expect(mockFetch).toHaveBeenCalledWith( + 'https://discord.com/api/webhooks/123/abc', + expect.objectContaining({ method: 'POST' }) + ); + }); + + it('skips a category-only route when category does not match', async () => { + mockGetEnabledNotificationRoutes.mockReturnValue([ + makeRoute({ stack_patterns: [], categories: ['deploy_failure'] }), + ]); + mockGetEnabledAgents.mockReturnValue([makeAgent()]); + + await svc.dispatchAlert('info', 'deploy_success', 'Deploy ok', { stackName: 'my-app' }); + + expect(mockFetch).not.toHaveBeenCalledWith( + 'https://discord.com/api/webhooks/123/abc', + expect.anything() + ); + expect(mockFetch).toHaveBeenCalledWith( + 'https://hooks.slack.com/services/global', + expect.objectContaining({ method: 'POST' }) + ); + }); + + it('fires a label-only route when stack has a matching label', async () => { + // Stack 'my-app' on node 1 has label id 42 + mockGetStackLabelIds.mockReturnValue([42]); + mockGetEnabledNotificationRoutes.mockReturnValue([ + makeRoute({ stack_patterns: [], label_ids: [42] }), + ]); + mockGetEnabledAgents.mockReturnValue([]); + + await svc.dispatchAlert('info', 'image_update_available', 'Update ready', { stackName: 'my-app' }); + + expect(mockFetch).toHaveBeenCalledWith( + 'https://discord.com/api/webhooks/123/abc', + expect.objectContaining({ method: 'POST' }) + ); + }); + + it('skips a label-only route when stack does not have a matching label', async () => { + mockGetStackLabelIds.mockReturnValue([99]); + mockGetEnabledNotificationRoutes.mockReturnValue([ + makeRoute({ stack_patterns: [], label_ids: [42] }), + ]); + mockGetEnabledAgents.mockReturnValue([makeAgent()]); + + await svc.dispatchAlert('info', 'image_update_available', 'Update ready', { stackName: 'my-app' }); + + expect(mockFetch).not.toHaveBeenCalledWith( + 'https://discord.com/api/webhooks/123/abc', + expect.anything() + ); + expect(mockFetch).toHaveBeenCalledWith( + 'https://hooks.slack.com/services/global', + expect.objectContaining({ method: 'POST' }) + ); + }); + + it('AND semantics: combined label + category route fires only when both match', async () => { + mockGetStackLabelIds.mockReturnValue([42]); + mockGetEnabledNotificationRoutes.mockReturnValue([ + makeRoute({ stack_patterns: [], label_ids: [42], categories: ['deploy_failure'] }), + ]); + mockGetEnabledAgents.mockReturnValue([makeAgent()]); + + mockGetStackLabelIds.mockReturnValueOnce([99]); + await svc.dispatchAlert('error', 'deploy_failure', 'Deploy fail 1', { stackName: 'my-app' }); + expect(mockFetch).not.toHaveBeenCalledWith('https://discord.com/api/webhooks/123/abc', expect.anything()); + + vi.clearAllMocks(); + mockGetStackLabelIds.mockReturnValue([42]); + await svc.dispatchAlert('info', 'deploy_success', 'Deploy ok', { stackName: 'my-app' }); + expect(mockFetch).not.toHaveBeenCalledWith('https://discord.com/api/webhooks/123/abc', expect.anything()); + + vi.clearAllMocks(); + // Both match + mockGetStackLabelIds.mockReturnValue([42]); + await svc.dispatchAlert('error', 'deploy_failure', 'Deploy fail 2', { stackName: 'my-app' }); + expect(mockFetch).toHaveBeenCalledWith( + 'https://discord.com/api/webhooks/123/abc', + expect.objectContaining({ method: 'POST' }) + ); + }); + + it('category-only route with no stackName matches alert from any emission', async () => { + mockGetEnabledNotificationRoutes.mockReturnValue([ + makeRoute({ stack_patterns: [], categories: ['system'] }), + ]); + mockGetEnabledAgents.mockReturnValue([]); + + await svc.dispatchAlert('info', 'system', 'Host rebooted'); expect(mockFetch).toHaveBeenCalledWith( 'https://discord.com/api/webhooks/123/abc', diff --git a/backend/src/routes/notifications.ts b/backend/src/routes/notifications.ts index cbcc1fe4..d62469d5 100644 --- a/backend/src/routes/notifications.ts +++ b/backend/src/routes/notifications.ts @@ -1,6 +1,7 @@ import { Router, type Request, type Response } from 'express'; import { DatabaseService } from '../services/DatabaseService'; -import { NotificationService } from '../services/NotificationService'; +import { NotificationService, ALL_NOTIFICATION_CATEGORIES } from '../services/NotificationService'; +import type { NotificationCategory } from '../services/NotificationService'; import { NodeRegistry } from '../services/NodeRegistry'; import { authMiddleware } from '../middleware/auth'; import { requireAdmin, requireAdmiral } from '../middleware/tierGates'; @@ -12,6 +13,8 @@ import { import { isDebugEnabled } from '../utils/debug'; import { getErrorMessage } from '../utils/errors'; +const VALID_CATEGORIES: ReadonlySet = new Set(ALL_NOTIFICATION_CATEGORIES); + function parseRouteId(req: Request, res: Response): number | null { const id = parseInt(req.params.id as string, 10); if (isNaN(id)) { @@ -35,6 +38,24 @@ function validateNodeId(nodeId: unknown, res: Response): number | null | false { return nodeId; } +function validateLabelIds(label_ids: unknown, res: Response): boolean { + if (label_ids === undefined || label_ids === null) return true; + if (!Array.isArray(label_ids) || label_ids.some((id: unknown) => typeof id !== 'number' || !Number.isInteger(id))) { + res.status(400).json({ error: 'label_ids must be an array of integers or null' }); + return false; + } + return true; +} + +function validateCategories(categories: unknown, res: Response): 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))) { + res.status(400).json({ error: 'categories must be an array of valid category names' }); + return false; + } + return true; +} + export const notificationsRouter = Router(); notificationsRouter.get('/', authMiddleware, async (req: Request, res: Response): Promise => { @@ -115,7 +136,7 @@ notificationRoutesRouter.post('/', authMiddleware, async (req: Request, res: Res if (!requireAdmin(req, res)) return; if (!requireAdmiral(req, res)) return; try { - const { name, node_id: rawNodeId, stack_patterns, channel_type, channel_url, priority, enabled } = req.body; + const { name, node_id: rawNodeId, stack_patterns, label_ids, categories, channel_type, channel_url, priority, enabled } = req.body; if (!name || typeof name !== 'string' || !name.trim()) { res.status(400).json({ error: 'Name is required' }); @@ -127,15 +148,13 @@ notificationRoutesRouter.post('/', authMiddleware, async (req: Request, res: Res } const nodeIdResult = validateNodeId(rawNodeId, res); if (nodeIdResult === false) return; - if (!Array.isArray(stack_patterns) || stack_patterns.length === 0 || stack_patterns.some((p: unknown) => typeof p !== 'string')) { - res.status(400).json({ error: 'stack_patterns must be a non-empty array of stack names' }); - return; - } - const cleanedPatterns = cleanStackPatterns(stack_patterns); - if (cleanedPatterns.length === 0) { - res.status(400).json({ error: 'stack_patterns must contain at least one non-empty stack name' }); + const cleanedPatterns = Array.isArray(stack_patterns) ? cleanStackPatterns(stack_patterns) : []; + 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; } + if (!validateLabelIds(label_ids, res)) return; + if (!validateCategories(categories, res)) return; if (!(NOTIFICATION_CHANNEL_TYPES as readonly string[]).includes(channel_type)) { res.status(400).json({ error: `channel_type must be ${NOTIFICATION_CHANNEL_TYPES.join(', ')}` }); return; @@ -152,6 +171,8 @@ notificationRoutesRouter.post('/', authMiddleware, async (req: Request, res: Res name: name.trim(), node_id: nodeIdResult, stack_patterns: cleanedPatterns, + label_ids: Array.isArray(label_ids) && label_ids.length > 0 ? label_ids : null, + categories: Array.isArray(categories) && categories.length > 0 ? (categories as NotificationCategory[]) : null, channel_type, channel_url: channel_url.trim(), priority: typeof priority === 'number' ? priority : 0, @@ -178,7 +199,7 @@ notificationRoutesRouter.put('/:id', authMiddleware, async (req: Request, res: R const existing = DatabaseService.getInstance().getNotificationRoute(id); if (!existing) { res.status(404).json({ error: 'Route not found' }); return; } - const { name, node_id: rawNodeId, stack_patterns, channel_type, channel_url, priority, enabled } = req.body; + const { name, node_id: rawNodeId, stack_patterns, label_ids, categories, channel_type, channel_url, priority, enabled } = req.body; if (name !== undefined && (typeof name !== 'string' || !name.trim())) { res.status(400).json({ error: 'Name must be a non-empty string' }); @@ -196,16 +217,14 @@ notificationRoutesRouter.put('/:id', authMiddleware, async (req: Request, res: R } let cleanedPatterns: string[] | undefined; if (stack_patterns !== undefined) { - if (!Array.isArray(stack_patterns) || stack_patterns.length === 0 || stack_patterns.some((p: unknown) => typeof p !== 'string')) { - res.status(400).json({ error: 'stack_patterns must be a non-empty array of stack names' }); + 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 (cleanedPatterns.length === 0) { - res.status(400).json({ error: 'stack_patterns must contain at least one non-empty stack name' }); - return; - } } + if (!validateLabelIds(label_ids, res)) return; + if (!validateCategories(categories, res)) return; if (channel_type !== undefined && !(NOTIFICATION_CHANNEL_TYPES as readonly string[]).includes(channel_type)) { res.status(400).json({ error: `channel_type must be ${NOTIFICATION_CHANNEL_TYPES.join(', ')}` }); return; @@ -227,6 +246,8 @@ notificationRoutesRouter.put('/:id', authMiddleware, async (req: Request, res: R 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 (channel_type !== undefined) updates.channel_type = channel_type; if (channel_url !== undefined) updates.channel_url = channel_url.trim(); if (priority !== undefined) updates.priority = priority; diff --git a/backend/src/services/DatabaseService.ts b/backend/src/services/DatabaseService.ts index 744e3fff..176cd9d5 100644 --- a/backend/src/services/DatabaseService.ts +++ b/backend/src/services/DatabaseService.ts @@ -309,6 +309,8 @@ export interface NotificationRoute { name: string; node_id: number | null; stack_patterns: string[]; + label_ids: number[] | null; + categories: string[] | null; channel_type: 'discord' | 'slack' | 'webhook'; channel_url: string; priority: number; @@ -487,6 +489,7 @@ export class DatabaseService { this.migrateRoleAssignments(); this.migrateNotificationRoutes(); this.migrateNotificationRoutesNodeId(); + this.migrateNotificationRoutesMatchers(); this.migrateNotificationHistoryContext(); this.migrateScanPolicyFleetColumns(); this.migrateSecretMisconfigColumns(); @@ -1141,53 +1144,38 @@ export class DatabaseService { this.db.prepare('CREATE INDEX IF NOT EXISTS idx_notification_routes_node_priority ON notification_routes(node_id, enabled, priority)').run(); } + private tryAddColumn(table: string, col: string, def: string): void { + try { + this.db.prepare(`ALTER TABLE ${table} ADD COLUMN ${col} ${def}`).run(); + } catch { + /* column already present */ + } + } + + private migrateNotificationRoutesMatchers(): void { + this.tryAddColumn('notification_routes', 'label_ids', 'TEXT NULL'); + this.tryAddColumn('notification_routes', 'categories', 'TEXT NULL'); + } + private migrateNotificationHistoryContext(): void { - const tryAddColumn = (col: string, def: string) => { - try { - this.db.prepare(`ALTER TABLE notification_history ADD COLUMN ${col} ${def}`).run(); - } catch { - /* column already present */ - } - }; - tryAddColumn('stack_name', 'TEXT'); - tryAddColumn('container_name', 'TEXT'); + this.tryAddColumn('notification_history', 'stack_name', 'TEXT'); + this.tryAddColumn('notification_history', 'container_name', 'TEXT'); } private migrateScanPolicyFleetColumns(): void { - const tryAddColumn = (table: string, col: string, def: string) => { - try { - this.db.prepare(`ALTER TABLE ${table} ADD COLUMN ${col} ${def}`).run(); - } catch { - /* column already present */ - } - }; - tryAddColumn('scan_policies', 'node_identity', "TEXT NOT NULL DEFAULT ''"); - tryAddColumn('scan_policies', 'replicated_from_control', 'INTEGER NOT NULL DEFAULT 0'); + this.tryAddColumn('scan_policies', 'node_identity', "TEXT NOT NULL DEFAULT ''"); + this.tryAddColumn('scan_policies', 'replicated_from_control', 'INTEGER NOT NULL DEFAULT 0'); } private migrateSecretMisconfigColumns(): void { - const tryAddColumn = (table: string, col: string, def: string) => { - try { - this.db.prepare(`ALTER TABLE ${table} ADD COLUMN ${col} ${def}`).run(); - } catch { - /* column already present */ - } - }; - tryAddColumn('vulnerability_scans', 'secret_count', 'INTEGER NOT NULL DEFAULT 0'); - tryAddColumn('vulnerability_scans', 'misconfig_count', 'INTEGER NOT NULL DEFAULT 0'); - tryAddColumn('vulnerability_scans', 'scanners_used', "TEXT NOT NULL DEFAULT 'vuln'"); + this.tryAddColumn('vulnerability_scans', 'secret_count', 'INTEGER NOT NULL DEFAULT 0'); + this.tryAddColumn('vulnerability_scans', 'misconfig_count', 'INTEGER NOT NULL DEFAULT 0'); + this.tryAddColumn('vulnerability_scans', 'scanners_used', "TEXT NOT NULL DEFAULT 'vuln'"); } private migrateAgentsAndNotificationsNodeId(): void { - const tryAddColumn = (table: string, col: string, def: string) => { - try { - this.db.prepare(`ALTER TABLE ${table} ADD COLUMN ${col} ${def}`).run(); - } catch { - /* column already present */ - } - }; - tryAddColumn('agents', 'node_id', 'INTEGER NOT NULL DEFAULT 0'); - tryAddColumn('notification_history', 'node_id', 'INTEGER NOT NULL DEFAULT 0'); + this.tryAddColumn('agents', 'node_id', 'INTEGER NOT NULL DEFAULT 0'); + this.tryAddColumn('notification_history', 'node_id', 'INTEGER NOT NULL DEFAULT 0'); const tryIndex = (sql: string, label: string) => { try { this.db.prepare(sql).run(); @@ -1260,6 +1248,8 @@ export class DatabaseService { 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, channel_type: row.channel_type as 'discord' | 'slack' | 'webhook', channel_url: row.channel_url as string, priority: row.priority as number, @@ -1269,6 +1259,13 @@ export class DatabaseService { }; } + public getStackLabelIds(nodeId: number, stackName: string): number[] { + const rows = this.db.prepare( + 'SELECT label_id FROM stack_label_assignments WHERE stack_name = ? AND node_id = ?' + ).all(stackName, nodeId) as { label_id: number }[]; + return rows.map(r => r.label_id); + } + public getNotificationRoutes(): NotificationRoute[] { return this.db.prepare('SELECT * FROM notification_routes ORDER BY priority ASC') .all() @@ -1288,11 +1285,13 @@ export class DatabaseService { public createNotificationRoute(route: Omit): NotificationRoute { const result = this.db.prepare( - 'INSERT INTO notification_routes (name, node_id, stack_patterns, channel_type, channel_url, priority, enabled, created_at, updated_at) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)' + 'INSERT INTO notification_routes (name, node_id, stack_patterns, label_ids, categories, channel_type, channel_url, priority, enabled, created_at, updated_at) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)' ).run( route.name, route.node_id ?? null, JSON.stringify(route.stack_patterns), + route.label_ids ? JSON.stringify(route.label_ids) : null, + route.categories ? JSON.stringify(route.categories) : null, route.channel_type, route.channel_url, route.priority, @@ -1310,6 +1309,8 @@ export class DatabaseService { 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 (updates.channel_type !== undefined) { fields.push('channel_type = ?'); values.push(updates.channel_type); } if (updates.channel_url !== undefined) { fields.push('channel_url = ?'); values.push(updates.channel_url); } if (updates.priority !== undefined) { fields.push('priority = ?'); values.push(updates.priority); } diff --git a/backend/src/services/NotificationService.ts b/backend/src/services/NotificationService.ts index d1dff71b..eb2c73fb 100644 --- a/backend/src/services/NotificationService.ts +++ b/backend/src/services/NotificationService.ts @@ -17,6 +17,12 @@ export type NotificationCategory = | 'scan_finding' | 'system'; +export const ALL_NOTIFICATION_CATEGORIES: readonly NotificationCategory[] = [ + 'deploy_success', 'deploy_failure', 'stack_started', 'stack_stopped', + 'stack_restarted', 'image_update_available', 'image_update_applied', + 'autoheal_triggered', 'monitor_alert', 'scan_finding', 'system', +]; + /** Webhook timeout: 10 seconds per external dispatch call. */ const WEBHOOK_TIMEOUT_MS = 10_000; @@ -117,17 +123,23 @@ export class NotificationService { // 2. Push to connected browser clients via WebSocket this.broadcastToSubscribers(notification); - // 3. Check notification routing rules if a stack context is available + // 3. Check notification routing rules — always evaluated, matchers compose AND const errors: string[] = []; - if (stackName !== undefined) { + { const routes = this.dbService.getEnabledNotificationRoutes(); - const matched = routes.filter(r => - (r.node_id == null || r.node_id === localNodeId) && - r.stack_patterns.includes(stackName) - ); + 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; + }); if (matched.length > 0) { - if (isDebugEnabled()) console.log(`[Notify:diag] Matched ${matched.length} route(s) for stack "${stackName}"`); + if (isDebugEnabled()) console.log(`[Notify:diag] Matched ${matched.length} route(s) for stack "${stackName ?? '(none)'}", category="${category}"`); await Promise.allSettled( matched.map(route => this.sendToChannel(route.channel_type, route.channel_url, level, message) diff --git a/frontend/src/components/NotificationPanel.tsx b/frontend/src/components/NotificationPanel.tsx index a806b4e1..6e8d1d7f 100644 --- a/frontend/src/components/NotificationPanel.tsx +++ b/frontend/src/components/NotificationPanel.tsx @@ -22,6 +22,7 @@ import { import { cn } from '@/lib/utils'; import type { NotificationCategory, NotificationItem } from './dashboard/types'; import type { Node } from '@/context/NodeContext'; +import { CATEGORY_LABELS } from '@/lib/notificationCategories'; const NODE_FILTER_ALL = 'all' as const; const CATEGORY_FILTER_ALL = 'all' as const; @@ -29,20 +30,6 @@ type NotifFilter = 'all' | 'unread' | 'alerts'; type NodeFilter = typeof NODE_FILTER_ALL | number; type CategoryFilter = typeof CATEGORY_FILTER_ALL | NotificationCategory; -const CATEGORY_LABELS: Record = { - deploy_success: 'Deploy success', - deploy_failure: 'Deploy failure', - stack_started: 'Stack started', - stack_stopped: 'Stack stopped', - stack_restarted: 'Stack restarted', - image_update_available: 'Update available', - image_update_applied: 'Update applied', - autoheal_triggered: 'Auto-heal', - monitor_alert: 'Monitor alert', - scan_finding: 'Scan finding', - system: 'System', -}; - type LevelConfig = { icon: LucideIcon; iconClass: string; diff --git a/frontend/src/components/settings/NotificationRoutingSection.tsx b/frontend/src/components/settings/NotificationRoutingSection.tsx index e9e8531b..71a67c65 100644 --- a/frontend/src/components/settings/NotificationRoutingSection.tsx +++ b/frontend/src/components/settings/NotificationRoutingSection.tsx @@ -32,6 +32,9 @@ import { apiFetch } from '@/lib/api'; import { useNodes } from '@/context/NodeContext'; import { AdmiralGate } from '@/components/AdmiralGate'; import { CapabilityGate } from '@/components/CapabilityGate'; +import type { NotificationCategory } from '@/components/dashboard/types'; +import type { Label as StackLabel } from '@/components/label-types'; +import { CATEGORY_LABELS } from '@/lib/notificationCategories'; import { Plus, Trash2, Pencil, RefreshCw, Zap, X, Route } from 'lucide-react'; interface NotificationRoute { @@ -39,6 +42,8 @@ interface NotificationRoute { name: string; node_id: number | null; stack_patterns: string[]; + label_ids: number[] | null; + categories: NotificationCategory[] | null; channel_type: 'discord' | 'slack' | 'webhook'; channel_url: string; priority: number; @@ -69,11 +74,14 @@ export function NotificationRoutingSection() { const [editingId, setEditingId] = useState(null); const [testingId, setTestingId] = useState(null); const [stackOptions, setStackOptions] = useState([]); + const [labelOptions, setLabelOptions] = useState([]); // Form state const [formName, setFormName] = useState(''); const [formNodeId, setFormNodeId] = useState(null); const [formStacks, setFormStacks] = useState([]); + const [formLabelIds, setFormLabelIds] = useState([]); + const [formCategories, setFormCategories] = useState([]); const [formChannelType, setFormChannelType] = useState<'discord' | 'slack' | 'webhook'>('discord'); const [formChannelUrl, setFormChannelUrl] = useState(''); const [formPriority, setFormPriority] = useState(0); @@ -104,15 +112,27 @@ export function NotificationRoutingSection() { } }, []); + const fetchLabels = useCallback(async () => { + try { + const res = await apiFetch('/labels'); + if (res.ok) { + setLabelOptions(await res.json()); + } + } catch { + // Labels non-critical + } + }, []); + useEffect(() => { - fetchRoutes(); - fetchStacks(); - }, [fetchRoutes, fetchStacks]); + void Promise.all([fetchRoutes(), fetchStacks(), fetchLabels()]); + }, [fetchRoutes, fetchStacks, fetchLabels]); const resetForm = () => { setFormName(''); setFormNodeId(null); setFormStacks([]); + setFormLabelIds([]); + setFormCategories([]); setFormChannelType('discord'); setFormChannelUrl(''); setFormPriority(0); @@ -126,6 +146,8 @@ export function NotificationRoutingSection() { setFormName(route.name); setFormNodeId(route.node_id); setFormStacks([...route.stack_patterns]); + setFormLabelIds(route.label_ids ? [...route.label_ids] : []); + setFormCategories(route.categories ? [...route.categories] : []); setFormChannelType(route.channel_type); setFormChannelUrl(route.channel_url); setFormPriority(route.priority); @@ -135,7 +157,6 @@ export function NotificationRoutingSection() { const handleSave = async () => { if (!formName.trim()) { toast.error('Name is required.'); return; } - if (formStacks.length === 0) { toast.error('At least one stack must be selected.'); return; } if (!formChannelUrl.trim() || !formChannelUrl.startsWith('https://')) { toast.error('Channel URL must be a valid HTTPS URL.'); return; @@ -147,6 +168,8 @@ export function NotificationRoutingSection() { name: formName.trim(), node_id: formNodeId, stack_patterns: formStacks, + label_ids: formLabelIds.length > 0 ? formLabelIds : null, + categories: formCategories.length > 0 ? formCategories : null, channel_type: formChannelType, channel_url: formChannelUrl.trim(), priority: formPriority, @@ -235,7 +258,37 @@ export function NotificationRoutingSection() { setFormStacks(prev => prev.filter(s => s !== stackName)); }; + const addLabel = (idStr: string) => { + const id = Number(idStr); + if (!isNaN(id) && id > 0 && !formLabelIds.includes(id)) { + setFormLabelIds(prev => [...prev, id]); + } + }; + + const removeLabel = (id: number) => { + setFormLabelIds(prev => prev.filter(l => l !== id)); + }; + + const addCategory = (cat: string) => { + const c = cat as NotificationCategory; + if (c && !formCategories.includes(c)) { + setFormCategories(prev => [...prev, c]); + } + }; + + const removeCategory = (cat: NotificationCategory) => { + setFormCategories(prev => prev.filter(c => c !== cat)); + }; + const availableStackOptions = stackOptions.filter(o => !formStacks.includes(o.value)); + const availableLabelOptions = useMemo( + () => labelOptions.filter(l => !formLabelIds.includes(l.id)).map(l => ({ value: String(l.id), label: l.name })), + [labelOptions, formLabelIds], + ); + const availableCategoryOptions = useMemo( + () => (Object.keys(CATEGORY_LABELS) as NotificationCategory[]).filter(c => !formCategories.includes(c)).map(c => ({ value: c, label: CATEGORY_LABELS[c] })), + [formCategories], + ); return ( @@ -284,7 +337,7 @@ export function NotificationRoutingSection() {
- + +
+ + + {formLabelIds.length > 0 && ( +
+ {formLabelIds.map(id => { + const lbl = labelOptions.find(l => l.id === id); + return ( + + {lbl?.name ?? `Label ${id}`} + + + ); + })} +
+ )} +
+ +
+ + + {formCategories.length > 0 && ( +
+ {formCategories.map(c => ( + + {CATEGORY_LABELS[c]} + + + ))} +
+ )} +

Leave blank to match all categories. All non-empty filters must match (AND).

+
+
setFormChannelType(v as 'discord' | 'slack' | 'webhook')}> @@ -457,12 +570,24 @@ export function NotificationRoutingSection() {
-
-
- {route.stack_patterns.map(s => ( - {s} - ))} -
+
+ {route.stack_patterns.length > 0 && route.stack_patterns.map(s => ( + {s} + ))} + {route.label_ids && route.label_ids.length > 0 && route.label_ids.map(id => { + const lbl = labelOptions.find(l => l.id === id); + return ( + + {lbl?.name ?? `label:${id}`} + + ); + })} + {route.categories && route.categories.length > 0 && route.categories.map(c => ( + {CATEGORY_LABELS[c] ?? c} + ))} + {route.stack_patterns.length === 0 && (!route.label_ids || route.label_ids.length === 0) && (!route.categories || route.categories.length === 0) && ( + Matches all alerts + )} | {route.channel_url} diff --git a/frontend/src/lib/notificationCategories.ts b/frontend/src/lib/notificationCategories.ts new file mode 100644 index 00000000..c0d1d647 --- /dev/null +++ b/frontend/src/lib/notificationCategories.ts @@ -0,0 +1,15 @@ +import type { NotificationCategory } from '@/components/dashboard/types'; + +export const CATEGORY_LABELS: Record = { + deploy_success: 'Deploy success', + deploy_failure: 'Deploy failure', + stack_started: 'Stack started', + stack_stopped: 'Stack stopped', + stack_restarted: 'Stack restarted', + image_update_available: 'Update available', + image_update_applied: 'Update applied', + autoheal_triggered: 'Auto-heal', + monitor_alert: 'Monitor alert', + scan_finding: 'Scan finding', + system: 'System', +};