feat: add notification suppression rules (#1525)

* feat: add notification suppression rules

* fix: restore label routing and routing test mocks for suppression

* fix: allow bell mute shortcuts for history-only notification categories

Suppression rule validation used the routable category whitelist, which rejected history-only categories such as update_started that appear in the bell during stack updates.

* feat: expand Mute Rules UX with compose-first entry points and activity badges

* fix: add missing NodeContext mocks for notification suppression tests
This commit is contained in:
Anso
2026-07-02 15:26:48 -04:00
committed by GitHub
parent bc111d28f3
commit b65daf6845
52 changed files with 2794 additions and 51 deletions
@@ -25,6 +25,7 @@ export const CAPABILITIES = [
'network-topology',
'notifications',
'notification-routing',
'notification-suppression',
'host-console',
'container-exec',
'audit-log',
+178
View File
@@ -377,6 +377,7 @@ export interface NotificationHistory {
stack_name?: string;
container_name?: string;
actor_username?: string | null;
suppression_match?: string | null;
}
export interface FleetSnapshot {
@@ -600,6 +601,23 @@ export interface NotificationRoute {
updated_at: number;
}
export type NotificationSuppressionAppliesTo = 'bell' | 'external' | 'both';
export interface NotificationSuppressionRule {
id: number;
name: string;
node_id: number | null;
stack_patterns: string[];
label_ids: number[] | null;
categories: string[] | null;
levels: ('info' | 'warning' | 'error')[] | null;
applies_to: NotificationSuppressionAppliesTo;
enabled: boolean;
expires_at: number | null;
created_at: number;
updated_at: number;
}
export type VulnSeverity = 'CRITICAL' | 'HIGH' | 'MEDIUM' | 'LOW' | 'UNKNOWN';
export type VulnScanStatus = 'in_progress' | 'completed' | 'failed';
export type VulnScanTrigger = 'manual' | 'scheduled' | 'deploy' | 'deploy-preflight';
@@ -871,6 +889,7 @@ export class DatabaseService {
this.migrateNotificationRoutes();
this.migrateNotificationRoutesNodeId();
this.migrateNotificationRoutesMatchers();
this.migrateNotificationSuppressionRules();
this.migrateNotificationHistoryContext();
this.migrateScanPolicyFleetColumns();
this.migrateScanPolicyRiskColumns();
@@ -1814,9 +1833,31 @@ export class DatabaseService {
this.tryAddColumn('notification_routes', 'categories', 'TEXT NULL');
}
private migrateNotificationSuppressionRules(): void {
this.db.exec(`
CREATE TABLE IF NOT EXISTS notification_suppression_rules (
id INTEGER PRIMARY KEY AUTOINCREMENT,
name TEXT NOT NULL,
node_id INTEGER NULL,
stack_patterns TEXT NOT NULL,
label_ids TEXT NULL,
categories TEXT NULL,
levels TEXT NULL,
applies_to TEXT NOT NULL,
enabled INTEGER DEFAULT 1,
expires_at INTEGER NULL,
created_at INTEGER NOT NULL,
updated_at INTEGER NOT NULL
);
CREATE INDEX IF NOT EXISTS idx_notification_suppression_enabled
ON notification_suppression_rules(enabled, expires_at);
`);
}
private migrateNotificationHistoryContext(): void {
this.tryAddColumn('notification_history', 'stack_name', 'TEXT');
this.tryAddColumn('notification_history', 'container_name', 'TEXT');
this.tryAddColumn('notification_history', 'suppression_match', 'TEXT');
}
private migrateStackDossierHashes(): void {
@@ -2293,6 +2334,135 @@ export class DatabaseService {
return this.db.prepare('DELETE FROM notification_routes WHERE id = ?').run(id).changes;
}
// --- Notification Suppression Rules ---
private parseNotificationSuppressionRule(row: Record<string, unknown>): NotificationSuppressionRule {
return {
id: row.id as number,
name: row.name as string,
node_id: row.node_id != null ? (row.node_id as number) : null,
stack_patterns: JSON.parse(row.stack_patterns as string) as string[],
label_ids: row.label_ids ? JSON.parse(row.label_ids as string) as number[] : null,
categories: row.categories ? JSON.parse(row.categories as string) as string[] : null,
levels: row.levels ? JSON.parse(row.levels as string) as ('info' | 'warning' | 'error')[] : null,
applies_to: row.applies_to as NotificationSuppressionAppliesTo,
enabled: row.enabled === 1,
expires_at: row.expires_at != null ? (row.expires_at as number) : null,
created_at: row.created_at as number,
updated_at: row.updated_at as number,
};
}
public getNotificationSuppressionRules(): NotificationSuppressionRule[] {
return this.db.prepare('SELECT * FROM notification_suppression_rules ORDER BY created_at ASC')
.all()
.map((row) => this.parseNotificationSuppressionRule(row as Record<string, unknown>));
}
public getEnabledNotificationSuppressionRules(now = Date.now()): NotificationSuppressionRule[] {
return this.db.prepare(
'SELECT * FROM notification_suppression_rules WHERE enabled = 1 AND (expires_at IS NULL OR expires_at > ?) ORDER BY created_at ASC',
)
.all(now)
.map((row) => this.parseNotificationSuppressionRule(row as Record<string, unknown>));
}
public getNotificationSuppressionRule(id: number): NotificationSuppressionRule | undefined {
const row = this.db.prepare('SELECT * FROM notification_suppression_rules WHERE id = ?').get(id) as Record<string, unknown> | undefined;
return row ? this.parseNotificationSuppressionRule(row) : undefined;
}
public createNotificationSuppressionRule(
rule: Omit<NotificationSuppressionRule, 'id'>,
): NotificationSuppressionRule {
const result = this.db.prepare(
'INSERT INTO notification_suppression_rules (name, node_id, stack_patterns, label_ids, categories, levels, applies_to, enabled, expires_at, created_at, updated_at) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)',
).run(
rule.name,
rule.node_id ?? null,
JSON.stringify(rule.stack_patterns),
rule.label_ids ? JSON.stringify(rule.label_ids) : null,
rule.categories ? JSON.stringify(rule.categories) : null,
rule.levels ? JSON.stringify(rule.levels) : null,
rule.applies_to,
rule.enabled ? 1 : 0,
rule.expires_at ?? null,
rule.created_at,
rule.updated_at,
);
return this.getNotificationSuppressionRule(result.lastInsertRowid as number)!;
}
public upsertNotificationSuppressionRuleReplica(rule: NotificationSuppressionRule): void {
const existing = this.getNotificationSuppressionRule(rule.id);
if (existing) {
this.db.prepare(
`UPDATE notification_suppression_rules SET
name = ?, node_id = ?, stack_patterns = ?, label_ids = ?, categories = ?, levels = ?,
applies_to = ?, enabled = ?, expires_at = ?, updated_at = ?
WHERE id = ?`,
).run(
rule.name,
rule.node_id ?? null,
JSON.stringify(rule.stack_patterns),
rule.label_ids ? JSON.stringify(rule.label_ids) : null,
rule.categories ? JSON.stringify(rule.categories) : null,
rule.levels ? JSON.stringify(rule.levels) : null,
rule.applies_to,
rule.enabled ? 1 : 0,
rule.expires_at ?? null,
rule.updated_at,
rule.id,
);
return;
}
this.db.prepare(
`INSERT INTO notification_suppression_rules
(id, name, node_id, stack_patterns, label_ids, categories, levels, applies_to, enabled, expires_at, created_at, updated_at)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
).run(
rule.id,
rule.name,
rule.node_id ?? null,
JSON.stringify(rule.stack_patterns),
rule.label_ids ? JSON.stringify(rule.label_ids) : null,
rule.categories ? JSON.stringify(rule.categories) : null,
rule.levels ? JSON.stringify(rule.levels) : null,
rule.applies_to,
rule.enabled ? 1 : 0,
rule.expires_at ?? null,
rule.created_at,
rule.updated_at,
);
}
public updateNotificationSuppressionRule(
id: number,
updates: Partial<Omit<NotificationSuppressionRule, 'id' | 'created_at'>>,
): void {
const fields: string[] = [];
const values: unknown[] = [];
if (updates.name !== undefined) { fields.push('name = ?'); values.push(updates.name); }
if ('node_id' in updates) { fields.push('node_id = ?'); values.push(updates.node_id ?? null); }
if (updates.stack_patterns !== undefined) { fields.push('stack_patterns = ?'); values.push(JSON.stringify(updates.stack_patterns)); }
if ('label_ids' in updates) { fields.push('label_ids = ?'); values.push(updates.label_ids ? JSON.stringify(updates.label_ids) : null); }
if ('categories' in updates) { fields.push('categories = ?'); values.push(updates.categories ? JSON.stringify(updates.categories) : null); }
if ('levels' in updates) { fields.push('levels = ?'); values.push(updates.levels ? JSON.stringify(updates.levels) : null); }
if (updates.applies_to !== undefined) { fields.push('applies_to = ?'); values.push(updates.applies_to); }
if (updates.enabled !== undefined) { fields.push('enabled = ?'); values.push(updates.enabled ? 1 : 0); }
if ('expires_at' in updates) { fields.push('expires_at = ?'); values.push(updates.expires_at ?? null); }
if (updates.updated_at !== undefined) { fields.push('updated_at = ?'); values.push(updates.updated_at); }
if (fields.length === 0) return;
values.push(id);
this.db.prepare(`UPDATE notification_suppression_rules SET ${fields.join(', ')} WHERE id = ?`).run(...values);
}
public deleteNotificationSuppressionRule(id: number): number {
return this.db.prepare('DELETE FROM notification_suppression_rules WHERE id = ?').run(id).changes;
}
// --- Global Settings ---
public getGlobalSettings(): Readonly<Record<string, string>> {
@@ -2806,6 +2976,7 @@ export class DatabaseService {
container_name: row.container_name ?? undefined,
category: row.category ?? undefined,
actor_username: row.actor_username ?? null,
suppression_match: row.suppression_match ?? null,
};
}
@@ -2914,6 +3085,13 @@ export class DatabaseService {
this.db.prepare('UPDATE notification_history SET dispatch_error = ? WHERE id = ?').run(error, id);
}
public updateNotificationSuppressionMatch(
id: number,
snapshot: { rules: { id: number; name: string }[]; bellSuppressed: boolean; externalSuppressed: boolean },
): void {
this.db.prepare('UPDATE notification_history SET suppression_match = ? WHERE id = ?').run(JSON.stringify(snapshot), id);
}
public getStackRestartSummary(nodeId: number, days: number): StackRestartSummary[] {
const since = Date.now() - days * 86400 * 1000;
return this.db.prepare(`
+53 -12
View File
@@ -6,6 +6,12 @@ import { getErrorMessage } from '../utils/errors';
import { sanitizeForLog } from '../utils/safeLog';
import { sanitizeNotificationMessage } from '../utils/notificationMessage';
import { StackActivityMetricsService } from './StackActivityMetricsService';
import {
appliesToBell,
appliesToExternal,
matchesNotificationFilters,
ruleNeedsStackLabels,
} from '../helpers/notificationMatchers';
export type NotificationCategory =
| 'deploy_success'
@@ -44,6 +50,13 @@ export const ALL_NOTIFICATION_CATEGORIES: readonly NotificationCategory[] = [
'node_update_available', 'system',
];
/** Every category that can appear in notification history / the bell panel. */
export const ALL_SUPPRESSIBLE_CATEGORIES: readonly NotificationCategory[] = [
...ALL_NOTIFICATION_CATEGORIES,
'drift_detected', 'drift_resolved',
'update_started', 'health_gate_passed', 'health_gate_failed',
];
/** Webhook timeout: 10 seconds per external dispatch call. */
const WEBHOOK_TIMEOUT_MS = 10_000;
@@ -185,23 +198,51 @@ export class NotificationService {
});
}
const suppressionRules = this.dbService.getEnabledNotificationSuppressionRules();
const routes = this.dbService.getEnabledNotificationRoutes();
const needsStackLabels = stackName !== undefined && (
ruleNeedsStackLabels(suppressionRules)
|| routes.some((r) => r.label_ids != null && r.label_ids.length > 0)
);
const stackLabelIds = needsStackLabels
? this.dbService.getStackLabelIds(localNodeId, stackName!)
: [];
const matchCtx = {
localNodeId,
stackName,
category,
level,
stackLabelIds,
};
const matchedSuppression = suppressionRules.filter((r) => matchesNotificationFilters(matchCtx, r));
const suppressBell = matchedSuppression.some((r) => appliesToBell(r.applies_to));
const suppressExternal = matchedSuppression.some((r) => appliesToExternal(r.applies_to));
if (isDebugEnabled() && matchedSuppression.length > 0) {
console.log(`[Notify:diag] Suppression matched ${matchedSuppression.length} rule(s); bell=${suppressBell}, external=${suppressExternal}`);
}
if (matchedSuppression.length > 0 && notification.id != null) {
this.dbService.updateNotificationSuppressionMatch(notification.id, {
rules: matchedSuppression.map((r) => ({ id: r.id, name: r.name })),
bellSuppressed: suppressBell,
externalSuppressed: suppressExternal,
});
}
// 2. Push to connected browser clients via WebSocket
this.broadcastToSubscribers(notification);
if (!suppressBell) {
this.broadcastToSubscribers(notification);
}
if (suppressExternal) {
return;
}
// 3. Check notification routing rules — always evaluated, matchers compose AND
const errors: string[] = [];
const routes = this.dbService.getEnabledNotificationRoutes();
const needsLabels = stackName !== undefined && routes.some(r => r.label_ids != null && r.label_ids.length > 0);
const stackLabelIds = needsLabels ? this.dbService.getStackLabelIds(localNodeId, stackName!) : [];
const matched = routes.filter(r => {
if (r.node_id != null && r.node_id !== localNodeId) return false;
if (r.stack_patterns.length > 0 && (stackName === undefined || !r.stack_patterns.includes(stackName))) return false;
if (r.label_ids != null && r.label_ids.length > 0 && !r.label_ids.some(id => stackLabelIds.includes(id))) return false;
if (r.categories != null && r.categories.length > 0 && !r.categories.includes(category)) return false;
return true;
});
const matched = routes.filter(r => matchesNotificationFilters(matchCtx, r));
if (matched.length > 0) {
if (isDebugEnabled()) console.log(`[Notify:diag] Matched ${matched.length} route(s) for stack "${sanitizeForLog(stackName ?? '(none)')}", category="${sanitizeForLog(category)}"`);
await Promise.allSettled(