From 972f2b9483432648689ba01aa7a2d64bb0eed917 Mon Sep 17 00:00:00 2001 From: Anso Date: Sun, 19 Jul 2026 14:54:48 -0400 Subject: [PATCH] feat: stack glob patterns and route severity levels (#1651) * feat: add stack glob patterns and route severity levels Operators can filter notification routes and mute rules with anchored * globs, and routes can target info, warning, or error. Matching is fail-closed for unsafe stored patterns; write paths keep partial-PUT semantics and ReDoS caps. * test: split mute and routing chip tests to avoid dialog race * fix: move stack pattern client validator out of PatternChips * fix: bound stack glob matching and atomic pattern chip saves --- .../__tests__/apprise-secret-at-rest.test.ts | 3 + .../__tests__/notification-apprise.test.ts | 1 + .../__tests__/notification-channels.test.ts | 1 + .../__tests__/notification-matchers.test.ts | 30 ++++ ...otification-route-levels-migration.test.ts | 83 ++++++++++ .../__tests__/notification-routes-api.test.ts | 82 +++++++++ .../__tests__/notification-routing.test.ts | 48 ++++++ ...otification-suppression-routes-api.test.ts | 122 ++++++++++++++ .../notification-suppression.test.ts | 1 + backend/src/__tests__/stack-pattern.test.ts | 75 +++++++++ backend/src/helpers/notificationChannels.ts | 5 +- backend/src/helpers/notificationMatchers.ts | 4 +- backend/src/helpers/stackPattern.ts | 88 ++++++++++ backend/src/routes/fleet.ts | 18 +- backend/src/routes/notifications.ts | 89 ++++++---- backend/src/routes/security.ts | 2 +- backend/src/services/DatabaseService.ts | 20 ++- backend/src/utils/misconfig-ack-filter.ts | 4 +- docs/features/alerts-notifications.mdx | 23 +-- docs/reference/settings.mdx | 5 +- .../settings/NotificationRoutingSection.tsx | 111 ++++++++++--- .../NotificationSuppressionSection.tsx | 31 ++-- .../src/components/settings/PatternChips.tsx | 134 +++++++++++++++ .../NotificationRoutingSection.test.tsx | 156 ++++++++++++++++++ .../NotificationSuppressionSection.test.tsx | 132 +++++++++++++++ .../components/settings/stackPatternClient.ts | 8 + 26 files changed, 1158 insertions(+), 118 deletions(-) create mode 100644 backend/src/__tests__/notification-route-levels-migration.test.ts create mode 100644 backend/src/__tests__/stack-pattern.test.ts create mode 100644 backend/src/helpers/stackPattern.ts create mode 100644 frontend/src/components/settings/PatternChips.tsx create mode 100644 frontend/src/components/settings/__tests__/NotificationSuppressionSection.test.tsx create mode 100644 frontend/src/components/settings/stackPatternClient.ts diff --git a/backend/src/__tests__/apprise-secret-at-rest.test.ts b/backend/src/__tests__/apprise-secret-at-rest.test.ts index a93437ba..5a8a8888 100644 --- a/backend/src/__tests__/apprise-secret-at-rest.test.ts +++ b/backend/src/__tests__/apprise-secret-at-rest.test.ts @@ -89,6 +89,7 @@ describe('Apprise secrets at rest (downgrade-safe)', () => { stack_patterns: [], label_ids: null, categories: null, + levels: null, channel_type: 'apprise', channel_url: `http://apprise.local/notify/${keySecret}`, config: '{}', @@ -173,6 +174,7 @@ describe('Apprise secrets at rest (downgrade-safe)', () => { stack_patterns: [], label_ids: null, categories: null, + levels: null, channel_type: 'discord', channel_url: 'https://discord.com/api/webhooks/1/token', config: null, @@ -187,6 +189,7 @@ describe('Apprise secrets at rest (downgrade-safe)', () => { stack_patterns: [], label_ids: null, categories: null, + levels: null, channel_type: 'apprise', channel_url: 'http://apprise.local/notify/route-key', config: '{}', diff --git a/backend/src/__tests__/notification-apprise.test.ts b/backend/src/__tests__/notification-apprise.test.ts index 5669e2cb..503846dd 100644 --- a/backend/src/__tests__/notification-apprise.test.ts +++ b/backend/src/__tests__/notification-apprise.test.ts @@ -69,6 +69,7 @@ function makeAppriseRoute(overrides: Record = {}) { stack_patterns: ['my-app'], label_ids: null, categories: null, + levels: null, channel_type: 'apprise' as const, channel_url: KEYED, config: '{}', diff --git a/backend/src/__tests__/notification-channels.test.ts b/backend/src/__tests__/notification-channels.test.ts index 773d0487..e95c9c8d 100644 --- a/backend/src/__tests__/notification-channels.test.ts +++ b/backend/src/__tests__/notification-channels.test.ts @@ -134,6 +134,7 @@ describe('Apprise channel helpers', () => { stack_patterns: [], label_ids: null, categories: null, + levels: null, channel_type: 'apprise', channel_url: 'http://apprise.local/notify/path-secret', config: JSON.stringify({ tags: 'ops' }), diff --git a/backend/src/__tests__/notification-matchers.test.ts b/backend/src/__tests__/notification-matchers.test.ts index 8b00579f..72a04ae6 100644 --- a/backend/src/__tests__/notification-matchers.test.ts +++ b/backend/src/__tests__/notification-matchers.test.ts @@ -73,6 +73,36 @@ describe('notificationMatchers', () => { })).toBe(true); }); + it('matches stack globs with OR across patterns', () => { + expect(matchesNotificationFilters(baseCtx, { + node_id: null, + stack_patterns: ['prod-*', 'my-app'], + label_ids: null, + categories: null, + })).toBe(true); + expect(matchesNotificationFilters({ ...baseCtx, stackName: 'prod-web' }, { + node_id: null, + stack_patterns: ['prod-*'], + label_ids: null, + categories: null, + })).toBe(true); + expect(matchesNotificationFilters(baseCtx, { + node_id: null, + stack_patterns: ['prod-*'], + label_ids: null, + categories: null, + })).toBe(false); + }); + + it('fails closed on invalid stored stack patterns', () => { + expect(matchesNotificationFilters(baseCtx, { + node_id: null, + stack_patterns: ['****'], + label_ids: null, + categories: null, + })).toBe(false); + }); + 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); diff --git a/backend/src/__tests__/notification-route-levels-migration.test.ts b/backend/src/__tests__/notification-route-levels-migration.test.ts new file mode 100644 index 00000000..f1d2b87b --- /dev/null +++ b/backend/src/__tests__/notification-route-levels-migration.test.ts @@ -0,0 +1,83 @@ +/** + * Additive `levels` column on notification_routes. + * Exercises production DatabaseService startup against a pre-levels schema. + */ +import { describe, it, expect, afterEach } from 'vitest'; +import fs from 'fs'; +import os from 'os'; +import path from 'path'; +import Database from 'better-sqlite3'; +import { DatabaseService } from '../services/DatabaseService'; + +function resetDatabaseSingleton(): void { + const holder = DatabaseService as unknown as { instance?: DatabaseService }; + const existing = holder.instance; + if (existing) { + try { + existing.getDb().close(); + } catch { + // already closed + } + holder.instance = undefined; + } +} + +describe('notification route levels column migration', () => { + let scratchDir: string | null = null; + + afterEach(() => { + resetDatabaseSingleton(); + if (scratchDir) { + try { + fs.rmSync(scratchDir, { recursive: true, force: true }); + } catch { + // best-effort + } + scratchDir = null; + } + }); + + it('adds levels via DatabaseService startup; legacy rows load as null; reopen is idempotent', { timeout: 60_000 }, () => { + scratchDir = fs.mkdtempSync(path.join(os.tmpdir(), 'sencho-route-levels-mig-')); + const dbPath = path.join(scratchDir, 'sencho.db'); + const seed = new Database(dbPath); + try { + seed.exec(` + CREATE TABLE notification_routes ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + name TEXT NOT NULL, + channel_type TEXT NOT NULL, + channel_url TEXT NOT NULL, + stack_patterns TEXT NOT NULL DEFAULT '[]', + priority INTEGER NOT NULL DEFAULT 0, + enabled INTEGER NOT NULL DEFAULT 1, + created_at INTEGER NOT NULL, + updated_at INTEGER NOT NULL + ); + INSERT INTO notification_routes + (name, channel_type, channel_url, stack_patterns, priority, enabled, created_at, updated_at) + VALUES ('Legacy', 'slack', 'https://hooks.slack.com/services/legacy', '[]', 0, 1, 1, 1); + `); + } finally { + seed.close(); + } + + process.env.DATA_DIR = scratchDir; + resetDatabaseSingleton(); + const db = DatabaseService.getInstance(); + + const routeCols = db.getDb().prepare('PRAGMA table_info(notification_routes)').all() as Array<{ name: string }>; + expect(routeCols.filter((c) => c.name === 'levels')).toHaveLength(1); + + const route = db.getNotificationRoutes().find((r) => r.name === 'Legacy'); + expect(route).toBeDefined(); + expect(route!.levels).toBeNull(); + + resetDatabaseSingleton(); + process.env.DATA_DIR = scratchDir; + const db2 = DatabaseService.getInstance(); + const routeCols2 = db2.getDb().prepare('PRAGMA table_info(notification_routes)').all() as Array<{ name: string }>; + expect(routeCols2.filter((c) => c.name === 'levels')).toHaveLength(1); + expect(db2.getNotificationRoutes().find((r) => r.name === 'Legacy')!.levels).toBeNull(); + }); +}); diff --git a/backend/src/__tests__/notification-routes-api.test.ts b/backend/src/__tests__/notification-routes-api.test.ts index 87c8be1b..a6c44894 100644 --- a/backend/src/__tests__/notification-routes-api.test.ts +++ b/backend/src/__tests__/notification-routes-api.test.ts @@ -824,3 +824,85 @@ describe('GET /api/notifications - history', () => { expect(consoleErrorSpy).toHaveBeenCalledWith('Failed to clear notifications:', expect.any(Error)); }); }); + +describe('notification routes - glob patterns and levels', () => { + it('POST omits stack_patterns and levels to defaults', async () => { + const res = await request(app) + .post('/api/notification-routes') + .set('Cookie', authCookie) + .send({ + name: 'defaults', + channel_type: 'discord', + channel_url: 'https://discord.com/api/webhooks/1/abc', + }); + expect(res.status).toBe(201); + expect(res.body.stack_patterns).toEqual([]); + expect(res.body.levels).toBeNull(); + DatabaseService.getInstance().deleteNotificationRoute(res.body.id); + }); + + it('POST rejects null stack_patterns and ReDoS patterns', async () => { + const nullRes = await request(app) + .post('/api/notification-routes') + .set('Cookie', authCookie) + .send({ + name: 'bad-null', + stack_patterns: null, + channel_type: 'discord', + channel_url: 'https://discord.com/api/webhooks/1/abc', + }); + expect(nullRes.status).toBe(400); + + const redos = await request(app) + .post('/api/notification-routes') + .set('Cookie', authCookie) + .send({ + name: 'bad-redos', + stack_patterns: ['****'], + channel_type: 'discord', + channel_url: 'https://discord.com/api/webhooks/1/abc', + }); + expect(redos.status).toBe(400); + }); + + it('POST/GET/PUT levels round-trip; invalid levels 400', async () => { + const created = await request(app) + .post('/api/notification-routes') + .set('Cookie', authCookie) + .send({ + name: 'level-route', + stack_patterns: ['prod-*'], + levels: ['error'], + channel_type: 'discord', + channel_url: 'https://discord.com/api/webhooks/1/abc', + }); + expect(created.status).toBe(201); + expect(created.body.levels).toEqual(['error']); + expect(created.body.stack_patterns).toEqual(['prod-*']); + const id = created.body.id as number; + + const bad = await request(app) + .put(`/api/notification-routes/${id}`) + .set('Cookie', authCookie) + .send({ levels: ['critical'] }); + expect(bad.status).toBe(400); + + const partial = await request(app) + .put(`/api/notification-routes/${id}`) + .set('Cookie', authCookie) + .send({ name: 'level-route-renamed' }); + expect(partial.status).toBe(200); + expect(partial.body.levels).toEqual(['error']); + expect(partial.body.stack_patterns).toEqual(['prod-*']); + + const cleared = await request(app) + .put(`/api/notification-routes/${id}`) + .set('Cookie', authCookie) + .send({ levels: null, stack_patterns: [] }); + expect(cleared.status).toBe(200); + expect(cleared.body.levels).toBeNull(); + expect(cleared.body.stack_patterns).toEqual([]); + + DatabaseService.getInstance().deleteNotificationRoute(id); + }); +}); diff --git a/backend/src/__tests__/notification-routing.test.ts b/backend/src/__tests__/notification-routing.test.ts index 345e5ec1..275fbc6d 100644 --- a/backend/src/__tests__/notification-routing.test.ts +++ b/backend/src/__tests__/notification-routing.test.ts @@ -70,6 +70,7 @@ function makeRoute(overrides: Record = {}) { stack_patterns: ['my-app'], label_ids: null as number[] | null, categories: null as string[] | null, + levels: null as ('info' | 'warning' | 'error')[] | null, channel_type: 'discord' as const, channel_url: 'https://discord.com/api/webhooks/123/abc', priority: 0, @@ -370,6 +371,53 @@ describe('NotificationService - routing logic', () => { ); }); + it('routes by severity when levels is set; mismatches fall back to agents', async () => { + mockGetEnabledNotificationRoutes.mockReturnValue([ + makeRoute({ stack_patterns: [], levels: ['error'] }), + ]); + mockGetEnabledAgents.mockReturnValue([makeAgent()]); + + await svc.dispatchAlert('info', 'monitor_alert', 'Info only', { 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' }), + ); + + vi.clearAllMocks(); + mockGetEnabledNotificationRoutes.mockReturnValue([ + makeRoute({ stack_patterns: [], levels: ['error'] }), + ]); + mockGetEnabledAgents.mockReturnValue([makeAgent()]); + + await svc.dispatchAlert('error', 'monitor_alert', 'Error route', { stackName: 'my-app' }); + expect(mockFetch).toHaveBeenCalledWith( + 'https://discord.com/api/webhooks/123/abc', + expect.objectContaining({ method: 'POST' }), + ); + }); + + it('fails closed on invalid stored route patterns without blocking agent fallback', async () => { + mockGetEnabledNotificationRoutes.mockReturnValue([ + makeRoute({ stack_patterns: ['****'] }), + ]); + mockGetEnabledAgents.mockReturnValue([makeAgent()]); + + await svc.dispatchAlert('error', 'monitor_alert', 'Bad pattern', { 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 category-only route when category matches', async () => { mockGetEnabledNotificationRoutes.mockReturnValue([ makeRoute({ stack_patterns: [], categories: ['deploy_failure'] }), diff --git a/backend/src/__tests__/notification-suppression-routes-api.test.ts b/backend/src/__tests__/notification-suppression-routes-api.test.ts index f4334d50..75d344a8 100644 --- a/backend/src/__tests__/notification-suppression-routes-api.test.ts +++ b/backend/src/__tests__/notification-suppression-routes-api.test.ts @@ -173,4 +173,126 @@ describe('Notification suppression - CRUD', () => { .set('Cookie', authCookie); expect(res.status).toBe(200); }); + + it('POST omits stack_patterns to []; rejects malformed and ReDoS patterns', async () => { + const omitted = await request(app) + .post('/api/notification-suppression-rules') + .set('Cookie', authCookie) + .send({ + name: 'Mute omit', + applies_to: 'both', + }); + expect(omitted.status).toBe(201); + expect(omitted.body.stack_patterns).toEqual([]); + DatabaseService.getInstance().deleteNotificationSuppressionRule(omitted.body.id); + + const bad = await request(app) + .post('/api/notification-suppression-rules') + .set('Cookie', authCookie) + .send({ ...validBody, name: 'bad', stack_patterns: null }); + expect(bad.status).toBe(400); + + const redos = await request(app) + .post('/api/notification-suppression-rules') + .set('Cookie', authCookie) + .send({ ...validBody, name: 'redos', stack_patterns: ['****'] }); + expect(redos.status).toBe(400); + }); + + it('PUT enabled-only preserves patterns; explicit [] clears', async () => { + const created = await request(app) + .post('/api/notification-suppression-rules') + .set('Cookie', authCookie) + .send(validBody); + const id = created.body.id as number; + + const partial = await request(app) + .put(`/api/notification-suppression-rules/${id}`) + .set('Cookie', authCookie) + .send({ enabled: false }); + expect(partial.status).toBe(200); + expect(partial.body.enabled).toBe(false); + expect(partial.body.stack_patterns).toEqual(['staging']); + expect(partial.body.levels).toEqual(['warning']); + + const cleared = await request(app) + .put(`/api/notification-suppression-rules/${id}`) + .set('Cookie', authCookie) + .send({ stack_patterns: [] }); + expect(cleared.status).toBe(200); + expect(cleared.body.stack_patterns).toEqual([]); + + DatabaseService.getInstance().deleteNotificationSuppressionRule(id); + }); + + it('replica requires and validates stack_patterns', async () => { + const jwt = await import('jsonwebtoken'); + const { TEST_JWT_SECRET } = await import('./helpers/testConstants'); + const token = jwt.default.sign({ scope: 'node_proxy' }, TEST_JWT_SECRET, { expiresIn: '1m' }); + + const missing = await request(app) + .post('/api/notification-suppression-rules/replica') + .set('Authorization', `Bearer ${token}`) + .send({ + rule: { + id: 9001, + name: 'replica', + applies_to: 'both', + node_id: null, + label_ids: null, + categories: null, + levels: null, + enabled: true, + expires_at: null, + created_at: 1, + updated_at: 1, + }, + }); + expect(missing.status).toBe(400); + + const redos = await request(app) + .post('/api/notification-suppression-rules/replica') + .set('Authorization', `Bearer ${token}`) + .send({ + rule: { + id: 9001, + name: 'replica', + applies_to: 'both', + stack_patterns: ['****'], + node_id: null, + label_ids: null, + categories: null, + levels: null, + enabled: true, + expires_at: null, + created_at: 1, + updated_at: 1, + }, + }); + expect(redos.status).toBe(400); + expect(DatabaseService.getInstance().getNotificationSuppressionRule(9001)).toBeUndefined(); + + const ok = await request(app) + .post('/api/notification-suppression-rules/replica') + .set('Authorization', `Bearer ${token}`) + .send({ + rule: { + id: 9001, + name: 'replica', + applies_to: 'both', + stack_patterns: ['prod-*'], + node_id: null, + label_ids: null, + categories: null, + levels: null, + enabled: true, + expires_at: null, + created_at: 1, + updated_at: 1, + }, + }); + expect(ok.status).toBe(200); + expect(DatabaseService.getInstance().getNotificationSuppressionRule(9001)?.stack_patterns).toEqual(['prod-*']); + DatabaseService.getInstance().deleteNotificationSuppressionRule(9001); + }); }); diff --git a/backend/src/__tests__/notification-suppression.test.ts b/backend/src/__tests__/notification-suppression.test.ts index f465f7f0..fade91e1 100644 --- a/backend/src/__tests__/notification-suppression.test.ts +++ b/backend/src/__tests__/notification-suppression.test.ts @@ -84,6 +84,7 @@ function makeRoute() { stack_patterns: ['my-app'], label_ids: null, categories: null, + levels: null, channel_type: 'discord' as const, channel_url: 'https://discord.com/api/webhooks/123/abc', priority: 0, diff --git a/backend/src/__tests__/stack-pattern.test.ts b/backend/src/__tests__/stack-pattern.test.ts new file mode 100644 index 00000000..5809ac29 --- /dev/null +++ b/backend/src/__tests__/stack-pattern.test.ts @@ -0,0 +1,75 @@ +import { describe, it, expect } from 'vitest'; +import { + stackPatternMatches, + validateStackPatternForRedos, + parseStackPatternsInput, + cleanStackPatterns, +} from '../helpers/stackPattern'; + +describe('stackPattern', () => { + it('matches exact names without wildcards', () => { + expect(stackPatternMatches('prod-api', 'prod-api')).toBe(true); + expect(stackPatternMatches('Prod-api', 'prod-api')).toBe(false); + }); + + it('matches anchored case-sensitive globs', () => { + expect(stackPatternMatches('prod-api', 'prod-*')).toBe(true); + expect(stackPatternMatches('staging-api', 'prod-*')).toBe(false); + expect(stackPatternMatches('prod-api-extra', 'prod-*')).toBe(true); + expect(stackPatternMatches('xprod-api', 'prod-*')).toBe(false); + }); + + it('treats ? and regex metacharacters as literal', () => { + expect(stackPatternMatches('a?b', 'a?b')).toBe(true); + expect(stackPatternMatches('ab', 'a?b')).toBe(false); + expect(stackPatternMatches('a.b', 'a.b')).toBe(true); + expect(stackPatternMatches('axb', 'a.b')).toBe(false); + expect(stackPatternMatches('a+b', 'a+b')).toBe(true); + }); + + it('fails closed on write-cap rejected patterns without throwing', () => { + expect(() => stackPatternMatches('anything', '****')).not.toThrow(); + expect(stackPatternMatches('anything', '****')).toBe(false); + expect(stackPatternMatches('x', 'a'.repeat(201))).toBe(false); + expect(stackPatternMatches('x', `${'a*'.repeat(9)}`)).toBe(false); + }); + + it('matches accepted separated-star patterns without RegExp backtracking', () => { + const pattern = '*a*a*a*a*a*a*a*a'; + expect(validateStackPatternForRedos(pattern)).toBeNull(); + expect(stackPatternMatches('aaaaaaaa', pattern)).toBe(true); + const nonMatch = `${'a'.repeat(80)}b`; + const started = performance.now(); + expect(stackPatternMatches(nonMatch, pattern)).toBe(false); + expect(performance.now() - started).toBeLessThan(50); + }); + + it('OR semantics are call-site: any pattern may match', () => { + const patterns = ['staging-*', 'prod-api']; + expect(patterns.some((p) => stackPatternMatches('prod-api', p))).toBe(true); + expect(patterns.some((p) => stackPatternMatches('staging-web', p))).toBe(true); + expect(patterns.some((p) => stackPatternMatches('dev-web', p))).toBe(false); + }); + + it('validateStackPatternForRedos rejects unsafe inputs', () => { + expect(validateStackPatternForRedos('ok-*')).toBeNull(); + expect(validateStackPatternForRedos('****')).toMatch(/consecutive/); + expect(validateStackPatternForRedos('a'.repeat(201))).toMatch(/too long/); + expect(validateStackPatternForRedos('*a*a*a*a*a*a*a*a')).toBeNull(); + }); + + it('parseStackPatternsInput rejects non-arrays and non-strings', () => { + expect(parseStackPatternsInput(null).ok).toBe(false); + expect(parseStackPatternsInput('prod-*').ok).toBe(false); + expect(parseStackPatternsInput([1]).ok).toBe(false); + expect(parseStackPatternsInput(['prod-*', '****']).ok).toBe(false); + expect(parseStackPatternsInput([' prod-* ', 'prod-*'])).toEqual({ + ok: true, + patterns: ['prod-*'], + }); + }); + + it('cleanStackPatterns trims, drops blanks, dedupes', () => { + expect(cleanStackPatterns([' a ', '', 'a', 'b'])).toEqual(['a', 'b']); + }); +}); diff --git a/backend/src/helpers/notificationChannels.ts b/backend/src/helpers/notificationChannels.ts index 80afa2ac..ea3accad 100644 --- a/backend/src/helpers/notificationChannels.ts +++ b/backend/src/helpers/notificationChannels.ts @@ -1,5 +1,7 @@ import type { Agent, NotificationRoute } from '../services/DatabaseService'; +export { cleanStackPatterns } from './stackPattern'; + export const NOTIFICATION_CHANNEL_TYPES = ['discord', 'slack', 'webhook', 'apprise'] as const; export type NotificationChannelType = typeof NOTIFICATION_CHANNEL_TYPES[number]; @@ -49,9 +51,6 @@ function notifyKeyFromPath(path: string): string | null { return match ? match[1] : null; } -export const cleanStackPatterns = (patterns: string[]): string[] => - [...new Set(patterns.map(p => p.trim()).filter(Boolean))]; - export function validateHttpsUrl(value: unknown): string | null { if (!value || typeof value !== 'string' || !value.startsWith('https://')) return 'must be a valid HTTPS URL'; try { new URL(value); } catch { return 'is not a valid URL'; } diff --git a/backend/src/helpers/notificationMatchers.ts b/backend/src/helpers/notificationMatchers.ts index 75c8deb7..eae9f208 100644 --- a/backend/src/helpers/notificationMatchers.ts +++ b/backend/src/helpers/notificationMatchers.ts @@ -1,4 +1,5 @@ import type { NotificationCategory } from '../services/NotificationService'; +import { stackPatternMatches } from './stackPattern'; export type NotificationLevel = 'info' | 'warning' | 'error'; export type NotificationAppliesTo = 'bell' | 'external' | 'both'; @@ -27,7 +28,8 @@ export function matchesNotificationFilters( 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)) + && (ctx.stackName === undefined + || !rule.stack_patterns.some((pattern) => stackPatternMatches(ctx.stackName!, pattern))) ) { return false; } diff --git a/backend/src/helpers/stackPattern.ts b/backend/src/helpers/stackPattern.ts new file mode 100644 index 00000000..68050012 --- /dev/null +++ b/backend/src/helpers/stackPattern.ts @@ -0,0 +1,88 @@ +/** + * Canonical stack-name glob matching and write-boundary validation. + * + * Glob only: `*` is the sole wildcard; `?` and regex metacharacters are literal. + * Matching is case-sensitive and anchored. Matching never uses RegExp so + * accepted patterns cannot trigger catastrophic backtracking. + * + * Write-time caps still reject extreme patterns (length, star count, runs of + * consecutive stars). Invalid patterns fail closed at match time (no match). + */ + +export function validateStackPatternForRedos(pattern: string): string | null { + if (pattern.length > 200) return 'stack_pattern is too long'; + const stars = (pattern.match(/\*/g) ?? []).length; + if (stars > 8) return 'stack_pattern has too many wildcards (max 8)'; + if (/\*{4,}/.test(pattern)) return 'stack_pattern must not contain 4+ consecutive wildcards'; + return null; +} + +export function cleanStackPatterns(patterns: string[]): string[] { + return [...new Set(patterns.map((p) => p.trim()).filter(Boolean))]; +} + +export type ParseStackPatternsResult = + | { ok: true; patterns: string[] } + | { ok: false; error: string }; + +/** Validate a present `stack_patterns` value. Callers must not invoke this for omitted keys. */ +export function parseStackPatternsInput(raw: unknown): ParseStackPatternsResult { + if (!Array.isArray(raw)) { + return { ok: false, error: 'stack_patterns must be an array of strings' }; + } + if (raw.some((p) => typeof p !== 'string')) { + return { ok: false, error: 'stack_patterns must be an array of strings' }; + } + const patterns = cleanStackPatterns(raw as string[]); + for (const pattern of patterns) { + const err = validateStackPatternForRedos(pattern); + if (err) return { ok: false, error: err }; + } + return { ok: true, patterns }; +} + +/** + * Bounded glob match: work is linear in name length times star count (no RegExp). + * `*` matches any substring including empty; all other characters are literal. + */ +function globMatchBounded(name: string, pattern: string): boolean { + let ni = 0; + let pi = 0; + let starP = -1; + let starN = -1; + const nLen = name.length; + const pLen = pattern.length; + + while (ni < nLen) { + if (pi < pLen && pattern[pi] !== '*' && pattern[pi] === name[ni]) { + ni += 1; + pi += 1; + continue; + } + if (pi < pLen && pattern[pi] === '*') { + starP = pi; + starN = ni; + pi += 1; + continue; + } + if (starP !== -1) { + pi = starP + 1; + starN += 1; + ni = starN; + continue; + } + return false; + } + + while (pi < pLen && pattern[pi] === '*') pi += 1; + return pi === pLen; +} + +/** + * True when `name` matches the stack glob. Patterns rejected by write-time + * caps return false without throwing so dispatch and policy evaluation stay safe. + */ +export function stackPatternMatches(name: string, pattern: string): boolean { + if (validateStackPatternForRedos(pattern) !== null) return false; + return globMatchBounded(name, pattern); +} diff --git a/backend/src/routes/fleet.ts b/backend/src/routes/fleet.ts index b78ca283..74a4caea 100644 --- a/backend/src/routes/fleet.ts +++ b/backend/src/routes/fleet.ts @@ -29,6 +29,9 @@ import { captureLocalNodeFiles, captureRemoteNodeFiles, buildSnapshotDocumentati import { getLatestVersion, getLatestRelease } from '../utils/version-check'; import { isValidStackName } from '../utils/validation'; import { isDebugEnabled } from '../utils/debug'; +import { validateStackPatternForRedos } from '../helpers/stackPattern'; + +export { validateStackPatternForRedos } from '../helpers/stackPattern'; import { getErrorMessage } from '../utils/errors'; import { parseIntParam } from '../utils/parseIntParam'; import { parseRequestedTargetVersion, pickCompareTarget } from '../utils/targetVersion'; @@ -170,21 +173,8 @@ function validateScanPolicyRow(row: unknown): string | null { /** * Reject `stack_pattern` inputs that would compile to a backtracking-prone - * regex. The matcher in `getMatchingPolicy` substitutes `*` with `.*`, so a - * pattern like `***...` becomes a chain of adjacent `.*` runs that exhibit - * catastrophic backtracking on long inputs. - * - * Caps mirror the limit in routes/security.ts so a control creating a policy - * sees the same error as a replica receiving one. Length is gated at 200 by - * the surrounding row validator. + * regex. Implementation lives in helpers/stackPattern.ts (re-exported above). */ -export function validateStackPatternForRedos(pattern: string): string | null { - if (pattern.length > 200) return 'stack_pattern is too long'; - const stars = (pattern.match(/\*/g) ?? []).length; - if (stars > 8) return 'stack_pattern has too many wildcards (max 8)'; - if (/\*{4,}/.test(pattern)) return 'stack_pattern must not contain 4+ consecutive wildcards'; - return null; -} function validateCveSuppressionRow(row: unknown): string | null { if (!row || typeof row !== 'object') return 'row must be an object'; diff --git a/backend/src/routes/notifications.ts b/backend/src/routes/notifications.ts index e83fda05..fc7b8b60 100644 --- a/backend/src/routes/notifications.ts +++ b/backend/src/routes/notifications.ts @@ -9,7 +9,6 @@ import { NOTIFICATION_CHANNEL_TYPES, serializePublicNotificationRoute, validateNotificationChannel, - cleanStackPatterns, maskWebhookUrl, normalizeAppriseStoredJson, parseStoredAppriseConfig, @@ -17,6 +16,7 @@ import { resolvePreservedAppriseConfig, storedAppriseToWriteConfig, } from '../helpers/notificationChannels'; +import { parseStackPatternsInput } from '../helpers/stackPattern'; import { deleteSuppressionRuleFromFleet, syncSuppressionRuleToFleet, @@ -109,6 +109,28 @@ function validateExpiresAt(expires_at: unknown, res: Response): number | null | return expires_at; } +function normalizeStoredLevels(levels: unknown): ('info' | 'warning' | 'error')[] | null { + if (!Array.isArray(levels) || levels.length === 0) return null; + return levels as ('info' | 'warning' | 'error')[]; +} + +/** Resolve stack_patterns with presence semantics. Returns false after sending 400. */ +function resolveStackPatternsField( + stack_patterns: unknown, + opts: { isCreate: boolean }, + res: Response, +): string[] | undefined | false { + if (stack_patterns === undefined) { + return opts.isCreate ? [] : undefined; + } + const parsed = parseStackPatternsInput(stack_patterns); + if (!parsed.ok) { + res.status(400).json({ error: parsed.error }); + return false; + } + return parsed.patterns; +} + function parseSuppressionRuleBody( req: Request, res: Response, @@ -144,16 +166,8 @@ function parseSuppressionRuleBody( : 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 = []; - } + const cleanedPatterns = resolveStackPatternsField(stack_patterns, { isCreate }, res); + if (cleanedPatterns === false) return null; if (!validateLabelIds(label_ids, res)) return null; if (!validateCategories(categories, res, VALID_SUPPRESSION_CATEGORIES)) return null; @@ -180,7 +194,7 @@ function parseSuppressionRuleBody( 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, + levels: normalizeStoredLevels(levels), applies_to: (appliesToResult ?? 'both') as NotificationSuppressionAppliesTo, enabled: enabled !== false, expires_at: expiresAtResult ?? null, @@ -282,7 +296,7 @@ notificationRoutesRouter.get('/', authMiddleware, (req: Request, res: Response): notificationRoutesRouter.post('/', authMiddleware, async (req: Request, res: Response): Promise => { if (!requireAdmin(req, res)) return; try { - const { name, node_id: rawNodeId, stack_patterns, label_ids, categories, channel_type, channel_url, config, priority, enabled } = req.body; + const { name, node_id: rawNodeId, stack_patterns, label_ids, categories, levels, channel_type, channel_url, config, priority, enabled } = req.body; if (!name || typeof name !== 'string' || !name.trim()) { res.status(400).json({ error: 'Name is required' }); @@ -294,13 +308,11 @@ notificationRoutesRouter.post('/', authMiddleware, async (req: Request, res: Res } const nodeIdResult = validateNodeId(rawNodeId, res); if (nodeIdResult === false) return; - 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; - } + const cleanedPatterns = resolveStackPatternsField(stack_patterns, { isCreate: true }, res); + if (cleanedPatterns === false) return; if (!validateLabelIds(label_ids, res)) return; if (!validateCategories(categories, res)) return; + if (!validateLevels(levels, 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; @@ -316,9 +328,10 @@ notificationRoutesRouter.post('/', authMiddleware, async (req: Request, res: Res const route = DatabaseService.getInstance().createNotificationRoute({ name: name.trim(), node_id: nodeIdResult, - stack_patterns: cleanedPatterns, + 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, + levels: normalizeStoredLevels(levels), channel_type, channel_url: channel_url.trim(), config: channel_type === 'apprise' ? normalizeAppriseStoredJson(channel_url.trim(), config) : null, @@ -328,7 +341,7 @@ notificationRoutesRouter.post('/', authMiddleware, async (req: Request, res: Res updated_at: now, }); console.log(`[Routes] Route "${sanitizeForLog(route.name)}" created (id=${route.id})`); - if (isDebugEnabled()) console.log(`[Routes:diag] Route "${sanitizeForLog(route.name)}" created with patterns=[${sanitizeForLog(cleanedPatterns.join(', '))}], channel=${channel_type}`); + if (isDebugEnabled()) console.log(`[Routes:diag] Route "${sanitizeForLog(route.name)}" created with patterns=[${sanitizeForLog((cleanedPatterns ?? []).join(', '))}], channel=${channel_type}`); res.status(201).json(serializePublicNotificationRoute(route)); } catch (error) { console.error('Failed to create notification route:', error); @@ -345,7 +358,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, label_ids, categories, channel_type, channel_url, config, priority, enabled } = req.body; + const { name, node_id: rawNodeId, stack_patterns, label_ids, categories, levels, channel_type, channel_url, config, priority, enabled } = req.body; if (name !== undefined && (typeof name !== 'string' || !name.trim())) { res.status(400).json({ error: 'Name must be a non-empty string' }); @@ -361,16 +374,11 @@ notificationRoutesRouter.put('/:id', authMiddleware, async (req: Request, res: R 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); - } + const cleanedPatterns = resolveStackPatternsField(stack_patterns, { isCreate: false }, res); + if (cleanedPatterns === false) return; if (!validateLabelIds(label_ids, res)) return; if (!validateCategories(categories, res)) return; + if ('levels' in req.body && !validateLevels(levels, 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; @@ -413,6 +421,7 @@ notificationRoutesRouter.put('/:id', authMiddleware, async (req: Request, res: R 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 = normalizeStoredLevels(levels); if (channel_type !== undefined) updates.channel_type = channel_type; if (channel_url !== undefined || typeChanged) updates.channel_url = effectiveUrl; if (effectiveType === 'apprise') updates.config = normalizeAppriseStoredJson(effectiveUrl, effectiveConfig); @@ -490,7 +499,19 @@ notificationSuppressionRouter.post('/replica', authMiddleware, (req: Request, re res.status(400).json({ error: 'Invalid applies_to on rule' }); return; } - DatabaseService.getInstance().upsertNotificationSuppressionRuleReplica(rule); + if (!('stack_patterns' in rule)) { + res.status(400).json({ error: 'stack_patterns is required on replica rule' }); + return; + } + const patterns = parseStackPatternsInput(rule.stack_patterns); + if (!patterns.ok) { + res.status(400).json({ error: patterns.error }); + return; + } + DatabaseService.getInstance().upsertNotificationSuppressionRuleReplica({ + ...rule, + stack_patterns: patterns.patterns, + }); res.json({ success: true }); } catch (error) { console.error('Failed to apply suppression rule replica:', error); @@ -582,11 +603,9 @@ notificationSuppressionRouter.put('/:id', authMiddleware, (req: Request, res: Re 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); + const resolved = resolveStackPatternsField(stack_patterns, { isCreate: false }, res); + if (resolved === false) return; + cleanedPatterns = resolved; } if (!validateLabelIds(label_ids, res)) return; diff --git a/backend/src/routes/security.ts b/backend/src/routes/security.ts index c60893be..29a2392f 100644 --- a/backend/src/routes/security.ts +++ b/backend/src/routes/security.ts @@ -20,7 +20,7 @@ import { sanitizeForLog } from '../utils/safeLog'; import { getErrorMessage } from '../utils/errors'; import { isDebugEnabled } from '../utils/debug'; import { blockIfReplica } from '../middleware/fleetSyncGuards'; -import { validateStackPatternForRedos } from './fleet'; +import { validateStackPatternForRedos } from '../helpers/stackPattern'; import { FINDING_SEVERITIES, POLICY_SEVERITIES } from '../utils/severity'; import { isNoOpBlockingPolicy } from '../utils/policy-risk'; import { DEFAULT_POLICY_PACKS } from '../services/policy-packs'; diff --git a/backend/src/services/DatabaseService.ts b/backend/src/services/DatabaseService.ts index afa394b3..c01182c2 100644 --- a/backend/src/services/DatabaseService.ts +++ b/backend/src/services/DatabaseService.ts @@ -9,6 +9,7 @@ import type { AuditStatsInput } from './AuditAnomalyService'; import { EXPOSURE_INTENTS, type ExposureIntent } from './network/types'; import { HIGH_EPSS_THRESHOLD } from './securityPosture'; import type { BackendScheduledAction } from './scheduledActionRegistry'; +import { stackPatternMatches } from '../helpers/stackPattern'; function isPilotMode(): boolean { return process.env.SENCHO_MODE === 'pilot'; @@ -685,6 +686,7 @@ export interface NotificationRoute { stack_patterns: string[]; label_ids: number[] | null; categories: string[] | null; + levels: ('info' | 'warning' | 'error')[] | null; channel_type: 'discord' | 'slack' | 'webhook' | 'apprise'; channel_url: string; config?: string | null; @@ -983,6 +985,7 @@ export class DatabaseService { this.migrateNotificationRoutesNodeId(); this.migrateNotificationRoutesMatchers(); this.migrateNotificationChannelConfig(); + this.migrateNotificationRouteLevels(); this.migrateNotificationSuppressionRules(); this.migrateNotificationHistoryContext(); this.migrateScanPolicyFleetColumns(); @@ -2078,6 +2081,10 @@ export class DatabaseService { this.tryAddColumn('notification_routes', 'config', 'TEXT NULL'); } + private migrateNotificationRouteLevels(): void { + this.tryAddColumn('notification_routes', 'levels', 'TEXT NULL'); + } + private migrateNotificationSuppressionRules(): void { this.db.exec(` CREATE TABLE IF NOT EXISTS notification_suppression_rules ( @@ -2568,6 +2575,7 @@ export class DatabaseService { 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, channel_type, channel_url: fields.url, config: fields.config, @@ -2605,13 +2613,14 @@ export class DatabaseService { public createNotificationRoute(route: Omit): NotificationRoute { const stored = this.storeAppriseFields(route.channel_type === 'apprise', route.channel_url, route.config); const result = this.db.prepare( - 'INSERT INTO notification_routes (name, node_id, stack_patterns, label_ids, categories, channel_type, channel_url, config, priority, enabled, created_at, updated_at) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)' + 'INSERT INTO notification_routes (name, node_id, stack_patterns, label_ids, categories, levels, channel_type, channel_url, config, 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.levels && route.levels.length > 0 ? JSON.stringify(route.levels) : null, route.channel_type, stored.url, stored.config, @@ -2635,6 +2644,10 @@ export class DatabaseService { 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 && updates.levels.length > 0 ? JSON.stringify(updates.levels) : null); + } if (updates.channel_type !== undefined) { fields.push('channel_type = ?'); values.push(updates.channel_type); } if (updates.channel_url !== undefined) { fields.push('channel_url = ?'); @@ -6396,10 +6409,7 @@ export class DatabaseService { const matchesStack = (pattern: string | null): boolean => { if (!pattern) return true; if (!stackName) return false; - const regex = new RegExp( - '^' + pattern.replace(/[.+?^${}()|[\]\\]/g, '\\$&').replace(/\*/g, '.*') + '$', - ); - return regex.test(stackName); + return stackPatternMatches(stackName, pattern); }; const matchesIdentity = (p: ScanPolicy): boolean => { // Locally created policies (never replicated) apply based on node_id logic already filtered. diff --git a/backend/src/utils/misconfig-ack-filter.ts b/backend/src/utils/misconfig-ack-filter.ts index bf8003c7..03de2b7c 100644 --- a/backend/src/utils/misconfig-ack-filter.ts +++ b/backend/src/utils/misconfig-ack-filter.ts @@ -14,6 +14,7 @@ * cross-multiply with the fleet ack list on every render. */ import type { MisconfigAcknowledgement } from '../services/DatabaseService'; +import { stackPatternMatches } from '../helpers/stackPattern'; export interface MisconfigAcknowledgementDecision { acknowledged: boolean; @@ -31,8 +32,7 @@ function matchesStackPattern(pattern: string | null, stackContext: string | null if (!pattern) return true; // Stack-scoped acks against an image scan (no stack_context) cannot match. if (stackContext === null) return false; - const escaped = pattern.replace(/[.+?^${}()|[\]\\]/g, '\\$&').replace(/\*/g, '.*'); - return new RegExp(`^${escaped}$`).test(stackContext); + return stackPatternMatches(stackContext, pattern); } function isActive(ack: MisconfigAcknowledgement, now: number): boolean { diff --git a/docs/features/alerts-notifications.mdx b/docs/features/alerts-notifications.mdx index 906f43fb..4987b5a9 100644 --- a/docs/features/alerts-notifications.mdx +++ b/docs/features/alerts-notifications.mdx @@ -63,33 +63,26 @@ Each dispatch is a single-shot HTTP POST with a 10-second `AbortSignal.timeout`. Routing lets you direct alerts that match specific criteria to dedicated channels. Production crashes can land in `#prod-incidents` on Slack while staging notifications go to a less urgent Discord channel, all without juggling per-channel webhook URLs across teams. - - Settings · Notifications · Notification Routing card list showing two rules ('Critical to Slack' and 'Production alerts'), each with a Discord badge, an ON pill, the 'Matches all alerts' summary line, a truncated channel URL, a Priority chip, and lightning-test, edit, and delete icon actions on the right. - - ### How routing fits into dispatch -For every alert Sencho dispatches, the routing engine evaluates every enabled route. A route matches when **all of its non-empty matchers** match the alert: the **Node**, **Stacks**, **Labels**, and **Categories** filters compose with AND. An empty matcher is treated as match-anything. +For every alert Sencho dispatches, the routing engine evaluates every enabled route. A route matches when **all of its non-empty matchers** match the alert: the **Node**, **Stacks**, **Labels**, **Categories**, and **Severity** filters compose with AND. An empty matcher is treated as match-anything. If at least one route matches, every matching route fires and the **global channels are skipped** for that alert. If zero routes match, the alert falls back to the global channels configured in the previous section. -A route with all four matchers left empty matches every alert and intercepts global delivery entirely. Stack-less alerts (host CPU, RAM, disk, fleet-sync warnings, and scheduled-task results without a stack target) almost always miss any populated `Stacks` filter, so they fall back to global by default. +A route with all five matchers left unconstrained (`Node` = any, and empty Stacks, Labels, Categories, and Severity) matches every alert and intercepts global delivery entirely. A route scoped to a node with the other four matchers empty matches every alert on that node. Stack-less alerts (host CPU, RAM, disk, fleet-sync warnings, and scheduled-task results without a stack target) almost always miss any populated `Stacks` filter, so they fall back to global by default. ### Creating a routing rule Open **Settings · Notifications · Notification Routing** and click **+ Add Route**. - - The New routing rule modal with empty Name, Node scope set to Any node, empty Stacks, Labels, and Categories fields, Channel tabs with Apprise selected, and Cancel and CREATE actions. Priority and Enabled may be scrolled out of the cropped frame. - - | Field | Purpose | |-------|---------| | **Name** | A human label, up to 100 characters. Shown on the rule card. | | **Node scope** | Either `Any node` or a specific node. When set to a node, the rule only matches alerts originating from that node. | -| **Stacks** *(optional)* | A combobox of stacks on the active node. Selected stacks appear as removable pills. Empty matches any stack. | +| **Stacks** *(optional)* | Free-form stack patterns with `*` as the only wildcard (for example `prod-*`), plus an optional picker that inserts a known exact stack name as a chip. Empty matches any stack. | | **Labels** *(optional)* | A combobox of stack labels on the active node. Empty matches any label. | | **Categories** *(optional)* | A combobox of notification categories. The helper line reads `Leave blank to match all categories. All non-empty filters must match (AND).` | +| **Severity** *(optional)* | One or more of info, warning, or error. Empty matches any severity. | | **Channel** | Tabs for Discord, Slack, Webhook, and Apprise. Discord, Slack, and Webhook require HTTPS. Apprise accepts HTTP or HTTPS. | | **Priority** | A number used to sort the rule list. Lower numbers appear higher up. Priority does not gate dispatch: when multiple rules match the same alert, every matching rule fires concurrently. | | **Enabled** | Toggle the rule on or off without deleting it. | @@ -98,7 +91,7 @@ The modal kicker reads `ROUTING · NEW RULE` when adding and `ROUTING · EDIT RU ### Managing rules -Each rule renders as a card on the Routing page with the rule name, channel-type badge, an `ON` / `OFF` pill, then a row of small badges naming each matcher: one mono badge per **Stack**, one outline badge per **Label**, one outline mono badge per **Category** label. When all matchers are empty, a single muted `Matches all alerts` line replaces the badge row. After the badges, a vertical bar separator is followed by the truncated channel URL, then a second separator and a `Priority: N` chip when priority is non-zero. +Each rule renders as a card on the Routing page with the rule name, channel-type badge, optional node badge, an `ON` / `OFF` pill, then a row of small badges naming each matcher: one mono badge per **Stack** pattern, one outline badge per **Label**, one outline mono badge per **Category**, and one outline badge per **Severity**. When Node is any and the other four matchers are empty, a muted `Matches all alerts` line replaces the badge row. When the rule is node-scoped and the other four are empty, the card shows `Matches all alerts on this node` instead. After the badges, a vertical bar separator is followed by the truncated channel URL, then a second separator and a `Priority: N` chip when priority is non-zero. Three icon actions appear on the right edge of each card: @@ -122,7 +115,7 @@ Open **Settings · Notifications · Mute Rules** and click **+ Add mute rule**. |-------|---------| | **Name** | A human label, up to 100 characters. | | **Node scope** | `Any node` or a specific fleet node. Limits which node emits the alert before the rule can match. | -| **Stacks** *(optional)* | Stack names. Empty matches any stack. | +| **Stacks** *(optional)* | Free-form stack patterns with `*` as the only wildcard (for example `prod-*`), plus an optional known-stack picker. Empty matches any stack. | | **Labels** *(optional)* | Stack labels. Empty matches any label. | | **Categories** *(optional)* | Notification categories. Empty matches any category. | | **Severity** *(optional)* | One or more of info, warning, or error. Empty matches any severity. | @@ -440,7 +433,7 @@ Switching the active node tears down per-stack rule editors and reloads channel - Check three things in order. First, the channel toggle in **Settings · Notifications · Channels** must be on; the kicker on each tab reads `enabled` or `off`. Second, Discord, Slack, and webhook URLs must use HTTPS (the form rejects plain `http://` for those channels); Apprise endpoints may use HTTP or HTTPS. Third, a routing rule with empty `Stacks`, `Labels`, and `Categories` matchers will intercept every alert and skip the global channels. Use the per-channel **Test** button to issue a one-shot dispatch and watch your endpoint for the literal message `🔌 Test Notification from Sencho!` Sencho records the failure reason in `notification_history.dispatch_error` when delivery throws, so a row that appears in the bell with no follow-up at the endpoint usually means a 4xx or timeout at the receiver. + Check three things in order. First, the channel toggle in **Settings · Notifications · Channels** must be on; the kicker on each tab reads `enabled` or `off`. Second, Discord, Slack, and webhook URLs must use HTTPS (the form rejects plain `http://` for those channels); Apprise endpoints may use HTTP or HTTPS. Third, a routing rule with unconstrained Node plus empty Stacks, Labels, Categories, and Severity matchers will intercept every alert and skip the global channels. Use the per-channel **Test** button to issue a one-shot dispatch and watch your endpoint for the literal message `🔌 Test Notification from Sencho!` Sencho records the failure reason in `notification_history.dispatch_error` when delivery throws, so a row that appears in the bell with no follow-up at the endpoint usually means a 4xx or timeout at the receiver. Three causes account for almost every case. First, the rule's **Duration** has not elapsed yet: the breach must persist for the full duration before the rule fires. Second, the rule is still in cooldown after a previous fire. Third, the panel's banner is not green: a remote-node banner means the rule was saved on a remote whose channels you may not have configured, and an amber `No notification channels configured` banner means the rule evaluates fine but Sencho has nowhere to send the alert. The evaluator runs on a 30-second tick, so expect up to 30 seconds of latency between the breach starting and the timer engaging. @@ -461,7 +454,7 @@ Switching the active node tears down per-stack rule editors and reloads channel Sencho deliberately hides rows whose category is one of `deploy_success`, `stack_started`, `stack_stopped`, `stack_restarted`, or `image_update_applied` AND whose `actor_username` is set to a real user. The reasoning: those are confirmations of the action you just clicked and are already shown as a toast. The rows are still persisted to `notification_history` and still dispatched to global channels and matching routes; only the bell render hides them. - Routing matchers AND together: every non-empty matcher must match the alert. A rule with **Stacks** set to `prod-api` will not match a `monitor_alert` for a different stack, and a rule with a populated **Stacks** matcher will not match host-level alerts (which carry no stack target). When zero rules match, Sencho falls back to global channels. To intercept everything, leave all four matcher fields empty on the rule. Also confirm the rule's **Enabled** pill is `ON`. + Routing matchers AND together: every non-empty matcher must match the alert. A rule with **Stacks** set to `prod-api` will not match a `monitor_alert` for a different stack, and a rule with a populated **Stacks** matcher will not match host-level alerts (which carry no stack target). Stack patterns may use `*` as a wildcard (for example `prod-*`). When zero rules match, Sencho falls back to global channels. To intercept everything, leave Node as any and leave Stacks, Labels, Categories, and Severity empty. Also confirm the rule's **Enabled** pill is `ON`. Fleet and the notification path share the same version lookup. When a newer published release is in that cache, Monitor dispatches a single `info`/`node_update_available` alert and records it under `last_sencho_update_notified_version`, so each release produces one alert per node. If you already upgraded to (or past) that version, the dedup self-heals and the alert does not fire. Check **Settings · Notifications · Mute Rules** for a rule that mutes `node_update_available` in the bell. A newly published release may take up to the cache TTL (about 30 minutes when published, or about 3 minutes while registry publish is still pending) before Fleet and the bell both observe it. diff --git a/docs/reference/settings.mdx b/docs/reference/settings.mdx index a31fffda..f95d1641 100644 --- a/docs/reference/settings.mdx +++ b/docs/reference/settings.mdx @@ -408,9 +408,10 @@ Create routing rules that direct specific alert types to specific notification c |-------|-------------| | **Name** | Operator-facing label for the rule. | | **Node** | Specific node, or all nodes if left empty. | -| **Stack patterns** | Glob patterns to match stack names (multi-select). | +| **Stack patterns** | Glob patterns with `*` (for example `prod-*`) matching stack names. | | **Labels** | Apply this rule to stacks that carry any of the selected labels. | | **Categories** | Event categories that trigger the rule (crash, deploy, vulnerability, etc.). | +| **Severity** | Info, warning, and/or error levels. Empty matches any severity. | | **Channel type** | `discord`, `slack`, `webhook`, or `apprise`. | | **Channel URL** | The destination endpoint for this rule. | | **Priority** | Sort order among matching rules. Every matching route fires; priority does not stop later matches. | @@ -436,7 +437,7 @@ Create notification suppression rules that mute or drop matching alerts from the |-------|-------------| | **Name** | Operator-facing label for the rule. | | **Node** | Specific node, or all nodes if left empty. | -| **Stack patterns** | Stack names to match (multi-select). | +| **Stack patterns** | Glob patterns with `*` matching stack names (for example `prod-*`). | | **Labels** | Match stacks that carry any selected label. | | **Categories** | Notification categories to suppress. | | **Severity** | Info, warning, and/or error levels to suppress. | diff --git a/frontend/src/components/settings/NotificationRoutingSection.tsx b/frontend/src/components/settings/NotificationRoutingSection.tsx index 81af1180..36ed2240 100644 --- a/frontend/src/components/settings/NotificationRoutingSection.tsx +++ b/frontend/src/components/settings/NotificationRoutingSection.tsx @@ -1,4 +1,4 @@ -import { useState, useEffect, useCallback, useMemo } from 'react'; +import { useState, useEffect, useCallback, useMemo, useRef } from 'react'; import { Button } from '@/components/ui/button'; import { Input } from '@/components/ui/input'; import { Label } from '@/components/ui/label'; @@ -24,6 +24,9 @@ import { SettingsCallout } from './SettingsCallout'; import { SettingsPrimaryButton } from './SettingsActions'; import { useMastheadStats } from './MastheadStatsContext'; import { classifyAppriseEndpoint, isStatelessAppriseEndpoint } from '@/lib/appriseEndpoint'; +import { PatternChips, type PatternChipsHandle } from './PatternChips'; + +type NotificationLevel = 'info' | 'warning' | 'error'; interface NotificationRoute { id: number; @@ -32,6 +35,7 @@ interface NotificationRoute { stack_patterns: string[]; label_ids: number[] | null; categories: NotificationCategory[] | null; + levels: NotificationLevel[] | null; channel_type: 'discord' | 'slack' | 'webhook' | 'apprise'; channel_url: string; config: { mode: 'keyed' | 'stateless'; tags?: string; has_urls: boolean; providers?: string[]; url_count?: number } | null; @@ -41,6 +45,12 @@ interface NotificationRoute { updated_at: number; } +const LEVEL_LABELS: Record = { + info: 'Info', + warning: 'Warning', + error: 'Error', +}; + const CHANNEL_LABELS: Record = { discord: 'Discord', slack: 'Slack', @@ -74,7 +84,9 @@ export function NotificationRoutingSection() { const [formStacks, setFormStacks] = useState([]); const [formLabelIds, setFormLabelIds] = useState([]); const [formCategories, setFormCategories] = useState([]); + const [formLevels, setFormLevels] = useState([]); const [formChannelType, setFormChannelType] = useState<'discord' | 'slack' | 'webhook' | 'apprise'>('discord'); + const patternChipsRef = useRef(null); const [formChannelUrl, setFormChannelUrl] = useState(''); const [formAppriseUrls, setFormAppriseUrls] = useState(''); const [formAppriseTags, setFormAppriseTags] = useState(''); @@ -132,6 +144,7 @@ export function NotificationRoutingSection() { setFormStacks([]); setFormLabelIds([]); setFormCategories([]); + setFormLevels([]); setFormChannelType('discord'); setFormChannelUrl(''); setFormAppriseUrls(''); @@ -153,6 +166,7 @@ export function NotificationRoutingSection() { setFormStacks([...route.stack_patterns]); setFormLabelIds(route.label_ids ? [...route.label_ids] : []); setFormCategories(route.categories ? [...route.categories] : []); + setFormLevels(route.levels ? [...route.levels] : []); setFormChannelType(route.channel_type); setFormChannelUrl(route.channel_url); setFormAppriseTags(route.config?.tags ?? ''); @@ -170,6 +184,11 @@ export function NotificationRoutingSection() { const handleSave = async () => { if (!formName.trim()) { toast.error('Name is required.'); return; } + const preparedPatterns = patternChipsRef.current?.prepareSave(); + if (!preparedPatterns?.ok) { + toast.error('Fix invalid stack patterns before saving.'); + return; + } if (!formChannelUrl.trim() || (formChannelType !== 'apprise' && !formChannelUrl.startsWith('https://'))) { toast.error(formChannelType === 'apprise' ? 'Enter a valid Apprise endpoint.' : 'Channel URL must be a valid HTTPS URL.'); return; @@ -204,9 +223,10 @@ export function NotificationRoutingSection() { const body = { name: formName.trim(), node_id: formNodeId, - stack_patterns: formStacks, + stack_patterns: preparedPatterns.patterns, label_ids: formLabelIds.length > 0 ? formLabelIds : null, categories: formCategories.length > 0 ? formCategories : null, + levels: formLevels.length > 0 ? formLevels : null, channel_type: formChannelType, ...(formChannelType !== 'apprise' || !editingId || appriseEndpointDirty || channelTypeChanged ? { channel_url: formChannelUrl.trim() } @@ -305,10 +325,6 @@ export function NotificationRoutingSection() { } }; - const removeStack = (stackName: string) => { - setFormStacks(prev => prev.filter(s => s !== stackName)); - }; - const addLabel = (idStr: string) => { const id = Number(idStr); if (!isNaN(id) && id > 0 && !formLabelIds.includes(id)) { @@ -331,6 +347,17 @@ export function NotificationRoutingSection() { setFormCategories(prev => prev.filter(c => c !== cat)); }; + const addLevel = (level: string) => { + const l = level as NotificationLevel; + if ((l === 'info' || l === 'warning' || l === 'error') && !formLevels.includes(l)) { + setFormLevels(prev => [...prev, l]); + } + }; + + const removeLevel = (level: NotificationLevel) => { + setFormLevels(prev => prev.filter(l => l !== level)); + }; + const enabledRoutesCount = routes.filter(r => r.enabled).length; useMastheadStats( loading @@ -355,6 +382,10 @@ export function NotificationRoutingSection() { () => (Object.keys(CATEGORY_LABELS) as NotificationCategory[]).filter(c => !formCategories.includes(c)).map(c => ({ value: c, label: CATEGORY_LABELS[c] })), [formCategories], ); + const availableLevelOptions = useMemo( + () => (Object.keys(LEVEL_LABELS) as NotificationLevel[]).filter(l => !formLevels.includes(l)).map(l => ({ value: l, label: LEVEL_LABELS[l] })), + [formLevels], + ); return ( @@ -402,30 +433,21 @@ export function NotificationRoutingSection() {
+ - {formStacks.length > 0 && ( -
- {formStacks.map(s => ( - - {s} - - - ))} -
- )}
@@ -488,6 +510,34 @@ export function NotificationRoutingSection() {

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

+
+ + + {formLevels.length > 0 && ( +
+ {formLevels.map((l) => ( + + {LEVEL_LABELS[l]} + + + ))} +
+ )} +
+
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.levels && route.levels.length > 0 && route.levels.map((l) => ( + {LEVEL_LABELS[l]} + ))} + {route.stack_patterns.length === 0 + && (!route.label_ids || route.label_ids.length === 0) + && (!route.categories || route.categories.length === 0) + && (!route.levels || route.levels.length === 0) + && ( + route.node_id === null + ? Matches all alerts + : Matches all alerts on this node + )} | {route.channel_url} diff --git a/frontend/src/components/settings/NotificationSuppressionSection.tsx b/frontend/src/components/settings/NotificationSuppressionSection.tsx index 2839a9f1..a32e8d1c 100644 --- a/frontend/src/components/settings/NotificationSuppressionSection.tsx +++ b/frontend/src/components/settings/NotificationSuppressionSection.tsx @@ -1,4 +1,4 @@ -import { useState, useEffect, useCallback, useMemo } from 'react'; +import { useState, useEffect, useCallback, useMemo, useRef } from 'react'; import { Button } from '@/components/ui/button'; import { Input } from '@/components/ui/input'; import { Label } from '@/components/ui/label'; @@ -23,6 +23,7 @@ import { Plus, Trash2, Pencil, RefreshCw, X, BellOff } from 'lucide-react'; import { SettingsCallout } from './SettingsCallout'; import { SettingsPrimaryButton } from './SettingsActions'; import { useMastheadStats } from './MastheadStatsContext'; +import { PatternChips, type PatternChipsHandle } from './PatternChips'; type NotificationLevel = 'info' | 'warning' | 'error'; type AppliesTo = 'bell' | 'external' | 'both'; @@ -118,6 +119,7 @@ export function NotificationSuppressionSection({ const [formName, setFormName] = useState(''); const [formNodeId, setFormNodeId] = useState(null); const [formStacks, setFormStacks] = useState([]); + const patternChipsRef = useRef(null); const [formLabelIds, setFormLabelIds] = useState([]); const [formCategories, setFormCategories] = useState([]); const [formLevels, setFormLevels] = useState([]); @@ -212,6 +214,11 @@ export function NotificationSuppressionSection({ const handleSave = async () => { if (!formName.trim()) { toast.error('Name is required.'); return; } + const preparedPatterns = patternChipsRef.current?.prepareSave(); + if (!preparedPatterns?.ok) { + toast.error('Fix invalid stack patterns before saving.'); + return; + } const customMs = formCustomExpiry ? new Date(formCustomExpiry).getTime() : null; if (formExpirationPreset === 'custom' && (customMs == null || Number.isNaN(customMs))) { toast.error('Choose a valid custom expiration date.'); @@ -223,7 +230,7 @@ export function NotificationSuppressionSection({ const body = { name: formName.trim(), node_id: formNodeId, - stack_patterns: formStacks, + stack_patterns: preparedPatterns.patterns, label_ids: formLabelIds.length > 0 ? formLabelIds : null, categories: formCategories.length > 0 ? formCategories : null, levels: formLevels.length > 0 ? formLevels : null, @@ -297,7 +304,6 @@ export function NotificationSuppressionSection({ const addStack = (stackName: string) => { if (stackName && !formStacks.includes(stackName)) setFormStacks((prev) => [...prev, stackName]); }; - const removeStack = (stackName: string) => setFormStacks((prev) => prev.filter((s) => s !== stackName)); const addLabel = (idStr: string) => { const id = Number(idStr); if (!Number.isNaN(id) && id > 0 && !formLabelIds.includes(id)) setFormLabelIds((prev) => [...prev, id]); @@ -387,17 +393,14 @@ export function NotificationSuppressionSection({
- - {formStacks.length > 0 && ( -
- {formStacks.map((s) => ( - - {s} - - - ))} -
- )} + +
diff --git a/frontend/src/components/settings/PatternChips.tsx b/frontend/src/components/settings/PatternChips.tsx new file mode 100644 index 00000000..9f1ddccf --- /dev/null +++ b/frontend/src/components/settings/PatternChips.tsx @@ -0,0 +1,134 @@ +import { forwardRef, useImperativeHandle, useState, type KeyboardEvent } from 'react'; +import { Input } from '@/components/ui/input'; +import { Badge } from '@/components/ui/badge'; +import { X } from 'lucide-react'; +import { validateStackPatternClient } from './stackPatternClient'; + +export type PrepareSaveResult = + | { ok: true; patterns: string[] } + | { ok: false }; + +export interface PatternChipsHandle { + /** + * Commit pending text if any and return the validated pattern list to serialize. + * Callers must use the returned `patterns` instead of a stale parent render. + */ + prepareSave: () => PrepareSaveResult; +} + +interface PatternChipsProps { + patterns: string[]; + onChange: (next: string[]) => void; + placeholder?: string; + 'data-testid'?: string; +} + +function appendPattern(base: string[], raw: string): { ok: true; patterns: string[] } | { ok: false; error: string } { + const trimmed = raw.trim(); + if (!trimmed) return { ok: true, patterns: base }; + const err = validateStackPatternClient(trimmed); + if (err) return { ok: false, error: err }; + if (base.includes(trimmed)) return { ok: true, patterns: base }; + return { ok: true, patterns: [...base, trimmed] }; +} + +export const PatternChips = forwardRef(function PatternChips( + { patterns, onChange, placeholder = 'Type a pattern and press Enter', 'data-testid': testId }, + ref, +) { + const [pending, setPending] = useState(''); + const [error, setError] = useState(null); + + const commitAgainst = (base: string[], raw: string): string[] | null => { + const result = appendPattern(base, raw); + if (!result.ok) { + setError(result.error); + return null; + } + setError(null); + return result.patterns; + }; + + useImperativeHandle(ref, () => ({ + prepareSave: () => { + let next = patterns; + if (pending.trim()) { + const committed = commitAgainst(patterns, pending); + if (!committed) return { ok: false }; + next = committed; + } + for (const p of next) { + const err = validateStackPatternClient(p); + if (err) { + setError(err); + return { ok: false }; + } + } + if (next !== patterns) onChange(next); + setPending(''); + setError(null); + return { ok: true, patterns: next }; + }, + })); + + const onKeyDown = (e: KeyboardEvent) => { + if (e.key === 'Enter' || e.key === ',') { + e.preventDefault(); + const next = commitAgainst(patterns, pending); + if (!next) return; + if (next !== patterns) onChange(next); + setPending(''); + } + }; + + const onChangePending = (value: string) => { + if (value.includes(',')) { + const parts = value.split(','); + const last = parts.pop() ?? ''; + let next = patterns; + for (const part of parts) { + const committed = commitAgainst(next, part); + if (!committed) return; + next = committed; + } + if (next !== patterns) onChange(next); + setPending(last); + return; + } + setPending(value); + if (error) setError(null); + }; + + return ( +
+ onChangePending(e.target.value)} + onKeyDown={onKeyDown} + placeholder={placeholder} + aria-invalid={error != null} + /> + {error &&

{error}

} + {patterns.length > 0 && ( +
+ {patterns.map((s) => ( + + {s} + + + ))} +
+ )} +

+ Use * as a wildcard (for example prod-*). Press Enter or comma to add. +

+
+ ); +}); diff --git a/frontend/src/components/settings/__tests__/NotificationRoutingSection.test.tsx b/frontend/src/components/settings/__tests__/NotificationRoutingSection.test.tsx index 810400f1..feb593d1 100644 --- a/frontend/src/components/settings/__tests__/NotificationRoutingSection.test.tsx +++ b/frontend/src/components/settings/__tests__/NotificationRoutingSection.test.tsx @@ -44,6 +44,7 @@ const APPRISE_ROUTE = { stack_patterns: ['app'], label_ids: null, categories: null, + levels: null, channel_type: 'apprise', channel_url: 'http://apprise.local/notify/', config: { @@ -226,4 +227,159 @@ describe('NotificationRoutingSection', () => { expect(body).not.toHaveProperty('config'); }); + it('shows Error badge and not Matches all alerts for a severity-only route', async () => { + mockedFetch.mockImplementation(async (url: string, opts?: { method?: string }) => { + if (url === '/notification-routes' && !opts?.method) { + return { + ok: true, + json: async () => [{ + ...APPRISE_ROUTE, + id: 7, + name: 'Errors only', + stack_patterns: [], + levels: ['error'], + channel_type: 'discord', + channel_url: 'https://discord.com/api/webhooks/1/x', + config: null, + }], + }; + } + if (url === '/stacks') return { ok: true, json: async () => ['app'] }; + if (url === '/labels') return { ok: true, json: async () => [] }; + return { ok: true, json: async () => ([]) }; + }); + render(); + await waitFor(() => expect(screen.getByText('Errors only')).toBeInTheDocument()); + expect(screen.getByText('Error')).toBeInTheDocument(); + expect(screen.queryByText('Matches all alerts')).not.toBeInTheDocument(); + expect(screen.queryByText('Matches all alerts on this node')).not.toBeInTheDocument(); + }); + + it('shows node-scoped match-all for a node-only route', async () => { + mockedFetch.mockImplementation(async (url: string, opts?: { method?: string }) => { + if (url === '/notification-routes' && !opts?.method) { + return { + ok: true, + json: async () => [{ + ...APPRISE_ROUTE, + id: 8, + name: 'Local only', + node_id: 1, + stack_patterns: [], + levels: null, + channel_type: 'discord', + channel_url: 'https://discord.com/api/webhooks/1/x', + config: null, + }], + }; + } + if (url === '/stacks') return { ok: true, json: async () => [] }; + if (url === '/labels') return { ok: true, json: async () => [] }; + return { ok: true, json: async () => ([]) }; + }); + render(); + await waitFor(() => expect(screen.getByText('Local only')).toBeInTheDocument()); + expect(screen.getByText('Local')).toBeInTheDocument(); + expect(screen.getByText('Matches all alerts on this node')).toBeInTheDocument(); + expect(screen.queryByText(/^Matches all alerts$/)).not.toBeInTheDocument(); + }); + + it('commits pattern chips and null severity into create JSON', async () => { + mockedFetch.mockImplementation(async (url: string, opts?: { method?: string }) => { + if (url === '/notification-routes' && !opts?.method) { + return { ok: true, json: async () => [] }; + } + if (url === '/stacks') return { ok: true, json: async () => ['known-stack'] }; + if (url === '/labels') return { ok: true, json: async () => [] }; + if (url === '/notification-routes' && opts?.method === 'POST') { + return { ok: true, json: async () => ({ id: 99 }) }; + } + return { ok: true, json: async () => ([]) }; + }); + render(); + await waitFor(() => expect(screen.getByRole('button', { name: /Add route/i })).toBeInTheDocument()); + await userEvent.click(screen.getByRole('button', { name: /Add route/i })); + await waitFor(() => expect(screen.getByRole('dialog')).toBeInTheDocument()); + + await userEvent.type(screen.getByPlaceholderText(/Type a pattern/i), 'prod-*{Enter}'); + const nameInput = screen.getByPlaceholderText(/Production alerts/i); + await userEvent.clear(nameInput); + await userEvent.type(nameInput, 'Chip route'); + await userEvent.type(screen.getByPlaceholderText(/discord/i), 'https://discord.com/api/webhooks/1/token'); + + await userEvent.click(screen.getByRole('button', { name: 'Create' })); + await waitFor(() => { + const post = mockedFetch.mock.calls.find( + ([url, opts]) => url === '/notification-routes' && (opts as { method?: string })?.method === 'POST', + ); + expect(post).toBeTruthy(); + const body = JSON.parse((post![1] as { body: string }).body); + expect(body.stack_patterns).toEqual(['prod-*']); + expect(body.levels).toBeNull(); + }); + }); + + it('includes a pending pattern that was never committed with Enter', async () => { + mockedFetch.mockImplementation(async (url: string, opts?: { method?: string }) => { + if (url === '/notification-routes' && !opts?.method) { + return { ok: true, json: async () => [] }; + } + if (url === '/stacks') return { ok: true, json: async () => ['known-stack'] }; + if (url === '/labels') return { ok: true, json: async () => [] }; + if (url === '/notification-routes' && opts?.method === 'POST') { + return { ok: true, json: async () => ({ id: 99 }) }; + } + return { ok: true, json: async () => ([]) }; + }); + render(); + await waitFor(() => expect(screen.getByRole('button', { name: /Add route/i })).toBeInTheDocument()); + await userEvent.click(screen.getByRole('button', { name: /Add route/i })); + await waitFor(() => expect(screen.getByRole('dialog')).toBeInTheDocument()); + + await userEvent.type(screen.getByPlaceholderText(/Type a pattern/i), 'prod-*'); + const nameInput = screen.getByPlaceholderText(/Production alerts/i); + await userEvent.clear(nameInput); + await userEvent.type(nameInput, 'Pending route'); + await userEvent.type(screen.getByPlaceholderText(/discord/i), 'https://discord.com/api/webhooks/1/token'); + + await userEvent.click(screen.getByRole('button', { name: 'Create' })); + await waitFor(() => { + const post = mockedFetch.mock.calls.find( + ([url, opts]) => url === '/notification-routes' && (opts as { method?: string })?.method === 'POST', + ); + expect(post).toBeTruthy(); + const body = JSON.parse((post![1] as { body: string }).body); + expect(body.stack_patterns).toEqual(['prod-*']); + }); + }); + + it('blocks create when a stack pattern is invalid', async () => { + mockedFetch.mockImplementation(async (url: string, opts?: { method?: string }) => { + if (url === '/notification-routes' && !opts?.method) { + return { ok: true, json: async () => [] }; + } + if (url === '/stacks') return { ok: true, json: async () => ['known-stack'] }; + if (url === '/labels') return { ok: true, json: async () => [] }; + if (url === '/notification-routes' && opts?.method === 'POST') { + return { ok: true, json: async () => ({ id: 99 }) }; + } + return { ok: true, json: async () => ([]) }; + }); + render(); + await waitFor(() => expect(screen.getByRole('button', { name: /Add route/i })).toBeInTheDocument()); + await userEvent.click(screen.getByRole('button', { name: /Add route/i })); + await waitFor(() => expect(screen.getByRole('dialog')).toBeInTheDocument()); + + await userEvent.type(screen.getByPlaceholderText(/Type a pattern/i), '****'); + await userEvent.type(screen.getByPlaceholderText(/Production alerts/i), 'Bad'); + await userEvent.type(screen.getByPlaceholderText(/discord/i), 'https://discord.com/api/webhooks/1/token'); + await userEvent.click(screen.getByRole('button', { name: 'Create' })); + await waitFor(() => { + const posts = mockedFetch.mock.calls.filter( + ([url, opts]) => url === '/notification-routes' && (opts as { method?: string })?.method === 'POST', + ); + expect(posts).toHaveLength(0); + }); + }); + }); diff --git a/frontend/src/components/settings/__tests__/NotificationSuppressionSection.test.tsx b/frontend/src/components/settings/__tests__/NotificationSuppressionSection.test.tsx new file mode 100644 index 00000000..c1fea07c --- /dev/null +++ b/frontend/src/components/settings/__tests__/NotificationSuppressionSection.test.tsx @@ -0,0 +1,132 @@ +/** + * NotificationSuppressionSection stack pattern chips. + */ +import { describe, it, expect, vi, beforeEach } from 'vitest'; +import { render, screen, waitFor } from '@testing-library/react'; +import userEvent from '@testing-library/user-event'; + +vi.mock('@/lib/api', () => ({ apiFetch: vi.fn() })); +vi.mock('@/components/ui/toast-store', () => ({ + toast: { + error: vi.fn(), + success: vi.fn(), + warning: vi.fn(), + info: vi.fn(), + loading: vi.fn(), + dismiss: vi.fn(), + }, +})); +vi.mock('@/context/NodeContext', () => ({ + useNodes: () => ({ + nodes: [{ id: 1, type: 'local', name: 'Local' }], + hasCapability: () => true, + activeNode: { id: 1, type: 'local', name: 'Local' }, + activeNodeMeta: { version: '1.0.0' }, + }), +})); +vi.mock('@/components/CapabilityGate', () => ({ + CapabilityGate: ({ children }: { children: React.ReactNode }) => <>{children}, +})); +vi.mock('../MastheadStatsContext', () => ({ + useMastheadStats: () => {}, +})); +vi.mock('@/hooks/useMuteRulesRefresh', () => ({ + useMuteRulesRefresh: () => {}, +})); +vi.mock('@/lib/muteRules', () => ({ + emitMuteRulesChanged: vi.fn(), +})); + +import { apiFetch } from '@/lib/api'; +import { NotificationSuppressionSection } from '../NotificationSuppressionSection'; + +const mockedFetch = apiFetch as unknown as ReturnType; + +async function openMuteForm() { + render(); + await waitFor(() => expect(screen.getByRole('button', { name: /Add mute rule|Add rule/i })).toBeInTheDocument()); + await userEvent.click(screen.getByRole('button', { name: /Add mute rule|Add rule/i })); + await waitFor(() => expect(screen.getByRole('dialog', { name: /New mute rule/i })).toBeInTheDocument()); +} + +describe('NotificationSuppressionSection', () => { + beforeEach(() => { + mockedFetch.mockReset(); + mockedFetch.mockImplementation(async (url: string, opts?: { method?: string }) => { + if (url === '/notification-suppression-rules' && !opts?.method) { + return { ok: true, json: async () => [] }; + } + if (url === '/stacks') return { ok: true, json: async () => ['staging'] }; + if (url === '/labels') return { ok: true, json: async () => [] }; + if (url === '/notification-suppression-rules' && opts?.method === 'POST') { + return { ok: true, json: async () => ({ id: 1 }) }; + } + return { ok: true, json: async () => ([]) }; + }); + }); + + it('posts normalized stack patterns and null levels', async () => { + await openMuteForm(); + + const nameInput = screen.getByPlaceholderText(/Mute staging/i); + await userEvent.type(nameInput, 'Mute prod'); + await userEvent.type(screen.getByPlaceholderText(/Type a pattern/i), 'prod-*,prod-*{Enter}'); + + await userEvent.click(screen.getByRole('button', { name: /Create|Update/i })); + await waitFor(() => { + const post = mockedFetch.mock.calls.find( + ([url, opts]) => url === '/notification-suppression-rules' && (opts as { method?: string })?.method === 'POST', + ); + expect(post).toBeTruthy(); + const body = JSON.parse((post![1] as { body: string }).body); + expect(body.stack_patterns).toEqual(['prod-*']); + expect(body.levels).toBeNull(); + }); + }); + + it('includes a pending pattern that was never committed with Enter', async () => { + await openMuteForm(); + + await userEvent.type(screen.getByPlaceholderText(/Mute staging/i), 'Pending mute'); + await userEvent.type(screen.getByPlaceholderText(/Type a pattern/i), 'prod-*'); + await userEvent.click(screen.getByRole('button', { name: /Create|Update/i })); + await waitFor(() => { + const post = mockedFetch.mock.calls.find( + ([url, opts]) => url === '/notification-suppression-rules' && (opts as { method?: string })?.method === 'POST', + ); + expect(post).toBeTruthy(); + const body = JSON.parse((post![1] as { body: string }).body); + expect(body.stack_patterns).toEqual(['prod-*']); + }); + }); + + it('accumulates multi-pattern comma input into the create body', async () => { + await openMuteForm(); + + await userEvent.type(screen.getByPlaceholderText(/Mute staging/i), 'Paste mute'); + await userEvent.type(screen.getByPlaceholderText(/Type a pattern/i), 'alpha-*,beta-*'); + await userEvent.click(screen.getByRole('button', { name: /Create|Update/i })); + await waitFor(() => { + const post = mockedFetch.mock.calls.find( + ([url, opts]) => url === '/notification-suppression-rules' && (opts as { method?: string })?.method === 'POST', + ); + expect(post).toBeTruthy(); + const body = JSON.parse((post![1] as { body: string }).body); + expect(body.stack_patterns).toEqual(['alpha-*', 'beta-*']); + }); + }); + + it('blocks create when a stack pattern is invalid', async () => { + await openMuteForm(); + + await userEvent.type(screen.getByPlaceholderText(/Mute staging/i), 'Bad mute'); + await userEvent.type(screen.getByPlaceholderText(/Type a pattern/i), '****'); + await userEvent.click(screen.getByRole('button', { name: /Create|Update/i })); + await waitFor(() => { + const posts = mockedFetch.mock.calls.filter( + ([url, opts]) => url === '/notification-suppression-rules' && (opts as { method?: string })?.method === 'POST', + ); + expect(posts).toHaveLength(0); + }); + }); +}); diff --git a/frontend/src/components/settings/stackPatternClient.ts b/frontend/src/components/settings/stackPatternClient.ts new file mode 100644 index 00000000..4e0eeee9 --- /dev/null +++ b/frontend/src/components/settings/stackPatternClient.ts @@ -0,0 +1,8 @@ +/** Client mirror of backend validateStackPatternForRedos. */ +export function validateStackPatternClient(pattern: string): string | null { + if (pattern.length > 200) return 'Pattern is too long (max 200 characters)'; + const stars = (pattern.match(/\*/g) ?? []).length; + if (stars > 8) return 'Pattern has too many wildcards (max 8)'; + if (/\*{4,}/.test(pattern)) return 'Pattern must not contain 4+ consecutive wildcards'; + return null; +}