mirror of
https://github.com/Studio-Saelix/sencho.git
synced 2026-08-07 17:34:23 +00:00
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:
@@ -25,6 +25,7 @@ export const CAPABILITIES = [
|
||||
'network-topology',
|
||||
'notifications',
|
||||
'notification-routing',
|
||||
'notification-suppression',
|
||||
'host-console',
|
||||
'container-exec',
|
||||
'audit-log',
|
||||
|
||||
@@ -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(`
|
||||
|
||||
@@ -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(
|
||||
|
||||
Reference in New Issue
Block a user