mirror of
https://github.com/Studio-Saelix/sencho.git
synced 2026-08-10 02:41:14 +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:
@@ -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)),
|
||||
);
|
||||
}
|
||||
}),
|
||||
);
|
||||
}
|
||||
@@ -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/',
|
||||
|
||||
Reference in New Issue
Block a user