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
@@ -0,0 +1,68 @@
import type { NotificationCategory } from '../services/NotificationService';
export type NotificationLevel = 'info' | 'warning' | 'error';
export type NotificationAppliesTo = 'bell' | 'external' | 'both';
export interface NotificationFilterRule {
node_id: number | null;
stack_patterns: string[];
label_ids: number[] | null;
categories: string[] | null;
levels?: NotificationLevel[] | null;
}
export interface NotificationMatchContext {
localNodeId: number;
stackName?: string;
category: NotificationCategory;
level: NotificationLevel;
stackLabelIds: number[];
}
/** True when all non-empty matchers on the rule match the alert context (AND). */
export function matchesNotificationFilters(
ctx: NotificationMatchContext,
rule: NotificationFilterRule,
): boolean {
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))
) {
return false;
}
if (
rule.label_ids != null
&& rule.label_ids.length > 0
&& !rule.label_ids.some((id) => ctx.stackLabelIds.includes(id))
) {
return false;
}
if (
rule.categories != null
&& rule.categories.length > 0
&& !rule.categories.includes(ctx.category)
) {
return false;
}
if (
rule.levels != null
&& rule.levels.length > 0
&& !rule.levels.includes(ctx.level)
) {
return false;
}
return true;
}
export function ruleNeedsStackLabels(rules: NotificationFilterRule[]): boolean {
return rules.some((r) => r.label_ids != null && r.label_ids.length > 0);
}
export function appliesToBell(appliesTo: NotificationAppliesTo): boolean {
return appliesTo === 'bell' || appliesTo === 'both';
}
export function appliesToExternal(appliesTo: NotificationAppliesTo): boolean {
return appliesTo === 'external' || appliesTo === 'both';
}
@@ -0,0 +1,100 @@
import { DatabaseService, type NotificationSuppressionRule, type Node } from '../services/DatabaseService';
import { NodeRegistry } from '../services/NodeRegistry';
import { LicenseService } from '../services/LicenseService';
import { PROXY_TIER_HEADER } from '../services/license-headers';
import { getErrorMessage } from '../utils/errors';
const SYNC_TIMEOUT_MS = 15_000;
function buildRemoteHeaders(apiToken: string): Record<string, string> {
const proxyHeaders = LicenseService.getInstance().getProxyHeaders();
const headers: Record<string, string> = {
'Content-Type': 'application/json',
[PROXY_TIER_HEADER]: proxyHeaders.tier,
};
if (apiToken) headers.Authorization = `Bearer ${apiToken}`;
return headers;
}
function replicationTargets(rule: NotificationSuppressionRule): Node[] {
const db = DatabaseService.getInstance();
const remotes = db.getNodes().filter((n) => n.type === 'remote');
if (rule.node_id != null) {
const target = remotes.find((n) => n.id === rule.node_id);
return target ? [target] : [];
}
return remotes;
}
async function pushRuleToNode(node: Node, rule: NotificationSuppressionRule): Promise<void> {
const target = NodeRegistry.getInstance().getProxyTarget(node.id);
if (!target?.apiUrl) {
console.warn(`[SuppressionSync] Skipping node "${node.name}": no proxy target`);
return;
}
const baseUrl = target.apiUrl.replace(/\/$/, '');
const res = await fetch(`${baseUrl}/api/notification-suppression-rules/replica`, {
method: 'POST',
headers: buildRemoteHeaders(target.apiToken),
body: JSON.stringify({ rule }),
signal: AbortSignal.timeout(SYNC_TIMEOUT_MS),
});
if (!res.ok) {
const body = await res.text().catch(() => '');
throw new Error(`HTTP ${res.status}${body ? `: ${body.slice(0, 200)}` : ''}`);
}
}
async function deleteRuleOnNode(node: Node, ruleId: number): Promise<void> {
const target = NodeRegistry.getInstance().getProxyTarget(node.id);
if (!target?.apiUrl) {
console.warn(`[SuppressionSync] Skipping node "${node.name}": no proxy target`);
return;
}
const baseUrl = target.apiUrl.replace(/\/$/, '');
const res = await fetch(`${baseUrl}/api/notification-suppression-rules/replica/${ruleId}`, {
method: 'DELETE',
headers: buildRemoteHeaders(target.apiToken),
signal: AbortSignal.timeout(SYNC_TIMEOUT_MS),
});
if (!res.ok && res.status !== 404) {
const body = await res.text().catch(() => '');
throw new Error(`HTTP ${res.status}${body ? `: ${body.slice(0, 200)}` : ''}`);
}
}
/** Best-effort push of a suppression rule to fleet nodes that should evaluate it. */
export function syncSuppressionRuleToFleet(rule: NotificationSuppressionRule): void {
const targets = replicationTargets(rule);
if (targets.length === 0) return;
void Promise.allSettled(
targets.map(async (node) => {
try {
await pushRuleToNode(node, rule);
} catch (err) {
console.error(
`[SuppressionSync] Failed to push rule ${rule.id} to node "${node.name}":`,
getErrorMessage(err, String(err)),
);
}
}),
);
}
/** Best-effort delete of a replicated rule on fleet nodes. */
export function deleteSuppressionRuleFromFleet(rule: NotificationSuppressionRule): void {
const targets = replicationTargets(rule);
if (targets.length === 0) return;
void Promise.allSettled(
targets.map(async (node) => {
try {
await deleteRuleOnNode(node, rule.id);
} catch (err) {
console.error(
`[SuppressionSync] Failed to delete rule ${rule.id} on node "${node.name}":`,
getErrorMessage(err, String(err)),
);
}
}),
);
}
+1
View File
@@ -52,6 +52,7 @@ export const HUB_ONLY_PREFIXES: readonly string[] = [
'/api/scheduled-tasks/',
'/api/audit-log/',
'/api/notification-routes/',
'/api/notification-suppression-rules/',
'/api/logs/global/',
'/api/system/log-stream-metrics/',
'/api/registries/',