mirror of
https://github.com/Studio-Saelix/sencho.git
synced 2026-08-11 11:16:55 +00:00
feat(notifications): match routing rules by labels and categories (#776)
- Add label_ids and categories columns to notification_routes via idempotent migration - Matcher logic always evaluates routes (AND semantics across all non-empty matchers) - getStackLabelIds skips DB call when no enabled route uses label filtering - Extract ALL_NOTIFICATION_CATEGORIES array from NotificationService as single source of truth - Derive VALID_CATEGORIES set from the array in the route handler - Extract validateLabelIds and validateCategories helpers to remove POST/PUT duplication - Extract tryAddColumn as a private DatabaseService class method (removes 5 local re-declarations) - Extract CATEGORY_LABELS to frontend/src/lib/notificationCategories.ts (shared by NotificationPanel and NotificationRoutingSection) - Frontend form adds label and category multiselects with AND-filter hint - Route cards show label and category badges; empty-matcher routes show 'Matches all alerts' - Add tests for category-only, label-only, and combined AND-semantics routing
This commit is contained in:
@@ -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 () => {
|
||||
|
||||
@@ -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<string, unknown> = {}) {
|
||||
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',
|
||||
|
||||
@@ -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<NotificationCategory> = 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<void> => {
|
||||
@@ -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;
|
||||
|
||||
@@ -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, 'id'>): 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); }
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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<NotificationCategory, string> = {
|
||||
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;
|
||||
|
||||
@@ -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<number | null>(null);
|
||||
const [testingId, setTestingId] = useState<number | null>(null);
|
||||
const [stackOptions, setStackOptions] = useState<ComboboxOption[]>([]);
|
||||
const [labelOptions, setLabelOptions] = useState<StackLabel[]>([]);
|
||||
|
||||
// Form state
|
||||
const [formName, setFormName] = useState('');
|
||||
const [formNodeId, setFormNodeId] = useState<number | null>(null);
|
||||
const [formStacks, setFormStacks] = useState<string[]>([]);
|
||||
const [formLabelIds, setFormLabelIds] = useState<number[]>([]);
|
||||
const [formCategories, setFormCategories] = useState<NotificationCategory[]>([]);
|
||||
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<ComboboxOption[]>(
|
||||
() => labelOptions.filter(l => !formLabelIds.includes(l.id)).map(l => ({ value: String(l.id), label: l.name })),
|
||||
[labelOptions, formLabelIds],
|
||||
);
|
||||
const availableCategoryOptions = useMemo<ComboboxOption[]>(
|
||||
() => (Object.keys(CATEGORY_LABELS) as NotificationCategory[]).filter(c => !formCategories.includes(c)).map(c => ({ value: c, label: CATEGORY_LABELS[c] })),
|
||||
[formCategories],
|
||||
);
|
||||
|
||||
return (
|
||||
<AdmiralGate featureName="Notification Routing">
|
||||
@@ -284,7 +337,7 @@ export function NotificationRoutingSection() {
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label>Stacks</Label>
|
||||
<Label>Stacks <span className="text-muted-foreground font-normal text-xs">(optional)</span></Label>
|
||||
<Combobox
|
||||
options={availableStackOptions}
|
||||
value=""
|
||||
@@ -311,6 +364,66 @@ export function NotificationRoutingSection() {
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label>Labels <span className="text-muted-foreground font-normal text-xs">(optional)</span></Label>
|
||||
<Combobox
|
||||
options={availableLabelOptions}
|
||||
value=""
|
||||
onValueChange={addLabel}
|
||||
placeholder="Add a label..."
|
||||
searchPlaceholder="Search labels..."
|
||||
emptyText="No labels found."
|
||||
/>
|
||||
{formLabelIds.length > 0 && (
|
||||
<div className="flex flex-wrap gap-1.5 pt-1">
|
||||
{formLabelIds.map(id => {
|
||||
const lbl = labelOptions.find(l => l.id === id);
|
||||
return (
|
||||
<Badge key={id} variant="secondary" className="text-xs gap-1 pr-1">
|
||||
{lbl?.name ?? `Label ${id}`}
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => removeLabel(id)}
|
||||
className="ml-0.5 rounded-full hover:bg-foreground/10 p-0.5"
|
||||
>
|
||||
<X className="w-3 h-3" />
|
||||
</button>
|
||||
</Badge>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label>Categories <span className="text-muted-foreground font-normal text-xs">(optional)</span></Label>
|
||||
<Combobox
|
||||
options={availableCategoryOptions}
|
||||
value=""
|
||||
onValueChange={addCategory}
|
||||
placeholder="Add a category..."
|
||||
searchPlaceholder="Search categories..."
|
||||
emptyText="No categories found."
|
||||
/>
|
||||
{formCategories.length > 0 && (
|
||||
<div className="flex flex-wrap gap-1.5 pt-1">
|
||||
{formCategories.map(c => (
|
||||
<Badge key={c} variant="secondary" className="text-xs gap-1 pr-1">
|
||||
{CATEGORY_LABELS[c]}
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => removeCategory(c)}
|
||||
className="ml-0.5 rounded-full hover:bg-foreground/10 p-0.5"
|
||||
>
|
||||
<X className="w-3 h-3" />
|
||||
</button>
|
||||
</Badge>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
<p className="text-xs text-muted-foreground">Leave blank to match all categories. All non-empty filters must match (AND).</p>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label>Channel</Label>
|
||||
<Tabs value={formChannelType} onValueChange={(v) => setFormChannelType(v as 'discord' | 'slack' | 'webhook')}>
|
||||
@@ -457,12 +570,24 @@ export function NotificationRoutingSection() {
|
||||
</AlertDialog>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center gap-3 text-xs text-muted-foreground">
|
||||
<div className="flex flex-wrap gap-1">
|
||||
{route.stack_patterns.map(s => (
|
||||
<Badge key={s} variant="secondary" className="font-mono text-[10px]">{s}</Badge>
|
||||
))}
|
||||
</div>
|
||||
<div className="flex flex-wrap items-center gap-1.5 text-xs text-muted-foreground">
|
||||
{route.stack_patterns.length > 0 && route.stack_patterns.map(s => (
|
||||
<Badge key={s} variant="secondary" className="font-mono text-[10px]">{s}</Badge>
|
||||
))}
|
||||
{route.label_ids && route.label_ids.length > 0 && route.label_ids.map(id => {
|
||||
const lbl = labelOptions.find(l => l.id === id);
|
||||
return (
|
||||
<Badge key={id} variant="outline" className="text-[10px]">
|
||||
{lbl?.name ?? `label:${id}`}
|
||||
</Badge>
|
||||
);
|
||||
})}
|
||||
{route.categories && route.categories.length > 0 && route.categories.map(c => (
|
||||
<Badge key={c} variant="outline" className="text-[10px] font-mono">{CATEGORY_LABELS[c] ?? c}</Badge>
|
||||
))}
|
||||
{route.stack_patterns.length === 0 && (!route.label_ids || route.label_ids.length === 0) && (!route.categories || route.categories.length === 0) && (
|
||||
<span className="text-muted-foreground/50 text-[10px]">Matches all alerts</span>
|
||||
)}
|
||||
<span className="text-muted-foreground/50">|</span>
|
||||
<span className="font-mono truncate max-w-[200px]" title={route.channel_url}>
|
||||
{route.channel_url}
|
||||
|
||||
@@ -0,0 +1,15 @@
|
||||
import type { NotificationCategory } from '@/components/dashboard/types';
|
||||
|
||||
export const CATEGORY_LABELS: Record<NotificationCategory, string> = {
|
||||
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',
|
||||
};
|
||||
Reference in New Issue
Block a user