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
This commit is contained in:
Anso
2026-07-19 14:54:48 -04:00
committed by GitHub
parent 63213c0960
commit 972f2b9483
26 changed files with 1158 additions and 118 deletions
@@ -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: '{}',
@@ -69,6 +69,7 @@ function makeAppriseRoute(overrides: Record<string, unknown> = {}) {
stack_patterns: ['my-app'],
label_ids: null,
categories: null,
levels: null,
channel_type: 'apprise' as const,
channel_url: KEYED,
config: '{}',
@@ -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' }),
@@ -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);
@@ -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();
});
});
@@ -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);
});
});
@@ -70,6 +70,7 @@ function makeRoute(overrides: Record<string, unknown> = {}) {
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'] }),
@@ -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);
});
});
@@ -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,
@@ -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']);
});
});
+2 -3
View File
@@ -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'; }
+3 -1
View File
@@ -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;
}
+88
View File
@@ -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);
}
+4 -14
View File
@@ -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';
+54 -35
View File
@@ -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<void> => {
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;
+1 -1
View File
@@ -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';
+15 -5
View File
@@ -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, 'id'>): 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.
+2 -2
View File
@@ -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 {
+8 -15
View File
@@ -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.
<Frame>
<img src="/images/alerts-notifications/routing-list.png" alt="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." />
</Frame>
### 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**.
<Frame>
<img src="/images/alerts-notifications/routing-modal.png" alt="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." />
</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
<AccordionGroup>
<Accordion title="Notifications never arrive">
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.
</Accordion>
<Accordion title="An alert rule never fires even when the threshold is breached">
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.
</Accordion>
<Accordion title="A routing rule is set up but the global channel still fires">
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`.
</Accordion>
<Accordion title="Fleet shows an update button but I never received a Sencho version notification">
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.
+3 -2
View File
@@ -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. |
@@ -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<NotificationLevel, string> = {
info: 'Info',
warning: 'Warning',
error: 'Error',
};
const CHANNEL_LABELS: Record<string, string> = {
discord: 'Discord',
slack: 'Slack',
@@ -74,7 +84,9 @@ export function NotificationRoutingSection() {
const [formStacks, setFormStacks] = useState<string[]>([]);
const [formLabelIds, setFormLabelIds] = useState<number[]>([]);
const [formCategories, setFormCategories] = useState<NotificationCategory[]>([]);
const [formLevels, setFormLevels] = useState<NotificationLevel[]>([]);
const [formChannelType, setFormChannelType] = useState<'discord' | 'slack' | 'webhook' | 'apprise'>('discord');
const patternChipsRef = useRef<PatternChipsHandle>(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<ComboboxOption[]>(
() => (Object.keys(LEVEL_LABELS) as NotificationLevel[]).filter(l => !formLevels.includes(l)).map(l => ({ value: l, label: LEVEL_LABELS[l] })),
[formLevels],
);
return (
<CapabilityGate capability="notification-routing" featureName="Routing">
@@ -402,30 +433,21 @@ export function NotificationRoutingSection() {
<div className="space-y-2">
<Label>Stacks <span className="text-muted-foreground font-normal text-xs">(optional)</span></Label>
<PatternChips
ref={patternChipsRef}
patterns={formStacks}
onChange={setFormStacks}
placeholder="Type a pattern (for example prod-*)"
data-testid="route-pattern-chips"
/>
<Combobox
options={availableStackOptions}
value=""
onValueChange={addStack}
placeholder="Add a stack..."
placeholder="Insert known stack name..."
searchPlaceholder="Search stacks..."
emptyText="No stacks found."
/>
{formStacks.length > 0 && (
<div className="flex flex-wrap gap-1.5 pt-1">
{formStacks.map(s => (
<Badge key={s} variant="secondary" className="font-mono text-xs gap-1 pr-1">
{s}
<button
type="button"
onClick={() => removeStack(s)}
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">
@@ -488,6 +510,34 @@ export function NotificationRoutingSection() {
<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>Severity <span className="text-muted-foreground font-normal text-xs">(optional)</span></Label>
<Combobox
options={availableLevelOptions}
value=""
onValueChange={addLevel}
placeholder="Add a severity..."
searchPlaceholder="Search..."
emptyText="No levels left."
/>
{formLevels.length > 0 && (
<div className="flex flex-wrap gap-1.5 pt-1">
{formLevels.map((l) => (
<Badge key={l} variant="outline" className="text-xs gap-1 pr-1">
{LEVEL_LABELS[l]}
<button
type="button"
onClick={() => removeLevel(l)}
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>Channel</Label>
<Tabs
@@ -685,9 +735,18 @@ export function NotificationRoutingSection() {
{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>
)}
{route.levels && route.levels.length > 0 && route.levels.map((l) => (
<Badge key={l} variant="outline" className="text-[10px]">{LEVEL_LABELS[l]}</Badge>
))}
{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
? <span className="text-muted-foreground/50 text-[10px]">Matches all alerts</span>
: <span className="text-muted-foreground/50 text-[10px]">Matches all alerts on this node</span>
)}
<span className="text-muted-foreground/50">|</span>
<span className="font-mono truncate max-w-[200px]" title={route.channel_type === 'apprise' ? undefined : route.channel_url}>
{route.channel_url}
@@ -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<number | null>(null);
const [formStacks, setFormStacks] = useState<string[]>([]);
const patternChipsRef = useRef<PatternChipsHandle>(null);
const [formLabelIds, setFormLabelIds] = useState<number[]>([]);
const [formCategories, setFormCategories] = useState<NotificationCategory[]>([]);
const [formLevels, setFormLevels] = useState<NotificationLevel[]>([]);
@@ -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({
<div className="space-y-2">
<Label>Stacks <span className="text-muted-foreground font-normal text-xs">(optional)</span></Label>
<Combobox options={availableStackOptions} value="" onValueChange={addStack} placeholder="Add a stack..." searchPlaceholder="Search stacks..." emptyText="No stacks found." />
{formStacks.length > 0 && (
<div className="flex flex-wrap gap-1.5 pt-1">
{formStacks.map((s) => (
<Badge key={s} variant="secondary" className="font-mono text-xs gap-1 pr-1">
{s}
<button type="button" onClick={() => removeStack(s)} className="ml-0.5 rounded-full hover:bg-foreground/10 p-0.5"><X className="w-3 h-3" /></button>
</Badge>
))}
</div>
)}
<PatternChips
ref={patternChipsRef}
patterns={formStacks}
onChange={setFormStacks}
placeholder="Type a pattern (for example prod-*)"
data-testid="mute-pattern-chips"
/>
<Combobox options={availableStackOptions} value="" onValueChange={addStack} placeholder="Insert known stack name..." searchPlaceholder="Search stacks..." emptyText="No stacks found." />
</div>
<div className="space-y-2">
@@ -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<PatternChipsHandle, PatternChipsProps>(function PatternChips(
{ patterns, onChange, placeholder = 'Type a pattern and press Enter', 'data-testid': testId },
ref,
) {
const [pending, setPending] = useState('');
const [error, setError] = useState<string | null>(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<HTMLInputElement>) => {
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 (
<div className="space-y-1.5" data-testid={testId}>
<Input
value={pending}
onChange={(e) => onChangePending(e.target.value)}
onKeyDown={onKeyDown}
placeholder={placeholder}
aria-invalid={error != null}
/>
{error && <p className="text-xs text-destructive">{error}</p>}
{patterns.length > 0 && (
<div className="flex flex-wrap gap-1.5 pt-1">
{patterns.map((s) => (
<Badge key={s} variant="secondary" className="font-mono text-xs gap-1 pr-1">
{s}
<button
type="button"
onClick={() => onChange(patterns.filter((p) => p !== s))}
className="ml-0.5 rounded-full hover:bg-foreground/10 p-0.5"
aria-label={`Remove ${s}`}
>
<X className="w-3 h-3" />
</button>
</Badge>
))}
</div>
)}
<p className="text-xs text-muted-foreground">
Use * as a wildcard (for example prod-*). Press Enter or comma to add.
</p>
</div>
);
});
@@ -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/<redacted>',
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(<NotificationRoutingSection />);
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(<NotificationRoutingSection />);
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(<NotificationRoutingSection />);
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(<NotificationRoutingSection />);
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(<NotificationRoutingSection />);
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);
});
});
});
@@ -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<typeof vi.fn>;
async function openMuteForm() {
render(<NotificationSuppressionSection />);
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);
});
});
});
@@ -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;
}