mirror of
https://github.com/Studio-Saelix/sencho.git
synced 2026-08-18 06:23:18 +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:
@@ -13,6 +13,7 @@ export const CAPABILITIES = [
|
||||
'network-topology',
|
||||
'notifications',
|
||||
'notification-routing',
|
||||
'notification-suppression',
|
||||
'host-console',
|
||||
'container-exec',
|
||||
'audit-log',
|
||||
|
||||
@@ -0,0 +1,188 @@
|
||||
import { apiFetch } from '@/lib/api';
|
||||
import { toast } from '@/components/ui/toast-store';
|
||||
import { CATEGORY_LABELS } from '@/lib/notificationCategories';
|
||||
import type { NotificationCategory, NotificationItem } from '@/components/dashboard/types';
|
||||
|
||||
export type MuteRuleLevel = 'info' | 'warning' | 'error';
|
||||
export type MuteRuleAppliesTo = 'bell' | 'external' | 'both';
|
||||
|
||||
export type MuteRuleDraft = {
|
||||
name: string;
|
||||
node_id?: number | null;
|
||||
stack_patterns?: string[];
|
||||
label_ids?: number[] | null;
|
||||
categories?: NotificationCategory[] | null;
|
||||
levels?: MuteRuleLevel[] | null;
|
||||
applies_to?: MuteRuleAppliesTo;
|
||||
enabled?: boolean;
|
||||
expires_at?: number | null;
|
||||
};
|
||||
|
||||
export const MUTE_RULES_CHANGED_EVENT = 'sencho:mute-rules-changed';
|
||||
|
||||
export function emitMuteRulesChanged(): void {
|
||||
window.dispatchEvent(new CustomEvent(MUTE_RULES_CHANGED_EVENT));
|
||||
}
|
||||
|
||||
const DEFAULT_BODY = {
|
||||
applies_to: 'both' as MuteRuleAppliesTo,
|
||||
enabled: true,
|
||||
expires_at: null as number | null,
|
||||
node_id: null as number | null,
|
||||
stack_patterns: [] as string[],
|
||||
label_ids: null as number[] | null,
|
||||
categories: null as NotificationCategory[] | null,
|
||||
levels: null as MuteRuleLevel[] | null,
|
||||
};
|
||||
|
||||
export async function createMuteRule(draft: MuteRuleDraft): Promise<{ ok: boolean; error?: string }> {
|
||||
try {
|
||||
const res = await apiFetch('/notification-suppression-rules', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({ ...DEFAULT_BODY, ...draft }),
|
||||
});
|
||||
if (res.ok) {
|
||||
emitMuteRulesChanged();
|
||||
return { ok: true };
|
||||
}
|
||||
const err = await res.json().catch(() => ({}));
|
||||
return { ok: false, error: (err as { error?: string })?.error || 'Failed to create mute rule.' };
|
||||
} catch {
|
||||
return { ok: false, error: 'Network error.' };
|
||||
}
|
||||
}
|
||||
|
||||
export async function createMuteRuleWithToast(
|
||||
draft: MuteRuleDraft,
|
||||
successMessage = 'Mute rule created. Open Settings · Mute Rules to edit it.',
|
||||
): Promise<boolean> {
|
||||
const result = await createMuteRule(draft);
|
||||
if (result.ok) {
|
||||
toast.success(successMessage);
|
||||
return true;
|
||||
}
|
||||
toast.error(result.error || 'Failed to create mute rule.');
|
||||
return false;
|
||||
}
|
||||
|
||||
export function stackMuteAllDraft(stackName: string, nodeId?: number | null): MuteRuleDraft {
|
||||
return {
|
||||
name: `Mute ${stackName}`,
|
||||
node_id: nodeId ?? null,
|
||||
stack_patterns: [stackName],
|
||||
};
|
||||
}
|
||||
|
||||
export function stackMuteDeploySuccessDraft(stackName: string, nodeId?: number | null): MuteRuleDraft {
|
||||
return {
|
||||
name: `Mute ${stackName} deploy success`,
|
||||
node_id: nodeId ?? null,
|
||||
stack_patterns: [stackName],
|
||||
categories: ['deploy_success'],
|
||||
};
|
||||
}
|
||||
|
||||
export function stackMuteMonitorDraft(stackName: string, nodeId?: number | null): MuteRuleDraft {
|
||||
return {
|
||||
name: `Mute ${stackName} monitor alerts`,
|
||||
node_id: nodeId ?? null,
|
||||
stack_patterns: [stackName],
|
||||
categories: ['monitor_alert'],
|
||||
};
|
||||
}
|
||||
|
||||
export function nodeMuteAllDraft(nodeId: number, nodeName: string): MuteRuleDraft {
|
||||
return {
|
||||
name: `Mute ${nodeName}`,
|
||||
node_id: nodeId,
|
||||
};
|
||||
}
|
||||
|
||||
export function nodeMuteUpdatesDraft(nodeId: number, nodeName: string): MuteRuleDraft {
|
||||
return {
|
||||
name: `Mute ${nodeName} update notifications`,
|
||||
node_id: nodeId,
|
||||
categories: ['image_update_available', 'node_update_available', 'update_started'],
|
||||
};
|
||||
}
|
||||
|
||||
export function nodeMuteMonitorDraft(nodeId: number, nodeName: string): MuteRuleDraft {
|
||||
return {
|
||||
name: `Mute ${nodeName} monitor alerts`,
|
||||
node_id: nodeId,
|
||||
categories: ['monitor_alert'],
|
||||
};
|
||||
}
|
||||
|
||||
export function labelMuteAllDraft(labelId: number, labelName: string, nodeId?: number | null): MuteRuleDraft {
|
||||
return {
|
||||
name: `Mute label: ${labelName}`,
|
||||
node_id: nodeId ?? null,
|
||||
label_ids: [labelId],
|
||||
applies_to: 'both',
|
||||
};
|
||||
}
|
||||
|
||||
export function labelMuteExternalDraft(labelId: number, labelName: string, nodeId?: number | null): MuteRuleDraft {
|
||||
return {
|
||||
name: `Mute external for label: ${labelName}`,
|
||||
node_id: nodeId ?? null,
|
||||
label_ids: [labelId],
|
||||
applies_to: 'external',
|
||||
};
|
||||
}
|
||||
|
||||
export function labelMuteLowPriorityDraft(labelId: number, labelName: string, nodeId?: number | null): MuteRuleDraft {
|
||||
return {
|
||||
name: `Mute low-priority alerts for label: ${labelName}`,
|
||||
node_id: nodeId ?? null,
|
||||
label_ids: [labelId],
|
||||
levels: ['info', 'warning'],
|
||||
};
|
||||
}
|
||||
|
||||
export type BellMuteMode = 'category' | 'similar' | 'stack';
|
||||
|
||||
export function muteDraftFromNotification(
|
||||
notif: NotificationItem,
|
||||
mode: BellMuteMode,
|
||||
): MuteRuleDraft | null {
|
||||
const categoryLabel = notif.category ? CATEGORY_LABELS[notif.category as NotificationCategory] : 'alert';
|
||||
|
||||
if (mode === 'category' && notif.category) {
|
||||
return {
|
||||
name: `Mute ${categoryLabel}`,
|
||||
node_id: notif.nodeId ?? null,
|
||||
categories: [notif.category as NotificationCategory],
|
||||
};
|
||||
}
|
||||
if (mode === 'stack' && notif.stack_name) {
|
||||
return {
|
||||
name: `Mute ${notif.stack_name}`,
|
||||
node_id: notif.nodeId ?? null,
|
||||
stack_patterns: [notif.stack_name],
|
||||
};
|
||||
}
|
||||
if (mode === 'similar') {
|
||||
return {
|
||||
name: 'Mute similar alerts',
|
||||
node_id: notif.nodeId ?? null,
|
||||
categories: notif.category ? [notif.category as NotificationCategory] : null,
|
||||
levels: [notif.level],
|
||||
stack_patterns: notif.stack_name ? [notif.stack_name] : [],
|
||||
};
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
export async function createMuteFromNotification(
|
||||
notif: NotificationItem,
|
||||
mode: BellMuteMode,
|
||||
): Promise<boolean> {
|
||||
const draft = muteDraftFromNotification(notif, mode);
|
||||
if (!draft) {
|
||||
toast.error('This notification cannot be muted with that shortcut.');
|
||||
return false;
|
||||
}
|
||||
return createMuteRuleWithToast(draft);
|
||||
}
|
||||
@@ -11,6 +11,11 @@ export const CATEGORY_LABELS: Record<NotificationCategory, string> = {
|
||||
autoheal_triggered: 'Auto-heal',
|
||||
monitor_alert: 'Monitor alert',
|
||||
scan_finding: 'Scan finding',
|
||||
drift_detected: 'Drift detected',
|
||||
drift_resolved: 'Drift resolved',
|
||||
update_started: 'Update started',
|
||||
health_gate_passed: 'Health gate passed',
|
||||
health_gate_failed: 'Health gate failed',
|
||||
node_update_available: 'Node update',
|
||||
system: 'System',
|
||||
};
|
||||
|
||||
Reference in New Issue
Block a user