From b65daf6845cbb4836638f0800a1f6b26a01ec50e Mon Sep 17 00:00:00 2001 From: Anso Date: Thu, 2 Jul 2026 15:26:48 -0400 Subject: [PATCH] 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 --- backend/src/__tests__/hub-only-guard.test.ts | 20 + .../__tests__/notification-matchers.test.ts | 89 +++ .../__tests__/notification-routing.test.ts | 3 + ...otification-suppression-routes-api.test.ts | 176 ++++++ .../notification-suppression.test.ts | 175 ++++++ backend/src/helpers/notificationMatchers.ts | 68 +++ .../helpers/notificationSuppressionSync.ts | 100 +++ backend/src/helpers/proxyExemptPaths.ts | 1 + backend/src/index.ts | 3 +- backend/src/routes/notifications.ts | 318 +++++++++- backend/src/services/CapabilityRegistry.ts | 1 + backend/src/services/DatabaseService.ts | 178 ++++++ backend/src/services/NotificationService.ts | 65 +- backend/src/utils/audit-summaries.ts | 3 + docs/features/alerts-notifications.mdx | 30 + docs/reference/settings.mdx | 26 + frontend/src/components/EditorLayout.tsx | 11 + .../components/EditorLayout/EditorView.tsx | 6 + .../EditorLayout/MobileStackDetail.tsx | 3 + .../components/EditorLayout/ViewRouter.tsx | 11 + .../EditorLayout/editor-view-blocks.tsx | 10 +- .../hooks/useSidebarContextMenu.test.ts | 4 + .../hooks/useSidebarContextMenu.ts | 41 +- .../hooks/useViewNavigationState.ts | 13 + frontend/src/components/FleetView.tsx | 5 +- .../src/components/FleetView/NodeCard.tsx | 14 +- .../src/components/FleetView/OverviewTab.tsx | 4 + .../FleetView/__tests__/NodeCard.test.tsx | 2 +- frontend/src/components/NotificationPanel.tsx | 76 ++- frontend/src/components/StackAnatomyPanel.tsx | 5 + frontend/src/components/dashboard/types.ts | 5 + .../src/components/mute/MuteMenuItems.tsx | 128 ++++ .../src/components/settings/LabelsSection.tsx | 21 +- .../NotificationSuppressionSection.tsx | 573 ++++++++++++++++++ .../src/components/settings/SettingsPage.tsx | 15 +- .../settings/SettingsSectionContent.tsx | 34 +- .../settings/__tests__/registry.test.ts | 1 + frontend/src/components/settings/registry.ts | 11 + frontend/src/components/settings/types.ts | 1 + .../components/sidebar/StackContextMenu.tsx | 74 ++- .../src/components/sidebar/StackGroup.tsx | 4 +- .../src/components/sidebar/StackKebabMenu.tsx | 74 ++- frontend/src/components/sidebar/StackList.tsx | 33 + .../src/components/sidebar/sidebar-types.ts | 9 + .../stack/StackActivityTimeline.tsx | 44 ++ .../__tests__/useStackMenuItems.test.tsx | 28 + frontend/src/hooks/useMuteRuleActions.ts | 107 ++++ frontend/src/hooks/useMuteRulesRefresh.ts | 11 + frontend/src/hooks/useStackMenuItems.tsx | 17 + frontend/src/lib/capabilities.ts | 1 + frontend/src/lib/muteRules.ts | 188 ++++++ frontend/src/lib/notificationCategories.ts | 5 + 52 files changed, 2794 insertions(+), 51 deletions(-) create mode 100644 backend/src/__tests__/notification-matchers.test.ts create mode 100644 backend/src/__tests__/notification-suppression-routes-api.test.ts create mode 100644 backend/src/__tests__/notification-suppression.test.ts create mode 100644 backend/src/helpers/notificationMatchers.ts create mode 100644 backend/src/helpers/notificationSuppressionSync.ts create mode 100644 frontend/src/components/mute/MuteMenuItems.tsx create mode 100644 frontend/src/components/settings/NotificationSuppressionSection.tsx create mode 100644 frontend/src/hooks/useMuteRuleActions.ts create mode 100644 frontend/src/hooks/useMuteRulesRefresh.ts create mode 100644 frontend/src/lib/muteRules.ts diff --git a/backend/src/__tests__/hub-only-guard.test.ts b/backend/src/__tests__/hub-only-guard.test.ts index 6028af5b..939987c0 100644 --- a/backend/src/__tests__/hub-only-guard.test.ts +++ b/backend/src/__tests__/hub-only-guard.test.ts @@ -120,6 +120,26 @@ describe('hubOnlyGuard', () => { expect(res.body?.code).toBe('HUB_ONLY_ENDPOINT'); }); + it('rejects /api/notification-suppression-rules with 403 when nodeId targets a remote node', async () => { + const res = await request(app) + .get('/api/notification-suppression-rules/') + .set('Authorization', authHeader) + .set('x-node-id', String(remoteNodeId)); + + expect(res.status).toBe(403); + expect(res.body?.code).toBe('HUB_ONLY_ENDPOINT'); + }); + + it('rejects /api/notification-suppression-rules (no trailing slash) with 403 when nodeId targets a remote node', async () => { + const res = await request(app) + .get('/api/notification-suppression-rules') + .set('Authorization', authHeader) + .set('x-node-id', String(remoteNodeId)); + + expect(res.status).toBe(403); + expect(res.body?.code).toBe('HUB_ONLY_ENDPOINT'); + }); + // Regression for the Global Observability admin gate: the logs feed's // `requireAdmin` lives in the local route handler, which the proxy skips when // forwarding a remote nodeId. Without these prefixes the guard would let the diff --git a/backend/src/__tests__/notification-matchers.test.ts b/backend/src/__tests__/notification-matchers.test.ts new file mode 100644 index 00000000..8b00579f --- /dev/null +++ b/backend/src/__tests__/notification-matchers.test.ts @@ -0,0 +1,89 @@ +import { describe, it, expect } from 'vitest'; +import { + matchesNotificationFilters, + ruleNeedsStackLabels, + appliesToBell, + appliesToExternal, +} from '../helpers/notificationMatchers'; +import type { NotificationMatchContext } from '../helpers/notificationMatchers'; + +const baseCtx: NotificationMatchContext = { + localNodeId: 1, + stackName: 'my-app', + category: 'monitor_alert', + level: 'error', + stackLabelIds: [10], +}; + +describe('notificationMatchers', () => { + it('matches when all non-empty filters pass', () => { + expect(matchesNotificationFilters(baseCtx, { + node_id: null, + stack_patterns: ['my-app'], + label_ids: [10], + categories: ['monitor_alert'], + levels: ['error'], + })).toBe(true); + }); + + it('rejects when node_id does not match', () => { + expect(matchesNotificationFilters(baseCtx, { + node_id: 2, + stack_patterns: [], + label_ids: null, + categories: null, + })).toBe(false); + }); + + it('rejects when stack pattern does not match', () => { + expect(matchesNotificationFilters(baseCtx, { + node_id: null, + stack_patterns: ['other'], + label_ids: null, + categories: null, + })).toBe(false); + }); + + it('rejects when category does not match', () => { + expect(matchesNotificationFilters(baseCtx, { + node_id: null, + stack_patterns: [], + label_ids: null, + categories: ['deploy_success'], + })).toBe(false); + }); + + it('rejects when level does not match', () => { + expect(matchesNotificationFilters(baseCtx, { + node_id: null, + stack_patterns: [], + label_ids: null, + categories: null, + levels: ['info'], + })).toBe(false); + }); + + it('matches any when all matchers empty', () => { + expect(matchesNotificationFilters(baseCtx, { + node_id: null, + stack_patterns: [], + label_ids: null, + categories: null, + levels: null, + })).toBe(true); + }); + + it('detects when stack labels are needed', () => { + expect(ruleNeedsStackLabels([{ node_id: null, stack_patterns: [], label_ids: [1], categories: null }])).toBe(true); + expect(ruleNeedsStackLabels([{ node_id: null, stack_patterns: [], label_ids: null, categories: null }])).toBe(false); + }); + + it('applies_to helpers', () => { + expect(appliesToBell('bell')).toBe(true); + expect(appliesToBell('external')).toBe(false); + expect(appliesToExternal('external')).toBe(true); + expect(appliesToExternal('bell')).toBe(false); + expect(appliesToBell('both')).toBe(true); + expect(appliesToExternal('both')).toBe(true); + }); +}); diff --git a/backend/src/__tests__/notification-routing.test.ts b/backend/src/__tests__/notification-routing.test.ts index 508ecc73..345e5ec1 100644 --- a/backend/src/__tests__/notification-routing.test.ts +++ b/backend/src/__tests__/notification-routing.test.ts @@ -8,12 +8,14 @@ import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; const { mockGetEnabledNotificationRoutes, + mockGetEnabledNotificationSuppressionRules, mockGetEnabledAgents, mockGetStackLabelIds, mockAddNotificationHistory, mockUpdateNotificationDispatchError, } = vi.hoisted(() => ({ mockGetEnabledNotificationRoutes: vi.fn().mockReturnValue([]), + mockGetEnabledNotificationSuppressionRules: vi.fn().mockReturnValue([]), mockGetEnabledAgents: vi.fn().mockReturnValue([]), mockGetStackLabelIds: vi.fn().mockReturnValue([]), mockAddNotificationHistory: vi.fn().mockReturnValue({ @@ -30,6 +32,7 @@ vi.mock('../services/DatabaseService', () => ({ DatabaseService: { getInstance: () => ({ getEnabledNotificationRoutes: mockGetEnabledNotificationRoutes, + getEnabledNotificationSuppressionRules: mockGetEnabledNotificationSuppressionRules, getEnabledAgents: mockGetEnabledAgents, getStackLabelIds: mockGetStackLabelIds, addNotificationHistory: mockAddNotificationHistory, diff --git a/backend/src/__tests__/notification-suppression-routes-api.test.ts b/backend/src/__tests__/notification-suppression-routes-api.test.ts new file mode 100644 index 00000000..f4334d50 --- /dev/null +++ b/backend/src/__tests__/notification-suppression-routes-api.test.ts @@ -0,0 +1,176 @@ +/** + * Integration tests for notification suppression rules CRUD. + */ +import { describe, it, expect, beforeAll, afterAll, vi } from 'vitest'; +import request from 'supertest'; +import bcrypt from 'bcrypt'; +import { setupTestDb, cleanupTestDb, loginAsTestAdmin } from './helpers/setupTestDb'; + +let tmpDir: string; +let app: import('express').Express; +let DatabaseService: typeof import('../services/DatabaseService').DatabaseService; +let authCookie: string; +let viewerCookie: string; + +const validBody = { + name: 'Mute staging', + stack_patterns: ['staging'], + categories: ['monitor_alert'], + levels: ['warning'], + applies_to: 'both', + enabled: true, + expires_at: null, +}; + +beforeAll(async () => { + tmpDir = await setupTestDb(); + ({ DatabaseService } = await import('../services/DatabaseService')); + + const { LicenseService } = await import('../services/LicenseService'); + vi.spyOn(LicenseService.getInstance(), 'getTier').mockReturnValue('community'); + + ({ app } = await import('../index')); + authCookie = await loginAsTestAdmin(app); + + const viewerHash = await bcrypt.hash('viewerpass', 1); + DatabaseService.getInstance().addUser({ username: 'viewer', password_hash: viewerHash, role: 'viewer' }); + const viewerRes = await request(app) + .post('/api/auth/login') + .send({ username: 'viewer', password: 'viewerpass' }); + const cookies = viewerRes.headers['set-cookie'] as string | string[]; + viewerCookie = Array.isArray(cookies) ? cookies[0] : cookies; +}); + +afterAll(() => { + cleanupTestDb(tmpDir); +}); + +describe('Notification suppression - auth enforcement', () => { + it('GET returns 401 without auth', async () => { + const res = await request(app).get('/api/notification-suppression-rules'); + expect(res.status).toBe(401); + }); + + it('GET returns 403 for viewer', async () => { + const res = await request(app) + .get('/api/notification-suppression-rules') + .set('Cookie', viewerCookie); + expect(res.status).toBe(403); + }); + + it('POST returns 403 for viewer', async () => { + const res = await request(app) + .post('/api/notification-suppression-rules') + .set('Cookie', viewerCookie) + .send(validBody); + expect(res.status).toBe(403); + }); +}); + +describe('Notification suppression - CRUD', () => { + it('POST creates a rule on Community tier', async () => { + const res = await request(app) + .post('/api/notification-suppression-rules') + .set('Cookie', authCookie) + .send(validBody); + expect(res.status).toBe(201); + expect(res.body.name).toBe('Mute staging'); + expect(res.body.applies_to).toBe('both'); + if (typeof res.body?.id === 'number') { + DatabaseService.getInstance().deleteNotificationSuppressionRule(res.body.id); + } + }); + + it('GET lists rules', async () => { + const res = await request(app) + .get('/api/notification-suppression-rules') + .set('Cookie', authCookie); + expect(res.status).toBe(200); + expect(Array.isArray(res.body)).toBe(true); + }); + + it('POST rejects invalid applies_to', async () => { + const res = await request(app) + .post('/api/notification-suppression-rules') + .set('Cookie', authCookie) + .send({ ...validBody, applies_to: 'invalid' }); + expect(res.status).toBe(400); + }); + + it('POST rejects invalid levels', async () => { + const res = await request(app) + .post('/api/notification-suppression-rules') + .set('Cookie', authCookie) + .send({ ...validBody, levels: ['critical'] }); + expect(res.status).toBe(400); + }); + + it('POST accepts history-only category update_started (bell-visible)', async () => { + const res = await request(app) + .post('/api/notification-suppression-rules') + .set('Cookie', authCookie) + .send({ + name: 'Mute stack updates', + stack_patterns: [], + categories: ['update_started'], + levels: null, + applies_to: 'both', + enabled: true, + expires_at: null, + }); + expect(res.status).toBe(201); + expect(res.body.categories).toEqual(['update_started']); + if (typeof res.body?.id === 'number') { + DatabaseService.getInstance().deleteNotificationSuppressionRule(res.body.id); + } + }); + + it('POST accepts routable category image_update_available', async () => { + const res = await request(app) + .post('/api/notification-suppression-rules') + .set('Cookie', authCookie) + .send({ + name: 'Mute image updates', + stack_patterns: [], + categories: ['image_update_available'], + levels: null, + applies_to: 'both', + enabled: true, + expires_at: null, + }); + expect(res.status).toBe(201); + if (typeof res.body?.id === 'number') { + DatabaseService.getInstance().deleteNotificationSuppressionRule(res.body.id); + } + }); + + it('PUT updates a rule', async () => { + const created = await request(app) + .post('/api/notification-suppression-rules') + .set('Cookie', authCookie) + .send(validBody); + const id = created.body.id as number; + + const res = await request(app) + .put(`/api/notification-suppression-rules/${id}`) + .set('Cookie', authCookie) + .send({ enabled: false }); + expect(res.status).toBe(200); + expect(res.body.enabled).toBe(false); + + DatabaseService.getInstance().deleteNotificationSuppressionRule(id); + }); + + it('DELETE removes a rule', async () => { + const created = await request(app) + .post('/api/notification-suppression-rules') + .set('Cookie', authCookie) + .send(validBody); + const id = created.body.id as number; + + const res = await request(app) + .delete(`/api/notification-suppression-rules/${id}`) + .set('Cookie', authCookie); + expect(res.status).toBe(200); + }); +}); diff --git a/backend/src/__tests__/notification-suppression.test.ts b/backend/src/__tests__/notification-suppression.test.ts new file mode 100644 index 00000000..f465f7f0 --- /dev/null +++ b/backend/src/__tests__/notification-suppression.test.ts @@ -0,0 +1,175 @@ +/** + * Unit tests for notification suppression in NotificationService.dispatchAlert. + */ +import { describe, it, expect, vi, beforeEach } from 'vitest'; + +const { + mockGetEnabledNotificationRoutes, + mockGetEnabledAgents, + mockGetStackLabelIds, + mockAddNotificationHistory, + mockUpdateNotificationDispatchError, + mockGetEnabledNotificationSuppressionRules, + mockUpdateNotificationSuppressionMatch, + mockBroadcast, +} = vi.hoisted(() => ({ + mockGetEnabledNotificationRoutes: vi.fn().mockReturnValue([]), + mockGetEnabledAgents: vi.fn().mockReturnValue([]), + mockGetStackLabelIds: vi.fn().mockReturnValue([]), + mockAddNotificationHistory: vi.fn().mockReturnValue({ + id: 1, + level: 'error', + message: 'test', + timestamp: Date.now(), + is_read: 0, + }), + mockUpdateNotificationDispatchError: vi.fn(), + mockGetEnabledNotificationSuppressionRules: vi.fn().mockReturnValue([]), + mockUpdateNotificationSuppressionMatch: vi.fn(), + mockBroadcast: vi.fn(), +})); + +vi.mock('../services/DatabaseService', () => ({ + DatabaseService: { + getInstance: () => ({ + getEnabledNotificationRoutes: mockGetEnabledNotificationRoutes, + getEnabledAgents: mockGetEnabledAgents, + getStackLabelIds: mockGetStackLabelIds, + addNotificationHistory: mockAddNotificationHistory, + updateNotificationDispatchError: mockUpdateNotificationDispatchError, + getEnabledNotificationSuppressionRules: mockGetEnabledNotificationSuppressionRules, + updateNotificationSuppressionMatch: mockUpdateNotificationSuppressionMatch, + }), + }, +})); + +vi.mock('../services/NodeRegistry', () => ({ + NodeRegistry: { + getInstance: () => ({ + getDefaultNodeId: () => 1, + getComposeDir: () => '/app/compose', + }), + }, +})); + +const mockFetch = vi.fn().mockResolvedValue({ ok: true }); +vi.stubGlobal('fetch', mockFetch); + +import { NotificationService } from '../services/NotificationService'; +import { StackActivityMetricsService } from '../services/StackActivityMetricsService'; + +function makeSuppressionRule(overrides: Record = {}) { + return { + id: 1, + name: 'Mute crashes', + node_id: null as number | null, + stack_patterns: [] as string[], + label_ids: null as number[] | null, + categories: ['monitor_alert'] as string[] | null, + levels: null as string[] | null, + applies_to: 'both' as const, + enabled: true, + expires_at: null as number | null, + created_at: Date.now(), + updated_at: Date.now(), + ...overrides, + }; +} + +function makeRoute() { + return { + id: 1, + name: 'Prod Discord', + node_id: null, + stack_patterns: ['my-app'], + label_ids: null, + categories: null, + channel_type: 'discord' as const, + channel_url: 'https://discord.com/api/webhooks/123/abc', + priority: 0, + enabled: true, + created_at: Date.now(), + updated_at: Date.now(), + }; +} + +describe('NotificationService - suppression logic', () => { + let svc: NotificationService; + + beforeEach(() => { + vi.clearAllMocks(); + (NotificationService as unknown as { instance?: NotificationService }).instance = undefined; + svc = NotificationService.getInstance(); + vi.spyOn( + svc as unknown as { broadcastToSubscribers: (n: unknown) => void }, + 'broadcastToSubscribers', + ).mockImplementation(mockBroadcast); + vi.spyOn(StackActivityMetricsService.getInstance(), 'record').mockImplementation(() => {}); + }); + + it('suppresses category via external dispatch', async () => { + mockGetEnabledNotificationSuppressionRules.mockReturnValue([ + makeSuppressionRule({ categories: ['monitor_alert'], applies_to: 'external' }), + ]); + mockGetEnabledAgents.mockReturnValue([{ type: 'slack', url: 'https://hooks.slack.com/x', enabled: true }]); + + await svc.dispatchAlert('error', 'monitor_alert', 'Container crashed', { stackName: 'my-app' }); + + expect(mockBroadcast).toHaveBeenCalled(); + expect(mockFetch).not.toHaveBeenCalled(); + }); + + it('suppresses severity via bell only', async () => { + mockGetEnabledNotificationSuppressionRules.mockReturnValue([ + makeSuppressionRule({ levels: ['error'], applies_to: 'bell', categories: null }), + ]); + mockGetEnabledAgents.mockReturnValue([{ type: 'slack', url: 'https://hooks.slack.com/x', enabled: true }]); + + await svc.dispatchAlert('error', 'monitor_alert', 'Container crashed', { stackName: 'my-app' }); + + expect(mockBroadcast).not.toHaveBeenCalled(); + expect(mockFetch).toHaveBeenCalled(); + }); + + it('suppresses both bell and external', async () => { + mockGetEnabledNotificationSuppressionRules.mockReturnValue([ + makeSuppressionRule({ categories: ['monitor_alert'], applies_to: 'both' }), + ]); + mockGetEnabledAgents.mockReturnValue([{ type: 'slack', url: 'https://hooks.slack.com/x', enabled: true }]); + + await svc.dispatchAlert('error', 'monitor_alert', 'Crash', { stackName: 'my-app' }); + + expect(mockBroadcast).not.toHaveBeenCalled(); + expect(mockFetch).not.toHaveBeenCalled(); + expect(mockUpdateNotificationSuppressionMatch).toHaveBeenCalledWith(1, { + rules: [{ id: 1, name: 'Mute crashes' }], + bellSuppressed: true, + externalSuppressed: true, + }); + }); + + it('allows dispatch when no suppression rules match', async () => { + mockGetEnabledNotificationSuppressionRules.mockReturnValue([]); + mockGetEnabledAgents.mockReturnValue([{ type: 'slack', url: 'https://hooks.slack.com/x', enabled: true }]); + + await svc.dispatchAlert('error', 'monitor_alert', 'Crash'); + + expect(mockBroadcast).toHaveBeenCalled(); + expect(mockFetch).toHaveBeenCalled(); + }); + + it('routing still works when suppression does not match', async () => { + mockGetEnabledNotificationSuppressionRules.mockReturnValue([ + makeSuppressionRule({ categories: ['deploy_success'], applies_to: 'both' }), + ]); + mockGetEnabledNotificationRoutes.mockReturnValue([makeRoute()]); + + await svc.dispatchAlert('error', 'monitor_alert', 'Crash', { stackName: 'my-app' }); + + expect(mockBroadcast).toHaveBeenCalled(); + expect(mockFetch).toHaveBeenCalledWith( + 'https://discord.com/api/webhooks/123/abc', + expect.objectContaining({ method: 'POST' }), + ); + }); +}); diff --git a/backend/src/helpers/notificationMatchers.ts b/backend/src/helpers/notificationMatchers.ts new file mode 100644 index 00000000..75c8deb7 --- /dev/null +++ b/backend/src/helpers/notificationMatchers.ts @@ -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'; +} diff --git a/backend/src/helpers/notificationSuppressionSync.ts b/backend/src/helpers/notificationSuppressionSync.ts new file mode 100644 index 00000000..5085a4ca --- /dev/null +++ b/backend/src/helpers/notificationSuppressionSync.ts @@ -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 { + const proxyHeaders = LicenseService.getInstance().getProxyHeaders(); + const headers: Record = { + '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 { + 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 { + 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)), + ); + } + }), + ); +} diff --git a/backend/src/helpers/proxyExemptPaths.ts b/backend/src/helpers/proxyExemptPaths.ts index 7e529c62..49d4c409 100644 --- a/backend/src/helpers/proxyExemptPaths.ts +++ b/backend/src/helpers/proxyExemptPaths.ts @@ -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/', diff --git a/backend/src/index.ts b/backend/src/index.ts index 4dd10761..bcfdb190 100644 --- a/backend/src/index.ts +++ b/backend/src/index.ts @@ -36,7 +36,7 @@ import { agentsRouter } from './routes/agents'; import { metricsRouter } from './routes/metrics'; import { imageUpdatesRouter, autoUpdateRouter } from './routes/imageUpdates'; import { autoHealRouter } from './routes/autoHeal'; -import { notificationsRouter, notificationRoutesRouter } from './routes/notifications'; +import { notificationsRouter, notificationRoutesRouter, notificationSuppressionRouter } from './routes/notifications'; import { consoleRouter } from './routes/console'; import { ssoConfigRouter } from './routes/ssoConfig'; import { registriesRouter } from './routes/registries'; @@ -134,6 +134,7 @@ app.use('/api/auto-update', autoUpdateRouter); app.use('/api/auto-heal', autoHealRouter); app.use('/api/notifications', notificationsRouter); app.use('/api/notification-routes', notificationRoutesRouter); +app.use('/api/notification-suppression-rules', notificationSuppressionRouter); app.use('/api/system', consoleRouter); app.use('/api/sso/config', ssoConfigRouter); app.use('/api/registries', registriesRouter); diff --git a/backend/src/routes/notifications.ts b/backend/src/routes/notifications.ts index e7c58c1a..f9fe2dc4 100644 --- a/backend/src/routes/notifications.ts +++ b/backend/src/routes/notifications.ts @@ -1,22 +1,29 @@ import { Router, type Request, type Response } from 'express'; -import { DatabaseService } from '../services/DatabaseService'; -import { NotificationService, ALL_NOTIFICATION_CATEGORIES } from '../services/NotificationService'; +import { DatabaseService, type NotificationSuppressionAppliesTo, type NotificationSuppressionRule } from '../services/DatabaseService'; +import { NotificationService, ALL_NOTIFICATION_CATEGORIES, ALL_SUPPRESSIBLE_CATEGORIES } from '../services/NotificationService'; import type { NotificationCategory } from '../services/NotificationService'; import { NodeRegistry } from '../services/NodeRegistry'; import { authMiddleware } from '../middleware/auth'; -import { requireAdmin } from '../middleware/tierGates'; +import { requireAdmin, requireNodeProxy } from '../middleware/tierGates'; import { NOTIFICATION_CHANNEL_TYPES, validateHttpsUrl, cleanStackPatterns, maskWebhookUrl, } from '../helpers/notificationChannels'; +import { + deleteSuppressionRuleFromFleet, + syncSuppressionRuleToFleet, +} from '../helpers/notificationSuppressionSync'; import { isDebugEnabled } from '../utils/debug'; import { getErrorMessage } from '../utils/errors'; import { sanitizeForLog } from '../utils/safeLog'; import { parseIntParam } from '../utils/parseIntParam'; const VALID_CATEGORIES: ReadonlySet = new Set(ALL_NOTIFICATION_CATEGORIES); +const VALID_SUPPRESSION_CATEGORIES: ReadonlySet = new Set(ALL_SUPPRESSIBLE_CATEGORIES); +const VALID_LEVELS = new Set(['info', 'warning', 'error']); +const VALID_APPLIES_TO = new Set(['bell', 'external', 'both']); function validateNodeId(nodeId: unknown, res: Response): number | null | false { if (nodeId === undefined || nodeId === null) return null; @@ -41,15 +48,138 @@ function validateLabelIds(label_ids: unknown, res: Response): boolean { return true; } -function validateCategories(categories: unknown, res: Response): boolean { +function validateCategories( + categories: unknown, + res: Response, + allowed: ReadonlySet = VALID_CATEGORIES, +): boolean { if (categories === undefined || categories === null) return true; - if (!Array.isArray(categories) || categories.some((c: unknown) => typeof c !== 'string' || !VALID_CATEGORIES.has(c as NotificationCategory))) { + if (!Array.isArray(categories) || categories.some((c: unknown) => typeof c !== 'string' || !allowed.has(c as NotificationCategory))) { res.status(400).json({ error: 'categories must be an array of valid category names' }); return false; } return true; } +function validateSuppressionNodeId(nodeId: unknown, res: Response): number | null | false { + if (nodeId === undefined || nodeId === null) return null; + if (typeof nodeId !== 'number' || !Number.isInteger(nodeId)) { + res.status(400).json({ error: 'node_id must be an integer or null' }); + return false; + } + const node = DatabaseService.getInstance().getNode(nodeId); + if (!node) { + res.status(400).json({ error: 'node_id must reference a registered node or be null' }); + return false; + } + return nodeId; +} + +function validateLevels(levels: unknown, res: Response): boolean { + if (levels === undefined || levels === null) return true; + if (!Array.isArray(levels) || levels.some((l: unknown) => typeof l !== 'string' || !VALID_LEVELS.has(l))) { + res.status(400).json({ error: 'levels must be an array of info, warning, or error' }); + return false; + } + return true; +} + +function validateAppliesTo(applies_to: unknown, res: Response): NotificationSuppressionAppliesTo | false { + if (typeof applies_to !== 'string' || !VALID_APPLIES_TO.has(applies_to as NotificationSuppressionAppliesTo)) { + res.status(400).json({ error: 'applies_to must be bell, external, or both' }); + return false; + } + return applies_to as NotificationSuppressionAppliesTo; +} + +function validateExpiresAt(expires_at: unknown, res: Response): number | null | false | undefined { + if (expires_at === undefined) return undefined; + if (expires_at === null) return null; + if (typeof expires_at !== 'number' || !Number.isFinite(expires_at)) { + res.status(400).json({ error: 'expires_at must be a finite timestamp or null' }); + return false; + } + return expires_at; +} + +function parseSuppressionRuleBody( + req: Request, + res: Response, + isCreate: boolean, +): Omit | null { + const { + name, + node_id: rawNodeId, + stack_patterns, + label_ids, + categories, + levels, + applies_to, + enabled, + expires_at, + } = req.body; + + if (isCreate && (!name || typeof name !== 'string' || !name.trim())) { + res.status(400).json({ error: 'Name is required' }); + return null; + } + if (name !== undefined && (typeof name !== 'string' || !name.trim())) { + res.status(400).json({ error: 'Name must be a non-empty string' }); + return null; + } + if (name !== undefined && name.trim().length > 100) { + res.status(400).json({ error: 'Name must be 100 characters or fewer' }); + return null; + } + + const nodeIdResult = isCreate || 'node_id' in req.body + ? validateSuppressionNodeId(rawNodeId, res) + : undefined; + if (nodeIdResult === false) return null; + + let cleanedPatterns: string[] | undefined; + if (stack_patterns !== undefined) { + if (!Array.isArray(stack_patterns) || stack_patterns.some((p: unknown) => typeof p !== 'string')) { + res.status(400).json({ error: 'stack_patterns must be an array of strings' }); + return null; + } + cleanedPatterns = cleanStackPatterns(stack_patterns); + } else if (isCreate) { + cleanedPatterns = []; + } + + if (!validateLabelIds(label_ids, res)) return null; + if (!validateCategories(categories, res, VALID_SUPPRESSION_CATEGORIES)) return null; + if (!validateLevels(levels, res)) return null; + + const appliesToResult = isCreate + ? validateAppliesTo(applies_to, res) + : applies_to !== undefined + ? validateAppliesTo(applies_to, res) + : undefined; + if (appliesToResult === false) return null; + + const expiresAtResult = validateExpiresAt(expires_at, res); + if (expiresAtResult === false) return null; + + if (enabled !== undefined && typeof enabled !== 'boolean') { + res.status(400).json({ error: 'enabled must be a boolean' }); + return null; + } + + return { + name: (name as string).trim(), + node_id: nodeIdResult ?? null, + stack_patterns: cleanedPatterns ?? [], + label_ids: Array.isArray(label_ids) && label_ids.length > 0 ? label_ids : null, + categories: Array.isArray(categories) && categories.length > 0 ? categories : null, + levels: Array.isArray(levels) && levels.length > 0 ? levels : null, + applies_to: (appliesToResult ?? 'both') as NotificationSuppressionAppliesTo, + enabled: enabled !== false, + expires_at: expiresAtResult ?? null, + }; +} + export const notificationsRouter = Router(); notificationsRouter.get('/', authMiddleware, async (req: Request, res: Response): Promise => { @@ -293,3 +423,181 @@ notificationRoutesRouter.post('/:id/test', authMiddleware, async (req: Request, } }); +export const notificationSuppressionRouter = Router(); + +notificationSuppressionRouter.post('/replica', authMiddleware, (req: Request, res: Response): void => { + if (!requireNodeProxy(req, res)) return; + try { + const rule = req.body?.rule as NotificationSuppressionRule | undefined; + if (!rule || typeof rule.id !== 'number' || typeof rule.name !== 'string') { + res.status(400).json({ error: 'rule object with id and name is required' }); + return; + } + if (!VALID_APPLIES_TO.has(rule.applies_to)) { + res.status(400).json({ error: 'Invalid applies_to on rule' }); + return; + } + DatabaseService.getInstance().upsertNotificationSuppressionRuleReplica(rule); + res.json({ success: true }); + } catch (error) { + console.error('Failed to apply suppression rule replica:', error); + res.status(500).json({ error: 'Failed to apply suppression rule replica' }); + } +}); + +notificationSuppressionRouter.delete('/replica/:id', authMiddleware, (req: Request, res: Response): void => { + if (!requireNodeProxy(req, res)) return; + try { + const id = parseIntParam(req, res, 'id', 'suppression rule ID'); + if (id === null) return; + DatabaseService.getInstance().deleteNotificationSuppressionRule(id); + res.json({ success: true }); + } catch (error) { + console.error('Failed to delete suppression rule replica:', error); + res.status(500).json({ error: 'Failed to delete suppression rule replica' }); + } +}); + +notificationSuppressionRouter.get('/', authMiddleware, (req: Request, res: Response): void => { + if (!requireAdmin(req, res)) return; + try { + const rules = DatabaseService.getInstance().getNotificationSuppressionRules(); + res.json(rules); + } catch (error) { + console.error('Failed to fetch notification suppression rules:', error); + res.status(500).json({ error: 'Failed to fetch notification suppression rules' }); + } +}); + +notificationSuppressionRouter.post('/', authMiddleware, (req: Request, res: Response): void => { + if (!requireAdmin(req, res)) return; + try { + const parsed = parseSuppressionRuleBody(req, res, true); + if (!parsed) return; + + const now = Date.now(); + const rule = DatabaseService.getInstance().createNotificationSuppressionRule({ + ...parsed, + created_at: now, + updated_at: now, + }); + syncSuppressionRuleToFleet(rule); + console.log(`[Suppression] Rule "${sanitizeForLog(rule.name)}" created (id=${rule.id})`); + res.status(201).json(rule); + } catch (error) { + console.error('Failed to create notification suppression rule:', error); + res.status(500).json({ error: 'Failed to create notification suppression rule' }); + } +}); + +notificationSuppressionRouter.put('/:id', authMiddleware, (req: Request, res: Response): void => { + if (!requireAdmin(req, res)) return; + try { + const id = parseIntParam(req, res, 'id', 'suppression rule ID'); + if (id === null) return; + + const existing = DatabaseService.getInstance().getNotificationSuppressionRule(id); + if (!existing) { res.status(404).json({ error: 'Suppression rule not found' }); return; } + + const { + name, + node_id: rawNodeId, + stack_patterns, + label_ids, + categories, + levels, + applies_to, + enabled, + expires_at, + } = req.body; + + if (name !== undefined && (typeof name !== 'string' || !name.trim())) { + res.status(400).json({ error: 'Name must be a non-empty string' }); + return; + } + if (name !== undefined && name.trim().length > 100) { + res.status(400).json({ error: 'Name must be 100 characters or fewer' }); + return; + } + + let validatedNodeId: number | null | undefined; + if ('node_id' in req.body) { + const result = validateSuppressionNodeId(rawNodeId, res); + if (result === false) return; + validatedNodeId = result; + } + + let cleanedPatterns: string[] | undefined; + if (stack_patterns !== undefined) { + if (!Array.isArray(stack_patterns) || stack_patterns.some((p: unknown) => typeof p !== 'string')) { + res.status(400).json({ error: 'stack_patterns must be an array of strings' }); + return; + } + cleanedPatterns = cleanStackPatterns(stack_patterns); + } + + if (!validateLabelIds(label_ids, res)) return; + if (!validateCategories(categories, res, VALID_SUPPRESSION_CATEGORIES)) return; + if (!validateLevels(levels, res)) return; + + let validatedAppliesTo: NotificationSuppressionAppliesTo | undefined; + if (applies_to !== undefined) { + const result = validateAppliesTo(applies_to, res); + if (result === false) return; + validatedAppliesTo = result; + } + + let validatedExpiresAt: number | null | undefined; + if ('expires_at' in req.body) { + const result = validateExpiresAt(expires_at, res); + if (result === false) return; + validatedExpiresAt = result; + } + + if (enabled !== undefined && typeof enabled !== 'boolean') { + res.status(400).json({ error: 'enabled must be a boolean' }); + return; + } + + const updates: Partial> = { updated_at: Date.now() }; + if (name !== undefined) updates.name = name.trim(); + if (validatedNodeId !== undefined) updates.node_id = validatedNodeId; + if (cleanedPatterns !== undefined) updates.stack_patterns = cleanedPatterns; + if ('label_ids' in req.body) updates.label_ids = Array.isArray(label_ids) && label_ids.length > 0 ? label_ids : null; + if ('categories' in req.body) updates.categories = Array.isArray(categories) && categories.length > 0 ? categories : null; + if ('levels' in req.body) updates.levels = Array.isArray(levels) && levels.length > 0 ? levels : null; + if (validatedAppliesTo !== undefined) updates.applies_to = validatedAppliesTo; + if (enabled !== undefined) updates.enabled = enabled; + if (validatedExpiresAt !== undefined) updates.expires_at = validatedExpiresAt; + + const db = DatabaseService.getInstance(); + db.updateNotificationSuppressionRule(id, updates); + const updated = db.getNotificationSuppressionRule(id)!; + syncSuppressionRuleToFleet(updated); + console.log(`[Suppression] Rule ${id} updated`); + res.json(updated); + } catch (error) { + console.error('Failed to update notification suppression rule:', error); + res.status(500).json({ error: 'Failed to update notification suppression rule' }); + } +}); + +notificationSuppressionRouter.delete('/:id', authMiddleware, (req: Request, res: Response): void => { + if (!requireAdmin(req, res)) return; + try { + const id = parseIntParam(req, res, 'id', 'suppression rule ID'); + if (id === null) return; + + const existing = DatabaseService.getInstance().getNotificationSuppressionRule(id); + if (!existing) { res.status(404).json({ error: 'Suppression rule not found' }); return; } + + DatabaseService.getInstance().deleteNotificationSuppressionRule(id); + deleteSuppressionRuleFromFleet(existing); + console.log(`[Suppression] Rule ${id} deleted`); + res.json({ success: true }); + } catch (error) { + console.error('Failed to delete notification suppression rule:', error); + res.status(500).json({ error: 'Failed to delete notification suppression rule' }); + } +}); + diff --git a/backend/src/services/CapabilityRegistry.ts b/backend/src/services/CapabilityRegistry.ts index 2d5cda75..16a0d4b5 100644 --- a/backend/src/services/CapabilityRegistry.ts +++ b/backend/src/services/CapabilityRegistry.ts @@ -25,6 +25,7 @@ export const CAPABILITIES = [ 'network-topology', 'notifications', 'notification-routing', + 'notification-suppression', 'host-console', 'container-exec', 'audit-log', diff --git a/backend/src/services/DatabaseService.ts b/backend/src/services/DatabaseService.ts index f238f311..b1443e82 100644 --- a/backend/src/services/DatabaseService.ts +++ b/backend/src/services/DatabaseService.ts @@ -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): 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)); + } + + 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)); + } + + public getNotificationSuppressionRule(id: number): NotificationSuppressionRule | undefined { + const row = this.db.prepare('SELECT * FROM notification_suppression_rules WHERE id = ?').get(id) as Record | undefined; + return row ? this.parseNotificationSuppressionRule(row) : undefined; + } + + public createNotificationSuppressionRule( + rule: Omit, + ): 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>, + ): 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> { @@ -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(` diff --git a/backend/src/services/NotificationService.ts b/backend/src/services/NotificationService.ts index 75bc3b51..8d619ba5 100644 --- a/backend/src/services/NotificationService.ts +++ b/backend/src/services/NotificationService.ts @@ -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( diff --git a/backend/src/utils/audit-summaries.ts b/backend/src/utils/audit-summaries.ts index cc372a03..97015ba0 100644 --- a/backend/src/utils/audit-summaries.ts +++ b/backend/src/utils/audit-summaries.ts @@ -72,6 +72,9 @@ export const AUDIT_ROUTE_SUMMARIES: Record = { 'PUT /notification-routes': 'Updated notification route', 'DELETE /notification-routes': 'Deleted notification route', 'POST /notification-routes/*/test': 'Tested notification route', + 'POST /notification-suppression-rules': 'Created notification suppression rule', + 'PUT /notification-suppression-rules': 'Updated notification suppression rule', + 'DELETE /notification-suppression-rules': 'Deleted notification suppression rule', // Webhooks 'POST /webhooks': 'Created webhook', diff --git a/docs/features/alerts-notifications.mdx b/docs/features/alerts-notifications.mdx index 6b1a62c1..1f4f72eb 100644 --- a/docs/features/alerts-notifications.mdx +++ b/docs/features/alerts-notifications.mdx @@ -101,6 +101,36 @@ Three icon actions appear on the right edge of each card: The masthead carries `SCOPE` (`global`), `ROUTES` (total), and `ENABLED` (count). +## Mute Rules + + + Admin role is required to create, edit, or delete mute rules. + + +Notification suppression rules **hide or drop** matching alerts. They do not send alerts elsewhere. Routing and suppression are separate: a rule can match the same alert as a route, but suppression is evaluated first and can block bell delivery, external channels, or both. + +Open **Settings · Notifications · Mute Rules** and click **+ Add mute rule**. + +| Field | Purpose | +|-------|---------| +| **Name** | A human label, up to 100 characters. | +| **Node scope** | `Any node` or a specific fleet node. Limits which node emits the alert before the rule can match. | +| **Stacks** *(optional)* | Stack names. Empty matches any stack. | +| **Labels** *(optional)* | Stack labels. Empty matches any label. | +| **Categories** *(optional)* | Notification categories. Empty matches any category. | +| **Severity** *(optional)* | One or more of info, warning, or error. Empty matches any severity. | +| **Apply to** | **Bell**, **External**, or **Both**. Bell skips the notification popover WebSocket push. External skips routing rules and global channels. | +| **Expiration** | Forever, 1 hour, 24 hours, or a custom date. Expired rules stop matching automatically. | +| **Enabled** | Toggle the rule without deleting it. | + +All non-empty matchers must match (AND). Suppressed alerts are still written to stack activity history; only delivery is affected. When a mute rule blocks delivery, the activity row shows a **Suppressed** badge with the matched rule name in a tooltip. + +Rules you create on the control instance replicate to remote nodes so alerts emitted on a remote stack honor the same suppression. From the bell, admins can open a row menu and choose **Mute this category**, **Mute notifications like this**, or **Mute this stack** to create a quick rule with default **Both** targeting. The same presets are available from stack menus (sidebar, stack header, activity tab), fleet node cards, and label groups. + +### Built-in bell quieting (not a mute rule) + +Sencho also hides one class of notification from the popover without a user rule: rows where the category is one of `deploy_success`, `stack_started`, `stack_stopped`, `stack_restarted`, or `image_update_applied`, and the row carries an `actor_username` other than `system`. These are confirmations of an action you just clicked. The rows are still persisted and still dispatched externally; only the bell render hides them. Use mute rules when you need configurable, operator-controlled muting. + ## Notification categories Every alert Sencho dispatches carries a category that you can filter on in the bell, target with a routing rule's **Categories** matcher, or reason about when reading audit history. diff --git a/docs/reference/settings.mdx b/docs/reference/settings.mdx index 5a4d3e52..362a15ac 100644 --- a/docs/reference/settings.mdx +++ b/docs/reference/settings.mdx @@ -419,6 +419,32 @@ See [Notification Routing](/features/alerts-notifications#notification-routing) --- +## Mute Rules + + + Creating, editing, and deleting mute rules is admin-only. + + +**Scope:** Global, admin-only + +Create notification suppression rules that mute or drop matching alerts from the bell, external channels, or both. Suppression is evaluated before routing. The masthead publishes **RULES** and **ACTIVE** counts. + +| Field | Description | +|-------|-------------| +| **Name** | Operator-facing label for the rule. | +| **Node** | Specific node, or all nodes if left empty. | +| **Stack patterns** | Stack names to match (multi-select). | +| **Labels** | Match stacks that carry any selected label. | +| **Categories** | Notification categories to suppress. | +| **Severity** | Info, warning, and/or error levels to suppress. | +| **Apply to** | Bell only, external channels only, or both. | +| **Expiration** | Forever, 1 hour, 24 hours, or a custom timestamp. | +| **Enabled** toggle | Disable a rule without deleting it. | + +See [Mute Rules](/features/alerts-notifications#mute-rules) for the full walkthrough, compose-first shortcuts, and bell quick-mute. + +--- + ## Image update checks **Scope:** Per-node (applies to the currently selected node) diff --git a/frontend/src/components/EditorLayout.tsx b/frontend/src/components/EditorLayout.tsx index 42116fd2..2e69e388 100644 --- a/frontend/src/components/EditorLayout.tsx +++ b/frontend/src/components/EditorLayout.tsx @@ -40,6 +40,7 @@ import type { SidebarActivityAction } from '@/components/sidebar/SidebarActivity import { useComposeDiffPreviewEnabled } from '@/hooks/use-compose-diff-preview-enabled'; import { useTopNavLabels } from '@/hooks/use-top-nav-labels'; import { useTopNavAlign } from '@/hooks/use-top-nav-align'; +import { useStackMuteActions } from '@/hooks/useMuteRuleActions'; import { toast } from '@/components/ui/toast-store'; import { useIsMobile } from '@/hooks/use-is-mobile'; import { MobileTabBar } from './MobileTabBar'; @@ -179,11 +180,14 @@ export default function EditorLayout() { fleetTab, setFleetTab, filterNodeId, setFilterNodeId, schedulePrefill, + muteRulePrefill, mobileNavOpen, setMobileNavOpen, handleOpenSettings, handlePrefillConsumed, + handleMutePrefillConsumed, handleNavigate, navItems, + openMuteRulesWithPrefill, } = navState; const { @@ -284,6 +288,8 @@ export default function EditorLayout() { const loadingAction = selectedFile ? (stackActionMap[selectedFile] ?? null) : null; const stackName = selectedFile || ''; + const stackDisplayName = selectedFile ? selectedFile.replace(/\.(yml|yaml)$/, '') : ''; + const stackMuteActions = useStackMuteActions(stackDisplayName, openMuteRulesWithPrefill); const { isDarkMode } = useTheme(); @@ -500,6 +506,7 @@ export default function EditorLayout() { onMobileBack={goToMobileList} onCloseEditor={() => stackActions.attemptLeaveEditor(() => setEditingCompose(false))} hasUnsavedChanges={stackActions.hasUnsavedChanges} + stackMuteActions={selectedFile ? stackMuteActions : undefined} /> ); @@ -680,6 +687,7 @@ export default function EditorLayout() { }, filterChip, onOpenCreate: can('stack:create') ? openCreateDialog : undefined, + openMuteRulesWithPrefill, }} activitySummary={activitySummary} onActivityAction={handleActivityAction} @@ -755,9 +763,12 @@ export default function EditorLayout() { onClearScheduledOpsFilter={() => setFilterNodeId(null)} schedulePrefill={schedulePrefill} onPrefillConsumed={handlePrefillConsumed} + muteRulePrefill={muteRulePrefill} + onMutePrefillConsumed={handleMutePrefillConsumed} notifications={notifications} onNavigateToStack={(stackFile) => { void stackActions.loadFile(stackFile); }} onOpenSettingsSection={(section) => openSettings(section)} + onOpenMuteRulesWithPrefill={openMuteRulesWithPrefill} onClearNotifications={clearAllNotifications} fleetUpdatesIntent={fleetUpdatesIntent} onFleetUpdatesIntentConsumed={handleFleetUpdatesIntentConsumed} diff --git a/frontend/src/components/EditorLayout/EditorView.tsx b/frontend/src/components/EditorLayout/EditorView.tsx index 96208d49..adfa3f7b 100644 --- a/frontend/src/components/EditorLayout/EditorView.tsx +++ b/frontend/src/components/EditorLayout/EditorView.tsx @@ -46,6 +46,7 @@ import { retryHandlerFor } from './recovery-retry'; import type { NotificationItem } from '../dashboard/types'; import type { Node } from '@/context/NodeContext'; import type { useAuth } from '@/context/AuthContext'; +import type { useStackMuteActions } from '@/hooks/useMuteRuleActions'; export interface ContainerInfo { Id: string; @@ -214,6 +215,8 @@ export interface EditorViewProps { // Mobile-only: notifications + more-menu cluster for the detail header right // slot (the global TopBar is dropped on the full-screen detail surface). headerActions?: React.ReactNode; + + stackMuteActions?: ReturnType; } export function EditorView(props: EditorViewProps) { @@ -270,6 +273,7 @@ export function EditorView(props: EditorViewProps) { onRefreshState, onDismissRecovery, panelStartedAt, + stackMuteActions, } = props; const monacoEditorRef = useRef(null); @@ -376,6 +380,7 @@ export function EditorView(props: EditorViewProps) { rollbackStack={rollbackStack} scanStackConfig={scanStackConfig} requestDeleteStack={requestDeleteStack} + stackMuteActions={stackMuteActions} /> {recoveryResult && loadingAction == null && ( @@ -616,6 +621,7 @@ export function EditorView(props: EditorViewProps) { applying={loadingAction === 'update'} canEdit={can('stack:edit', 'stack', stackName)} notifications={notifications} + stackMuteActions={stackMuteActions} /> )} diff --git a/frontend/src/components/EditorLayout/MobileStackDetail.tsx b/frontend/src/components/EditorLayout/MobileStackDetail.tsx index 995b02fd..e2a5a1cf 100644 --- a/frontend/src/components/EditorLayout/MobileStackDetail.tsx +++ b/frontend/src/components/EditorLayout/MobileStackDetail.tsx @@ -78,6 +78,7 @@ export function MobileStackDetail(props: EditorViewProps) { onRefreshState, onDismissRecovery, panelStartedAt, + stackMuteActions, } = props; const [segment, setSegment] = useState('logs'); @@ -152,6 +153,7 @@ export function MobileStackDetail(props: EditorViewProps) { rollbackStack={rollbackStack} scanStackConfig={scanStackConfig} requestDeleteStack={requestDeleteStack} + stackMuteActions={stackMuteActions} /> @@ -243,6 +245,7 @@ export function MobileStackDetail(props: EditorViewProps) { applying={loadingAction === 'update'} canEdit={canEditStack} notifications={notifications} + stackMuteActions={stackMuteActions} /> )} diff --git a/frontend/src/components/EditorLayout/ViewRouter.tsx b/frontend/src/components/EditorLayout/ViewRouter.tsx index a10cee12..36bc0295 100644 --- a/frontend/src/components/EditorLayout/ViewRouter.tsx +++ b/frontend/src/components/EditorLayout/ViewRouter.tsx @@ -12,6 +12,7 @@ import ResourcesView from '../ResourcesView'; import HomeDashboard from '../HomeDashboard'; import type { NotificationItem } from '../dashboard/types'; import type { ScheduleTaskPrefill } from '../ScheduledOperationsView'; +import type { MuteRuleDraft } from '@/lib/muteRules'; import type { ActiveView } from './hooks/useViewNavigationState'; import type { SecurityTab, FleetTab } from '@/lib/events'; @@ -81,9 +82,12 @@ export interface ViewRouterProps { onClearScheduledOpsFilter: () => void; schedulePrefill: ScheduleTaskPrefill | null; onPrefillConsumed: () => void; + muteRulePrefill: MuteRuleDraft | null; + onMutePrefillConsumed: () => void; notifications: NotificationItem[]; onNavigateToStack: (stackFile: string) => void; onOpenSettingsSection: (section: SectionId) => void; + onOpenMuteRulesWithPrefill?: (draft: MuteRuleDraft) => void; onClearNotifications: () => void; securityTab: SecurityTab; onSecurityTabChange: (tab: SecurityTab) => void; @@ -110,9 +114,12 @@ export function ViewRouter({ onClearScheduledOpsFilter, schedulePrefill, onPrefillConsumed, + muteRulePrefill, + onMutePrefillConsumed, notifications, onNavigateToStack, onOpenSettingsSection, + onOpenMuteRulesWithPrefill, onClearNotifications, securityTab, onSecurityTabChange, @@ -128,6 +135,9 @@ export function ViewRouter({ ); } @@ -186,6 +196,7 @@ export function ViewRouter({ Promise; scanStackConfig: () => Promise; requestDeleteStack: () => void; + stackMuteActions?: ReturnType; } // Breadcrumb + serif title + state pill + image ref + action bar. The action @@ -154,6 +157,7 @@ export function StackIdentityHeader({ rollbackStack, scanStackConfig, requestDeleteStack, + stackMuteActions, }: StackIdentityHeaderProps) { return (
@@ -228,8 +232,9 @@ export function StackIdentityHeader({ const canDelete = can('stack:delete', 'stack', stackName); const canRollback = canDeploy && backupInfo.exists; const canScan = trivy.available && isAdmin; + const canMute = stackMuteActions?.canMute ?? false; const hasOverflowExtras = canRollback || canScan; - const hasOverflow = hasOverflowExtras || canDelete; + const hasOverflow = hasOverflowExtras || canDelete || canMute; if (!canDeploy && !hasOverflow) return null; return (
@@ -287,7 +292,8 @@ export function StackIdentityHeader({ {stackMisconfigScanning ? 'Scanning...' : 'Scan config'} )} - {hasOverflowExtras && canDelete && } + {stackMuteActions && } + {(canRollback || canScan || stackMuteActions?.canMute) && canDelete && } {canDelete && ( ({ + useNodes: () => ({ hasCapability: () => false }), +})); + // buildMenuCtx derives canOpenApp from the active node plus the stack's // published port; only the fields it reads need to be real, the handler // closures are never invoked here. diff --git a/frontend/src/components/EditorLayout/hooks/useSidebarContextMenu.ts b/frontend/src/components/EditorLayout/hooks/useSidebarContextMenu.ts index 0c8dda6f..1e02f8c0 100644 --- a/frontend/src/components/EditorLayout/hooks/useSidebarContextMenu.ts +++ b/frontend/src/components/EditorLayout/hooks/useSidebarContextMenu.ts @@ -2,6 +2,15 @@ import { useCallback } from 'react'; import { apiFetch } from '@/lib/api'; import { toast } from '@/components/ui/toast-store'; import { buildServiceUrl } from '@/lib/serviceUrl'; +import { + createMuteRuleWithToast, + stackMuteAllDraft, + stackMuteDeploySuccessDraft, + stackMuteMonitorDraft, + labelMuteAllDraft, + labelMuteExternalDraft, + labelMuteLowPriorityDraft, +} from '@/lib/muteRules'; import type { StackMenuCtx } from '@/components/sidebar/sidebar-types'; import type { Label as StackLabel, LabelColor } from '../../label-types'; import type { OverlayState } from './useOverlayState'; @@ -9,6 +18,7 @@ import type { StackActionsHook } from './useStackActions'; import type { useStackListState } from './useStackListState'; import type { useViewNavigationState } from './useViewNavigationState'; import type { Node } from '@/context/NodeContext'; +import { useNodes } from '@/context/NodeContext'; import type { PermissionAction } from '@/context/AuthContext'; type StackListState = ReturnType; @@ -33,6 +43,7 @@ export function useSidebarContextMenu({ isAdmin, can, }: UseSidebarContextMenuOptions) { + const { hasCapability } = useNodes(); const buildMenuCtx = useCallback((file: string): StackMenuCtx => { const sName = file.replace(/\.(yml|yaml)$/, ''); const mainPort = stackListState.stackPorts[file]; @@ -40,6 +51,8 @@ export function useSidebarContextMenu({ // lifecycle affordances; the menu's status union stays three-state. const rawStatus = stackListState.stackStatuses[file] ?? 'unknown'; const stackStatus = rawStatus === 'partial' ? 'running' : rawStatus; + const nodeId = activeNode?.id ?? null; + const canMuteNotifications = isAdmin && hasCapability('notification-suppression'); return { stackStatus, // Only offer "Open App" when a browser-reachable URL can actually be built @@ -125,6 +138,31 @@ export function useSidebarContextMenu({ navState.setSchedulePrefill({ stackName: sName, nodeId: activeNode?.id ?? null }); navState.setActiveView('scheduled-ops'); }, + canMuteNotifications, + muteStackAll: () => { + void createMuteRuleWithToast(stackMuteAllDraft(sName, nodeId)); + }, + muteStackDeploySuccess: () => { + void createMuteRuleWithToast(stackMuteDeploySuccessDraft(sName, nodeId)); + }, + muteStackMonitor: () => { + void createMuteRuleWithToast(stackMuteMonitorDraft(sName, nodeId)); + }, + openStackMuteRules: () => { + navState.openMuteRulesWithPrefill(stackMuteAllDraft(sName, nodeId)); + }, + muteLabelAll: (labelId: number, labelName: string) => { + void createMuteRuleWithToast(labelMuteAllDraft(labelId, labelName, nodeId)); + }, + muteLabelExternal: (labelId: number, labelName: string) => { + void createMuteRuleWithToast(labelMuteExternalDraft(labelId, labelName, nodeId)); + }, + muteLabelLowPriority: (labelId: number, labelName: string) => { + void createMuteRuleWithToast(labelMuteLowPriorityDraft(labelId, labelName, nodeId)); + }, + openLabelMuteRules: (labelId: number, labelName: string) => { + navState.openMuteRulesWithPrefill(labelMuteAllDraft(labelId, labelName, nodeId)); + }, }; // Handlers from useStackActions, useOverlayState, useViewNavigationState are // useCallback-stabilized at their owner hooks, so listing the menu surface @@ -134,7 +172,8 @@ export function useSidebarContextMenu({ }, [ stackListState.stackStatuses, stackListState.stackPorts, isAdmin, stackListState.isPinned, stackListState.labels, stackListState.stackLabelMap, - stackListState.pin, stackListState.unpin, activeNode?.type, activeNode?.api_url, + stackListState.pin, stackListState.unpin, activeNode?.type, activeNode?.api_url, activeNode?.id, + hasCapability, navState.openMuteRulesWithPrefill, ]); return buildMenuCtx; diff --git a/frontend/src/components/EditorLayout/hooks/useViewNavigationState.ts b/frontend/src/components/EditorLayout/hooks/useViewNavigationState.ts index 2e31133e..41c195ff 100644 --- a/frontend/src/components/EditorLayout/hooks/useViewNavigationState.ts +++ b/frontend/src/components/EditorLayout/hooks/useViewNavigationState.ts @@ -12,6 +12,7 @@ import type { SenchoNavigateDetail } from '@/components/NodeManager'; import type { SecurityTab, FleetTab } from '@/lib/events'; import type { SectionId } from '@/components/settings/types'; import type { ScheduleTaskPrefill } from '@/components/ScheduledOperationsView'; +import type { MuteRuleDraft } from '@/lib/muteRules'; export type ActiveView = | 'dashboard' @@ -64,6 +65,7 @@ export function useViewNavigationState(options?: UseViewNavigationStateOptions) const [fleetTab, setFleetTab] = useState(null); const [filterNodeId, setFilterNodeId] = useState(null); const [schedulePrefill, setSchedulePrefill] = useState(null); + const [muteRulePrefill, setMuteRulePrefill] = useState(null); const [mobileNavOpen, setMobileNavOpen] = useState(false); const handleOpenSettings = useCallback((section?: SectionId) => { @@ -73,6 +75,14 @@ export function useViewNavigationState(options?: UseViewNavigationStateOptions) }, []); const handlePrefillConsumed = useCallback(() => setSchedulePrefill(null), []); + const handleMutePrefillConsumed = useCallback(() => setMuteRulePrefill(null), []); + + const openMuteRulesWithPrefill = useCallback((draft: MuteRuleDraft) => { + setMuteRulePrefill(draft); + setSettingsSection('notification-suppression'); + setActiveView('settings'); + setFilterNodeId(null); + }, []); const handleNavigate = useCallback((value: string) => { if (value === activeView) return; @@ -166,9 +176,12 @@ export function useViewNavigationState(options?: UseViewNavigationStateOptions) fleetTab, setFleetTab, filterNodeId, setFilterNodeId, schedulePrefill, setSchedulePrefill, + muteRulePrefill, setMuteRulePrefill, mobileNavOpen, setMobileNavOpen, handleOpenSettings, handlePrefillConsumed, + handleMutePrefillConsumed, + openMuteRulesWithPrefill, handleNavigate, navItems, } as const; diff --git a/frontend/src/components/FleetView.tsx b/frontend/src/components/FleetView.tsx index c15855f4..8661767e 100644 --- a/frontend/src/components/FleetView.tsx +++ b/frontend/src/components/FleetView.tsx @@ -32,11 +32,13 @@ import { DependencyMapTab } from './fleet/DependencyMapTab'; import { useNodeActions } from './nodes/useNodeActions'; import type { FleetTab } from '@/lib/events'; import type { SectionId } from '@/components/settings/types'; +import type { MuteRuleDraft } from '@/lib/muteRules'; interface FleetViewProps { onNavigateToNode: (nodeId: number, stackName: string) => void; /** Opens a Settings section (used to send "Add node" to Settings > Nodes). */ onOpenSettingsSection?: (section: SectionId) => void; + onOpenMuteRulesWithPrefill?: (draft: MuteRuleDraft) => void; fleetUpdatesIntent?: { tab: 'nodes' | 'changelog' } | null; onFleetUpdatesIntentConsumed?: () => void; /** Deep-link target tab (e.g. 'snapshots' from the stack storage warning). */ @@ -44,7 +46,7 @@ interface FleetViewProps { onFleetTabConsumed?: () => void; } -export function FleetView({ onNavigateToNode, onOpenSettingsSection, fleetUpdatesIntent, onFleetUpdatesIntentConsumed, fleetTab, onFleetTabConsumed }: FleetViewProps) { +export function FleetView({ onNavigateToNode, onOpenSettingsSection, onOpenMuteRulesWithPrefill, fleetUpdatesIntent, onFleetUpdatesIntentConsumed, fleetTab, onFleetTabConsumed }: FleetViewProps) { const { isPaid } = useLicense(); const { isAdmin } = useAuth(); @@ -221,6 +223,7 @@ export function FleetView({ onNavigateToNode, onOpenSettingsSection, fleetUpdate onCordonChange={() => { void overview.fetchOverview(true); }} onEditNode={isAdmin ? openEdit : undefined} onDeleteNode={isAdmin ? openDelete : undefined} + onOpenMuteRulesWithPrefill={onOpenMuteRulesWithPrefill} onAddNode={isAdmin && onOpenSettingsSection ? () => onOpenSettingsSection('nodes') : undefined} onCheckUpdates={updateStatus.checkUpdates} checkingUpdates={updateStatus.checkingUpdates} diff --git a/frontend/src/components/FleetView/NodeCard.tsx b/frontend/src/components/FleetView/NodeCard.tsx index 587a933d..8c95177f 100644 --- a/frontend/src/components/FleetView/NodeCard.tsx +++ b/frontend/src/components/FleetView/NodeCard.tsx @@ -14,6 +14,9 @@ import { DropdownMenuItem, DropdownMenuTrigger, } from '@/components/ui/dropdown-menu'; +import { NodeMuteSubmenu } from '@/components/mute/MuteMenuItems'; +import { useNodeMuteActions } from '@/hooks/useMuteRuleActions'; +import type { MuteRuleDraft } from '@/lib/muteRules'; import { formatBytes } from '@/lib/utils'; import { apiFetch } from '@/lib/api'; import { toast } from '@/components/ui/toast-store'; @@ -42,6 +45,7 @@ export interface NodeCardProps { onCordonChange?: () => void; onEdit?: (node: Node) => void; onDelete?: (node: Node) => void; + onOpenMuteRulesWithPrefill?: (draft: MuteRuleDraft) => void; } // --- Sub-Components --- @@ -59,7 +63,7 @@ function UsageBar({ percent, color }: { percent: number; color: string }) { // --- Main Export --- -export function NodeCard({ node, onNavigate, labelMap, updateStatus, onUpdate, updatingNodeId, onRetryUpdate, onDismissUpdate, onCordonChange, onEdit, onDelete }: NodeCardProps) { +export function NodeCard({ node, onNavigate, labelMap, updateStatus, onUpdate, updatingNodeId, onRetryUpdate, onDismissUpdate, onCordonChange, onEdit, onDelete, onOpenMuteRulesWithPrefill }: NodeCardProps) { const [expanded, setExpanded] = useState(false); const [stacks, setStacks] = useState(node.stacks); const [loadingStacks, setLoadingStacks] = useState(false); @@ -77,7 +81,12 @@ export function NodeCard({ node, onNavigate, labelMap, updateStatus, onUpdate, u // (requirePermission('node:manage','node',id) + requirePaid). Gating on tier // alone would surface the control to deployer/viewer/auditor users whose calls 403. const canCordon = isPaid && can('node:manage', 'node', String(node.id)); - const showMenu = canEdit || canDelete || canCordon; + const nodeMuteActions = useNodeMuteActions( + node.id, + node.name, + onOpenMuteRulesWithPrefill ?? (() => {}), + ); + const showMenu = canEdit || canDelete || canCordon || (nodeMuteActions.canMute && Boolean(onOpenMuteRulesWithPrefill)); const isOnline = node.status === 'online'; const isLocal = node.type === 'local'; @@ -182,6 +191,7 @@ export function NodeCard({ node, onNavigate, labelMap, updateStatus, onUpdate, u {node.cordoned ? 'Uncordon node' : 'Cordon node'} )} + {onOpenMuteRulesWithPrefill && }
diff --git a/frontend/src/components/FleetView/OverviewTab.tsx b/frontend/src/components/FleetView/OverviewTab.tsx index 50cbaade..d4c9bce1 100644 --- a/frontend/src/components/FleetView/OverviewTab.tsx +++ b/frontend/src/components/FleetView/OverviewTab.tsx @@ -8,6 +8,7 @@ import type { FleetTopologyNode, LayoutMode, SavedPositions } from '@/lib/fleet- import type { Label as StackLabel } from '../label-types'; import type { Node } from '@/context/NodeContext'; import type { FleetNode, NodeUpdateStatus, ViewMode, FleetPreferences, FleetPaletteEntry } from './types'; +import type { MuteRuleDraft } from '@/lib/muteRules'; interface OverviewTabProps { loading: boolean; @@ -35,6 +36,7 @@ interface OverviewTabProps { onCordonChange?: () => void; onEditNode?: (node: Node) => void; onDeleteNode?: (node: Node) => void; + onOpenMuteRulesWithPrefill?: (draft: MuteRuleDraft) => void; topologyMode: LayoutMode; onTopologyModeChange: (mode: LayoutMode) => void; topologyPositions: SavedPositions; @@ -70,6 +72,7 @@ export function OverviewTab({ onCordonChange, onEditNode, onDeleteNode, + onOpenMuteRulesWithPrefill, topologyMode, onTopologyModeChange, topologyPositions, @@ -149,6 +152,7 @@ export function OverviewTab({ onCordonChange={onCordonChange} onEdit={onEditNode} onDelete={onDeleteNode} + onOpenMuteRulesWithPrefill={onOpenMuteRulesWithPrefill} /> ))}
diff --git a/frontend/src/components/FleetView/__tests__/NodeCard.test.tsx b/frontend/src/components/FleetView/__tests__/NodeCard.test.tsx index 8c526b7f..c0d1b726 100644 --- a/frontend/src/components/FleetView/__tests__/NodeCard.test.tsx +++ b/frontend/src/components/FleetView/__tests__/NodeCard.test.tsx @@ -34,7 +34,7 @@ function baseProps(node: FleetNode) { } beforeEach(() => { - useNodesMock.mockReturnValue({ nodes: [] }); + useNodesMock.mockReturnValue({ nodes: [], hasCapability: vi.fn(() => false) }); useAuthMock.mockReturnValue({ isAdmin: true, can: vi.fn(() => true) }); useLicenseMock.mockReturnValue({ isPaid: false }); }); diff --git a/frontend/src/components/NotificationPanel.tsx b/frontend/src/components/NotificationPanel.tsx index dc1187c3..5a7b4e82 100644 --- a/frontend/src/components/NotificationPanel.tsx +++ b/frontend/src/components/NotificationPanel.tsx @@ -9,6 +9,7 @@ import { Trash2, SlidersHorizontal, CheckCheck, + MoreHorizontal, } from 'lucide-react'; import type { LucideIcon } from 'lucide-react'; import { Button } from '@/components/ui/button'; @@ -22,10 +23,18 @@ import { SelectValue, } from '@/components/ui/select'; import { cn } from '@/lib/utils'; -import type { NotificationCategory, NotificationItem } from './dashboard/types'; -import type { Node } from '@/context/NodeContext'; +import { createMuteFromNotification } from '@/lib/muteRules'; +import { useAuth } from '@/context/AuthContext'; +import { + DropdownMenu, + DropdownMenuContent, + DropdownMenuItem, + DropdownMenuTrigger, +} from '@/components/ui/dropdown-menu'; import { CATEGORY_LABELS } from '@/lib/notificationCategories'; import { countVisibleUnread, filterPanelVisible } from '@/lib/notificationVisibility'; +import type { NotificationCategory, NotificationItem } from './dashboard/types'; +import type { Node } from '@/context/NodeContext'; const NODE_FILTER_ALL = 'all' as const; const CATEGORY_FILTER_ALL = 'all' as const; @@ -128,6 +137,7 @@ export function NotificationPanel({ onNavigate, onNavigateChangelog, }: NotificationPanelProps) { + const { isAdmin } = useAuth(); const [filter, setFilter] = useState('all'); const [nodeFilter, setNodeFilter] = useState(NODE_FILTER_ALL); const [categoryFilter, setCategoryFilter] = useState(CATEGORY_FILTER_ALL); @@ -369,6 +379,7 @@ export function NotificationPanel({ onDelete={onDelete} onNavigate={onNavigate ? handleNavigate : undefined} onNavigateChangelog={onNavigateChangelog} + canMute={isAdmin} /> ))} @@ -386,9 +397,17 @@ interface NotificationRowProps { onDelete: (notif: NotificationItem) => void; onNavigate?: (notif: NotificationItem) => void; onNavigateChangelog?: (notif: NotificationItem) => void; + canMute?: boolean; } -function NotificationRow({ notif, showNodeName, onDelete, onNavigate, onNavigateChangelog }: NotificationRowProps) { +async function createQuickSuppressionRule( + notif: NotificationItem, + mode: 'category' | 'similar' | 'stack', +): Promise { + await createMuteFromNotification(notif, mode); +} + +function NotificationRow({ notif, showNodeName, onDelete, onNavigate, onNavigateChangelog, canMute }: NotificationRowProps) { const config = LEVEL_CONFIG[notif.level]; const Icon = config.icon; const isUnread = !notif.is_read; @@ -470,15 +489,48 @@ function NotificationRow({ notif, showNodeName, onDelete, onNavigate, onNavigate ) : (
{content}
)} - +
+ {canMute ? ( + + + + + + {notif.category ? ( + void createQuickSuppressionRule(notif, 'category')}> + Mute this category + + ) : null} + void createQuickSuppressionRule(notif, 'similar')}> + Mute notifications like this + + {notif.stack_name ? ( + void createQuickSuppressionRule(notif, 'stack')}> + Mute this stack + + ) : null} + + + ) : null} + +
); } diff --git a/frontend/src/components/StackAnatomyPanel.tsx b/frontend/src/components/StackAnatomyPanel.tsx index a5710991..1ee8b61b 100644 --- a/frontend/src/components/StackAnatomyPanel.tsx +++ b/frontend/src/components/StackAnatomyPanel.tsx @@ -18,6 +18,8 @@ import EnvironmentPanel from './stack/EnvironmentPanel'; import StackNetworkingPanel from './stack/StackNetworkingPanel'; import { useNodes } from '@/context/NodeContext'; import type { NotificationItem } from '@/components/dashboard/types'; +import { ActivityMuteKebab } from '@/components/mute/MuteMenuItems'; +import type { useStackMuteActions } from '@/hooks/useMuteRuleActions'; interface StackAnatomyPanelProps { stackName: string; @@ -32,6 +34,7 @@ interface StackAnatomyPanelProps { canEdit: boolean; applying?: boolean; notifications?: NotificationItem[]; + stackMuteActions?: ReturnType; } type SemverBump = 'none' | 'patch' | 'minor' | 'major' | 'unknown'; @@ -82,6 +85,7 @@ export default function StackAnatomyPanel({ canEdit, applying = false, notifications, + stackMuteActions, }: StackAnatomyPanelProps) { const anatomy = useMemo(() => parseAnatomy(content), [content]); const envKeys = useMemo(() => parseEnvKeys(envContent), [envContent]); @@ -409,6 +413,7 @@ export default function StackAnatomyPanel({ edit )} + {stackMuteActions && } diff --git a/frontend/src/components/dashboard/types.ts b/frontend/src/components/dashboard/types.ts index d9103fe7..95b9dd9c 100644 --- a/frontend/src/components/dashboard/types.ts +++ b/frontend/src/components/dashboard/types.ts @@ -54,6 +54,11 @@ export type NotificationCategory = | 'autoheal_triggered' | 'monitor_alert' | 'scan_finding' + | 'drift_detected' + | 'drift_resolved' + | 'update_started' + | 'health_gate_passed' + | 'health_gate_failed' | 'node_update_available' | 'system'; diff --git a/frontend/src/components/mute/MuteMenuItems.tsx b/frontend/src/components/mute/MuteMenuItems.tsx new file mode 100644 index 00000000..6e66c834 --- /dev/null +++ b/frontend/src/components/mute/MuteMenuItems.tsx @@ -0,0 +1,128 @@ +import { BellOff, MoreVertical } from 'lucide-react'; +import type { MouseEvent } from 'react'; +import { + DropdownMenu, + DropdownMenuContent, + DropdownMenuItem, + DropdownMenuSeparator, + DropdownMenuSub, + DropdownMenuSubContent, + DropdownMenuSubTrigger, + DropdownMenuTrigger, +} from '@/components/ui/dropdown-menu'; +import type { useStackMuteActions, useLabelMuteActions, useNodeMuteActions } from '@/hooks/useMuteRuleActions'; + +type StackMuteActions = ReturnType; +type LabelMuteActions = ReturnType; +type NodeMuteActions = ReturnType; + +export function StackMuteSubmenu({ actions }: { actions: StackMuteActions }) { + if (!actions.canMute) return null; + return ( + + + + Mute + + + Mute notifications for this stack + Mute deploy success noise + Mute monitor alerts for this stack + + Manage stack mute rules + + + ); +} + +export function LabelMuteSubmenu({ actions }: { actions: LabelMuteActions }) { + if (!actions.canMute) return null; + return ( + + + + Mute + + + Mute notifications for this label + Mute external alerts for this label + Mute low-priority stack alerts + + Manage label mute rules + + + ); +} + +export function NodeMuteSubmenu({ actions }: { actions: NodeMuteActions }) { + if (!actions.canMute) return null; + return ( + + + + Mute + + + Mute node notifications + Mute update notifications for this node + Mute monitor alerts on this node + + Manage node mute rules + + + ); +} + +export function LabelGroupMuteKebab({ + actions, +}: { + actions: LabelMuteActions; +}) { + if (!actions.canMute) return null; + return ( + + + + + e.stopPropagation()}> + Mute notifications for this label + Mute external alerts for this label + Mute low-priority stack alerts + + Manage label mute rules + + + ); +} + +export function ActivityMuteKebab({ actions }: { actions: StackMuteActions }) { + if (!actions.canMute) return null; + return ( + + + + + + Mute notifications for this stack + Mute deploy success noise + Mute monitor alerts for this stack + + Manage stack mute rules + + + ); +} diff --git a/frontend/src/components/settings/LabelsSection.tsx b/frontend/src/components/settings/LabelsSection.tsx index 9b1115a7..b5d0a67d 100644 --- a/frontend/src/components/settings/LabelsSection.tsx +++ b/frontend/src/components/settings/LabelsSection.tsx @@ -1,5 +1,5 @@ import { useState, useEffect, useCallback } from 'react'; -import { Plus, Pencil, Trash2 } from 'lucide-react'; +import { Plus, Pencil, Trash2, BellOff } from 'lucide-react'; import { Button } from '@/components/ui/button'; import { Input } from '@/components/ui/input'; import { Modal, ModalHeader, ModalBody, ModalFooter, ConfirmModal } from '@/components/ui/modal'; @@ -13,13 +13,17 @@ import { LABEL_COLORS, MAX_LABELS_PER_NODE, type Label, type LabelColor } from ' import { SettingsCallout } from './SettingsCallout'; import { SettingsPrimaryButton } from './SettingsActions'; import { useMastheadStats } from './MastheadStatsContext'; +import { labelMuteAllDraft, type MuteRuleDraft } from '@/lib/muteRules'; +import { useCanMuteNotifications } from '@/hooks/useMuteRuleActions'; interface LabelsSectionProps { onLabelsChanged?: () => void; + onOpenMuteRulesWithPrefill?: (draft: MuteRuleDraft) => void; } -export function LabelsSection({ onLabelsChanged }: LabelsSectionProps = {}) { - const { can } = useAuth(); +export function LabelsSection({ onLabelsChanged, onOpenMuteRulesWithPrefill }: LabelsSectionProps = {}) { + const { can, isAdmin } = useAuth(); + const canMute = useCanMuteNotifications(); // Mirrors the backend `requirePermission('stack:edit')` guard on // POST/PUT/DELETE /api/labels, keeping a viewer/deployer/auditor from // clicking through to a guaranteed 403. @@ -161,6 +165,17 @@ export function LabelsSection({ onLabelsChanged }: LabelsSectionProps = {}) { {assignmentCounts[label.id] || 0} stack{(assignmentCounts[label.id] || 0) !== 1 ? 's' : ''} + {canMute && onOpenMuteRulesWithPrefill && isAdmin && ( + + )} {canMutate && ( <> + + ))} + + )} + + +
+ + + {formLabelIds.length > 0 && ( +
+ {formLabelIds.map((id) => { + const lbl = labelOptions.find((l) => l.id === id); + return ( + + {lbl?.name ?? `Label ${id}`} + + + ); + })} +
+ )} +
+ +
+ + + {formCategories.length > 0 && ( +
+ {formCategories.map((c) => ( + + {CATEGORY_LABELS[c]} + + + ))} +
+ )} +
+ +
+ + + {formLevels.length > 0 && ( +
+ {formLevels.map((l) => ( + + {LEVEL_LABELS[l]} + + + ))} +
+ )} +

Leave matchers blank to match any value. All non-empty filters must match (AND).

+
+ +
+ + +
+ +
+ + + {formExpirationPreset === 'custom' && ( + setFormCustomExpiry(e.target.value)} /> + )} +
+ +
+ + + {formEnabled ? 'Enabled' : 'Disabled'} + +
+ + Cancel} + primary={ + + {saving ? <>Saving : editingId ? 'Update' : 'Create'} + + } + /> + + + {loading && ( +
+ + +
+ )} + + {!loading && rules.length === 0 && ( + } + title="No mute rules configured" + subtitle="Alerts follow your routing and global channels unless a mute rule matches." + /> + )} + + {!loading && rules.map((rule) => ( +
+
+
+ + {rule.name} + {APPLIES_TO_LABELS[rule.applies_to]} + {rule.node_id !== null && ( + + {nodes.find((n) => n.id === rule.node_id)?.name ?? `node:${rule.node_id}`} + + )} + {!rule.enabled && Disabled} + {rule.expires_at != null && rule.expires_at <= Date.now() && ( + Expired + )} +
+
+ handleToggleEnabled(rule)} className="scale-75" /> + + +
+
+
+ {rule.stack_patterns.map((s) => {s})} + {rule.label_ids?.map((id) => { + const lbl = labelOptions.find((l) => l.id === id); + return {lbl?.name ?? `label:${id}`}; + })} + {rule.categories?.map((c) => {CATEGORY_LABELS[c as NotificationCategory] ?? c})} + {rule.levels?.map((l) => {LEVEL_LABELS[l]})} + {rule.stack_patterns.length === 0 && !rule.label_ids?.length && !rule.categories?.length && !rule.levels?.length && ( + Matches all alerts + )} + | + Expires: {formatExpiry(rule.expires_at)} +
+
+ ))} + + { if (!open) setDeleteRuleId(null); }} + variant="destructive" + kicker="MUTE RULES · DELETE · IRREVERSIBLE" + title="Delete mute rule" + confirmLabel="Delete" + onConfirm={handleDelete} + > +

+ Deletes {deleteTarget?.name ?? 'this rule'}. Matching alerts will deliver normally again. +

+
+ + + ); +} diff --git a/frontend/src/components/settings/SettingsPage.tsx b/frontend/src/components/settings/SettingsPage.tsx index cd792d31..3b46040a 100644 --- a/frontend/src/components/settings/SettingsPage.tsx +++ b/frontend/src/components/settings/SettingsPage.tsx @@ -24,6 +24,7 @@ import { scopeLabel, } from './index'; import type { SectionId, SettingsItemMeta, VisibilityContext } from './index'; +import type { MuteRuleDraft } from '@/lib/muteRules'; import { SettingsSidebar } from './SettingsSidebar'; import { SettingsSectionContent } from './SettingsSectionContent'; import { MastheadStatsProvider, useMastheadStatsValue } from './MastheadStatsContext'; @@ -31,6 +32,9 @@ import { MastheadStatsProvider, useMastheadStatsValue } from './MastheadStatsCon interface SettingsPageProps { currentSection: SectionId; onSectionChange: (section: SectionId) => void; + muteRulePrefill?: MuteRuleDraft | null; + onMutePrefillConsumed?: () => void; + onOpenMuteRulesWithPrefill?: (draft: MuteRuleDraft) => void; } export function SettingsPage(props: SettingsPageProps) { @@ -41,7 +45,13 @@ export function SettingsPage(props: SettingsPageProps) { ); } -function SettingsPageInner({ currentSection, onSectionChange }: SettingsPageProps) { +function SettingsPageInner({ + currentSection, + onSectionChange, + muteRulePrefill = null, + onMutePrefillConsumed, + onOpenMuteRulesWithPrefill, +}: SettingsPageProps) { const { isAdmin } = useAuth(); const { isPaid } = useLicense(); const { activeNode } = useNodes(); @@ -197,6 +207,9 @@ function SettingsPageInner({ currentSection, onSectionChange }: SettingsPageProp sectionId={safeSection} onDirtyChange={handleDirtyChange} showDescription + muteRulePrefill={muteRulePrefill} + onMutePrefillConsumed={onMutePrefillConsumed} + onOpenMuteRulesWithPrefill={onOpenMuteRulesWithPrefill} /> diff --git a/frontend/src/components/settings/SettingsSectionContent.tsx b/frontend/src/components/settings/SettingsSectionContent.tsx index 885e0f72..90b37554 100644 --- a/frontend/src/components/settings/SettingsSectionContent.tsx +++ b/frontend/src/components/settings/SettingsSectionContent.tsx @@ -22,6 +22,7 @@ import { getSettingsItem, } from './index'; import type { SectionId } from './index'; +import type { MuteRuleDraft } from '@/lib/muteRules'; import LazyBoundary from '../LazyBoundary'; import { SectionGate } from './SectionGate'; @@ -44,6 +45,9 @@ const LabelsSection = lazy(() => const NotificationRoutingSection = lazy(() => import('./NotificationRoutingSection').then(m => ({ default: m.NotificationRoutingSection })), ); +const NotificationSuppressionSection = lazy(() => + import('./NotificationSuppressionSection').then(m => ({ default: m.NotificationSuppressionSection })), +); const CloudBackupSection = lazy(() => import('./CloudBackupSection').then(m => ({ default: m.CloudBackupSection })), ); @@ -72,6 +76,9 @@ function SectionSkeleton() { function renderSection( sectionId: SectionId, onDirtyChange: (section: SectionId, dirty: boolean) => void, + muteRulePrefill: MuteRuleDraft | null | undefined, + onMutePrefillConsumed: (() => void) | undefined, + onOpenMuteRulesWithPrefill: ((draft: MuteRuleDraft) => void) | undefined, ) { switch (sectionId) { case 'account': return ; @@ -81,7 +88,7 @@ function renderSection( case 'sso': return ; case 'api-tokens': return ; case 'registries': return ; - case 'labels': return ; + case 'labels': return ; case 'host-alerts': return onDirtyChange('host-alerts', d)} />; case 'container-alerts': return onDirtyChange('container-alerts', d)} />; case 'docker-storage': return onDirtyChange('docker-storage', d)} />; @@ -89,6 +96,12 @@ function renderSection( case 'fleet-mesh': return onDirtyChange('fleet-mesh', d)} />; case 'notifications': return ; case 'notification-routing': return ; + case 'notification-suppression': return ( + + ); case 'webhooks': return ; case 'cloud-backup': return ; case 'developer': return onDirtyChange('developer', d)} />; @@ -109,6 +122,9 @@ interface SettingsSectionContentProps { onDirtyChange: (section: SectionId, dirty: boolean) => void; /** Render the section's lead description paragraph above the content. */ showDescription?: boolean; + muteRulePrefill?: MuteRuleDraft | null; + onMutePrefillConsumed?: () => void; + onOpenMuteRulesWithPrefill?: (draft: MuteRuleDraft) => void; } /** @@ -117,14 +133,18 @@ interface SettingsSectionContentProps { * the desktop SettingsPage and the mobile settings screen so the section switch, * lazy splitting, and gating live in exactly one place. */ -export function SettingsSectionContent({ sectionId, onDirtyChange, showDescription }: SettingsSectionContentProps) { +export function SettingsSectionContent({ + sectionId, + onDirtyChange, + showDescription, + muteRulePrefill, + onMutePrefillConsumed, + onOpenMuteRulesWithPrefill, +}: SettingsSectionContentProps) { const item = getSettingsItem(sectionId); - // Memoize the section element so unrelated re-renders of the host page (the - // command palette opening, a dirty-flag toggle) do not re-render the active - // section. onDirtyChange is stable from both call sites. const element = useMemo( - () => renderSection(sectionId, onDirtyChange), - [sectionId, onDirtyChange], + () => renderSection(sectionId, onDirtyChange, muteRulePrefill, onMutePrefillConsumed, onOpenMuteRulesWithPrefill), + [sectionId, onDirtyChange, muteRulePrefill, onMutePrefillConsumed, onOpenMuteRulesWithPrefill], ); return ( <> diff --git a/frontend/src/components/settings/__tests__/registry.test.ts b/frontend/src/components/settings/__tests__/registry.test.ts index d2fa6e65..4106c3ff 100644 --- a/frontend/src/components/settings/__tests__/registry.test.ts +++ b/frontend/src/components/settings/__tests__/registry.test.ts @@ -82,6 +82,7 @@ describe('settings registry', () => { const byId = new Map(SETTINGS_ITEMS.map(i => [i.id, i])); expect(byId.get('notifications')?.label).toBe('Channels'); expect(byId.get('notification-routing')?.label).toBe('Notification Routing'); + expect(byId.get('notification-suppression')?.label).toBe('Mute Rules'); }); it('no longer registers the standalone Vulnerability Scanning section (moved to the Security page)', () => { diff --git a/frontend/src/components/settings/registry.ts b/frontend/src/components/settings/registry.ts index 7843ab45..7cb82c13 100644 --- a/frontend/src/components/settings/registry.ts +++ b/frontend/src/components/settings/registry.ts @@ -220,6 +220,17 @@ export const SETTINGS_ITEMS: readonly SettingsItemMeta[] = [ adminOnly: true, hiddenOnRemote: true, }, + { + id: 'notification-suppression', + group: 'notifications', + label: 'Mute Rules', + description: 'Mute or drop matching alerts from the bell, external channels, or both.', + keywords: ['mute', 'suppress', 'silence', 'hide', 'bell', 'rules'], + tier: null, + scope: 'global', + adminOnly: true, + hiddenOnRemote: true, + }, // Automation { id: 'image-updates', diff --git a/frontend/src/components/settings/types.ts b/frontend/src/components/settings/types.ts index 90901dd6..06fe6288 100644 --- a/frontend/src/components/settings/types.ts +++ b/frontend/src/components/settings/types.ts @@ -69,6 +69,7 @@ export type SectionId = | 'app-store' | 'stacks' | 'notification-routing' + | 'notification-suppression' | 'recovery' | 'support' | 'about'; diff --git a/frontend/src/components/sidebar/StackContextMenu.tsx b/frontend/src/components/sidebar/StackContextMenu.tsx index f762d38c..4910205c 100644 --- a/frontend/src/components/sidebar/StackContextMenu.tsx +++ b/frontend/src/components/sidebar/StackContextMenu.tsx @@ -1,5 +1,5 @@ import { useState, type ReactNode } from 'react'; -import { Check, Plus } from 'lucide-react'; +import { Check, Plus, BellOff } from 'lucide-react'; import { ContextMenu, ContextMenuContent, ContextMenuItem, ContextMenuSeparator, ContextMenuSub, ContextMenuSubContent, ContextMenuSubTrigger, ContextMenuTrigger, @@ -72,6 +72,42 @@ function LabelsSub({ item, ctx }: { item: MenuItem; ctx: StackMenuCtx }) { Manage labels... + {ctx.canMuteNotifications && ctx.labels.length > 0 && ( + <> + + + + + Mute label… + + + {ctx.labels.map(label => ( + + + + {label.name} + + + ctx.muteLabelAll(label.id, label.name)}> + Mute notifications for this label + + ctx.muteLabelExternal(label.id, label.name)}> + Mute external alerts for this label + + ctx.muteLabelLowPriority(label.id, label.name)}> + Mute low-priority stack alerts + + + ctx.openLabelMuteRules(label.id, label.name)}> + Manage label mute rules + + + + ))} + + + + )} )} @@ -79,8 +115,44 @@ function LabelsSub({ item, ctx }: { item: MenuItem; ctx: StackMenuCtx }) { ); } +function SimpleSub({ item, MenuSub, MenuSubTrigger, MenuSubContent, MenuItem }: { + item: MenuItem; + MenuSub: typeof ContextMenuSub; + MenuSubTrigger: typeof ContextMenuSubTrigger; + MenuSubContent: typeof ContextMenuSubContent; + MenuItem: typeof ContextMenuItem; +}) { + return ( + + + + {item.label} + + + {item.subItems?.map((sub) => ( + sub.onSelect()}> + {sub.label} + + ))} + + + ); +} + function renderItem(item: MenuItem, ctx: StackMenuCtx) { if (item.id === 'labels') return ; + if (item.subItems?.length) { + return ( + + ); + } return ( void; variant?: 'default' | 'pinned'; + headerActions?: ReactNode; children: ReactNode; } -export function StackGroup({ id, label, count, collapsed, onToggle, variant = 'default', children }: StackGroupProps) { +export function StackGroup({ id, label, count, collapsed, onToggle, variant = 'default', headerActions, children }: StackGroupProps) { const isPinned = variant === 'pinned'; const labelColor = isPinned ? 'text-brand/90' : 'text-stat-subtitle'; return ( @@ -32,6 +33,7 @@ export function StackGroup({ id, label, count, collapsed, onToggle, variant = 'd {isPinned ? '★ ' : ''}{label} + {headerActions} {count} {collapsed ? diff --git a/frontend/src/components/sidebar/StackKebabMenu.tsx b/frontend/src/components/sidebar/StackKebabMenu.tsx index d7025df8..2bbc5963 100644 --- a/frontend/src/components/sidebar/StackKebabMenu.tsx +++ b/frontend/src/components/sidebar/StackKebabMenu.tsx @@ -1,5 +1,5 @@ import { useState } from 'react'; -import { MoreVertical, Check, Plus } from 'lucide-react'; +import { MoreVertical, Check, Plus, BellOff } from 'lucide-react'; import { Button } from '@/components/ui/button'; import { DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuSeparator, @@ -72,6 +72,42 @@ function LabelsSub({ item, ctx }: { item: MenuItem; ctx: StackMenuCtx }) { Manage labels... + {ctx.canMuteNotifications && ctx.labels.length > 0 && ( + <> + + + + + Mute label… + + + {ctx.labels.map(label => ( + + + + {label.name} + + + ctx.muteLabelAll(label.id, label.name)}> + Mute notifications for this label + + ctx.muteLabelExternal(label.id, label.name)}> + Mute external alerts for this label + + ctx.muteLabelLowPriority(label.id, label.name)}> + Mute low-priority stack alerts + + + ctx.openLabelMuteRules(label.id, label.name)}> + Manage label mute rules + + + + ))} + + + + )} )} @@ -79,8 +115,44 @@ function LabelsSub({ item, ctx }: { item: MenuItem; ctx: StackMenuCtx }) { ); } +function SimpleSub({ item, MenuSub, MenuSubTrigger, MenuSubContent, MenuItem }: { + item: MenuItem; + MenuSub: typeof DropdownMenuSub; + MenuSubTrigger: typeof DropdownMenuSubTrigger; + MenuSubContent: typeof DropdownMenuSubContent; + MenuItem: typeof DropdownMenuItem; +}) { + return ( + + + + {item.label} + + + {item.subItems?.map((sub) => ( + sub.onSelect()}> + {sub.label} + + ))} + + + ); +} + function renderItem(item: MenuItem, ctx: StackMenuCtx) { if (item.id === 'labels') return ; + if (item.subItems?.length) { + return ( + + ); + } return ( void; + openMuteRulesWithPrefill?: (draft: MuteRuleDraft) => void; } interface BuiltGroup { @@ -63,6 +67,8 @@ interface BuiltGroup { count: number; files: string[]; variant?: 'default' | 'pinned'; + labelId?: number; + labelName?: string; } function buildGroups( @@ -101,6 +107,8 @@ function buildGroups( result.push({ kind: 'labeled', id: `label:${label.id}`, label: label.name.toUpperCase(), count: bucketFiles.length, files: bucketFiles, + labelId: label.id, + labelName: label.name, }); } @@ -111,6 +119,19 @@ function buildGroups( return result; } +function LabelGroupMuteActions({ + labelId, + labelName, + openMuteRulesWithPrefill, +}: { + labelId: number; + labelName: string; + openMuteRulesWithPrefill: (draft: MuteRuleDraft) => void; +}) { + const actions = useLabelMuteActions(labelId, labelName, openMuteRulesWithPrefill); + return ; +} + interface StackListBulkProps { bulkMode: boolean; selectedFiles: Set; @@ -125,6 +146,7 @@ export function StackList(props: StackListProps & StackListBulkProps) { bulkMode, selectedFiles, onToggleSelect, remoteResults, remoteLoading, remoteFailedNodes, onSelectRemoteFile, filterChip, onOpenCreate, + openMuteRulesWithPrefill, } = props; const [failedNodesExpanded, setFailedNodesExpanded] = useState(false); @@ -164,6 +186,17 @@ export function StackList(props: StackListProps & StackListBulkProps) { collapsed={isCollapsed(g.id)} onToggle={() => toggleCollapse(g.id)} variant={g.variant} + headerActions={ + g.kind === 'labeled' && g.labelId != null && g.labelName && openMuteRulesWithPrefill + ? ( + + ) + : undefined + } > {g.files.map(file => { const ctx = buildMenuCtx(file); diff --git a/frontend/src/components/sidebar/sidebar-types.ts b/frontend/src/components/sidebar/sidebar-types.ts index 4f98e269..8b1d52ed 100644 --- a/frontend/src/components/sidebar/sidebar-types.ts +++ b/frontend/src/components/sidebar/sidebar-types.ts @@ -46,6 +46,15 @@ export interface StackMenuCtx { unpin: () => void; toggleLabel: (labelId: number) => void; createAndAssignLabel: (name: string, color: LabelColor) => Promise; + canMuteNotifications: boolean; + muteStackAll: () => void; + muteStackDeploySuccess: () => void; + muteStackMonitor: () => void; + openStackMuteRules: () => void; + muteLabelAll: (labelId: number, labelName: string) => void; + muteLabelExternal: (labelId: number, labelName: string) => void; + muteLabelLowPriority: (labelId: number, labelName: string) => void; + openLabelMuteRules: (labelId: number, labelName: string) => void; openLabelManager: () => void; openScheduleTask: () => void; } diff --git a/frontend/src/components/stack/StackActivityTimeline.tsx b/frontend/src/components/stack/StackActivityTimeline.tsx index fd5bf49e..d687e7b1 100644 --- a/frontend/src/components/stack/StackActivityTimeline.tsx +++ b/frontend/src/components/stack/StackActivityTimeline.tsx @@ -5,6 +5,8 @@ import { } from 'lucide-react'; import type { LucideIcon } from 'lucide-react'; import { Button } from '@/components/ui/button'; +import { Badge } from '@/components/ui/badge'; +import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip'; import { toast } from '@/components/ui/toast-store'; import { apiFetch } from '@/lib/api'; import { formatTimeAgo } from '@/lib/relativeTime'; @@ -13,6 +15,12 @@ import type { NotificationItem } from '@/components/dashboard/types'; type ActivityLevel = 'info' | 'warning' | 'error'; const ACTIVITY_LEVELS: readonly string[] = ['info', 'warning', 'error']; +interface SuppressionMatchSnapshot { + rules: { id: number; name: string }[]; + bellSuppressed: boolean; + externalSuppressed: boolean; +} + interface ActivityEvent { id: number; level: ActivityLevel; @@ -21,6 +29,7 @@ interface ActivityEvent { timestamp: number; stack_name?: string; actor_username?: string | null; + suppression_match?: string | SuppressionMatchSnapshot | null; } interface StackActivityTimelineProps { @@ -61,6 +70,25 @@ function formatActor(actor: string): { label: string; isSystem: boolean } { return { label: SYSTEM_ACTOR_LABEL[actor] ?? actor, isSystem }; } +function parseSuppressionMatch(raw: ActivityEvent['suppression_match']): SuppressionMatchSnapshot | null { + if (!raw) return null; + if (typeof raw === 'object') return raw; + try { + const parsed = JSON.parse(raw) as SuppressionMatchSnapshot; + if (!parsed || typeof parsed !== 'object') return null; + if (!Array.isArray(parsed.rules)) return null; + return parsed; + } catch { + return null; + } +} + +function suppressionTooltip(match: SuppressionMatchSnapshot): string { + const names = match.rules.map((r) => r.name).filter(Boolean); + const ruleText = names.length > 0 ? names.join(', ') : 'Unnamed rule'; + return `Matched rule: ${ruleText}`; +} + function isActivityEvent(value: unknown): value is ActivityEvent { if (!value || typeof value !== 'object') return false; const v = value as Record; @@ -261,6 +289,10 @@ export function StackActivityTimeline({ stackName, liveEvents }: StackActivityTi {g.events.map(e => { const Icon = CATEGORY_ICON[e.category ?? ''] ?? Activity; const actor = e.actor_username ? formatActor(e.actor_username) : null; + const suppression = parseSuppressionMatch(e.suppression_match); + const showSuppressed = Boolean( + suppression && (suppression.bellSuppressed || suppression.externalSuppressed), + ); return (
)} + {showSuppressed && suppression && ( + + + + Suppressed + + + + {suppressionTooltip(suppression)} + + + )}
{formatTimeAgo(e.timestamp)} diff --git a/frontend/src/hooks/__tests__/useStackMenuItems.test.tsx b/frontend/src/hooks/__tests__/useStackMenuItems.test.tsx index 84adc2eb..63ce1907 100644 --- a/frontend/src/hooks/__tests__/useStackMenuItems.test.tsx +++ b/frontend/src/hooks/__tests__/useStackMenuItems.test.tsx @@ -32,6 +32,15 @@ function makeCtx(overrides: Partial = {}): StackMenuCtx { createAndAssignLabel: vi.fn(), openLabelManager: vi.fn(), openScheduleTask: vi.fn(), + canMuteNotifications: false, + muteStackAll: vi.fn(), + muteStackDeploySuccess: vi.fn(), + muteStackMonitor: vi.fn(), + openStackMuteRules: vi.fn(), + muteLabelAll: vi.fn(), + muteLabelExternal: vi.fn(), + muteLabelLowPriority: vi.fn(), + openLabelMuteRules: vi.fn(), ...overrides, }; } @@ -111,6 +120,25 @@ describe('useStackMenuItems', () => { expect(lifecycle?.items.some(i => i.id === 'schedule')).toBeFalsy(); }); + it('includes Mute submenu in Inspect when canMuteNotifications', () => { + const { result } = renderHook(() => useStackMenuItems('web.yml', makeCtx({ canMuteNotifications: true }))); + const inspect = result.current.find(g => g.id === 'inspect')!; + const muteItem = inspect.items.find(i => i.id === 'mute'); + expect(muteItem).toBeDefined(); + expect(muteItem?.subItems?.map(s => s.id)).toEqual([ + 'mute-stack-all', + 'mute-stack-deploy', + 'mute-stack-monitor', + 'mute-stack-manage', + ]); + }); + + it('hides Mute submenu when canMuteNotifications is false', () => { + const { result } = renderHook(() => useStackMenuItems('web.yml', makeCtx({ canMuteNotifications: false }))); + const inspect = result.current.find(g => g.id === 'inspect')!; + expect(inspect.items.find(i => i.id === 'mute')).toBeUndefined(); + }); + it('keeps label assignment available for any tier', () => { const { result } = renderHook(() => useStackMenuItems('web.yml', makeCtx({ labels: [{ id: 1, node_id: 0, name: 'prod', color: 'teal' }], diff --git a/frontend/src/hooks/useMuteRuleActions.ts b/frontend/src/hooks/useMuteRuleActions.ts new file mode 100644 index 00000000..4c883028 --- /dev/null +++ b/frontend/src/hooks/useMuteRuleActions.ts @@ -0,0 +1,107 @@ +import { useCallback, useMemo } from 'react'; +import { useAuth } from '@/context/AuthContext'; +import { useNodes } from '@/context/NodeContext'; +import { + createMuteRuleWithToast, + stackMuteAllDraft, + stackMuteDeploySuccessDraft, + stackMuteMonitorDraft, + labelMuteAllDraft, + labelMuteExternalDraft, + labelMuteLowPriorityDraft, + nodeMuteAllDraft, + nodeMuteUpdatesDraft, + nodeMuteMonitorDraft, + type MuteRuleDraft, +} from '@/lib/muteRules'; + +export type OpenMuteRulesPrefill = (draft: MuteRuleDraft) => void; + +export function useCanMuteNotifications(): boolean { + const { isAdmin } = useAuth(); + const { hasCapability } = useNodes(); + return isAdmin && hasCapability('notification-suppression'); +} + +export function useStackMuteActions(stackName: string, openMuteRulesWithPrefill: OpenMuteRulesPrefill) { + const { activeNode } = useNodes(); + const canMute = useCanMuteNotifications(); + const nodeId = activeNode?.id ?? null; + + const muteAll = useCallback(() => { + void createMuteRuleWithToast(stackMuteAllDraft(stackName, nodeId)); + }, [stackName, nodeId]); + + const muteDeploySuccess = useCallback(() => { + void createMuteRuleWithToast(stackMuteDeploySuccessDraft(stackName, nodeId)); + }, [stackName, nodeId]); + + const muteMonitor = useCallback(() => { + void createMuteRuleWithToast(stackMuteMonitorDraft(stackName, nodeId)); + }, [stackName, nodeId]); + + const manage = useCallback(() => { + openMuteRulesWithPrefill(stackMuteAllDraft(stackName, nodeId)); + }, [stackName, nodeId, openMuteRulesWithPrefill]); + + return useMemo( + () => ({ canMute, muteAll, muteDeploySuccess, muteMonitor, manage }), + [canMute, muteAll, muteDeploySuccess, muteMonitor, manage], + ); +} + +export function useLabelMuteActions( + labelId: number, + labelName: string, + openMuteRulesWithPrefill: OpenMuteRulesPrefill, +) { + const { activeNode } = useNodes(); + const canMute = useCanMuteNotifications(); + const nodeId = activeNode?.id ?? null; + + const muteAll = useCallback(() => { + void createMuteRuleWithToast(labelMuteAllDraft(labelId, labelName, nodeId)); + }, [labelId, labelName, nodeId]); + + const muteExternal = useCallback(() => { + void createMuteRuleWithToast(labelMuteExternalDraft(labelId, labelName, nodeId)); + }, [labelId, labelName, nodeId]); + + const muteLowPriority = useCallback(() => { + void createMuteRuleWithToast(labelMuteLowPriorityDraft(labelId, labelName, nodeId)); + }, [labelId, labelName, nodeId]); + + const manage = useCallback(() => { + openMuteRulesWithPrefill(labelMuteAllDraft(labelId, labelName, nodeId)); + }, [labelId, labelName, nodeId, openMuteRulesWithPrefill]); + + return useMemo( + () => ({ canMute, muteAll, muteExternal, muteLowPriority, manage }), + [canMute, muteAll, muteExternal, muteLowPriority, manage], + ); +} + +export function useNodeMuteActions(nodeId: number, nodeName: string, openMuteRulesWithPrefill: OpenMuteRulesPrefill) { + const canMute = useCanMuteNotifications(); + + const muteAll = useCallback(() => { + void createMuteRuleWithToast(nodeMuteAllDraft(nodeId, nodeName)); + }, [nodeId, nodeName]); + + const muteUpdates = useCallback(() => { + void createMuteRuleWithToast(nodeMuteUpdatesDraft(nodeId, nodeName)); + }, [nodeId, nodeName]); + + const muteMonitor = useCallback(() => { + void createMuteRuleWithToast(nodeMuteMonitorDraft(nodeId, nodeName)); + }, [nodeId, nodeName]); + + const manage = useCallback(() => { + openMuteRulesWithPrefill(nodeMuteAllDraft(nodeId, nodeName)); + }, [nodeId, nodeName, openMuteRulesWithPrefill]); + + return useMemo( + () => ({ canMute, muteAll, muteUpdates, muteMonitor, manage }), + [canMute, muteAll, muteUpdates, muteMonitor, manage], + ); +} diff --git a/frontend/src/hooks/useMuteRulesRefresh.ts b/frontend/src/hooks/useMuteRulesRefresh.ts new file mode 100644 index 00000000..94259df5 --- /dev/null +++ b/frontend/src/hooks/useMuteRulesRefresh.ts @@ -0,0 +1,11 @@ +import { useEffect } from 'react'; +import { MUTE_RULES_CHANGED_EVENT } from '@/lib/muteRules'; + +/** Refetch mute rules when another surface creates or updates a rule. */ +export function useMuteRulesRefresh(onRefresh: () => void): void { + useEffect(() => { + const handler = () => { onRefresh(); }; + window.addEventListener(MUTE_RULES_CHANGED_EVENT, handler); + return () => window.removeEventListener(MUTE_RULES_CHANGED_EVENT, handler); + }, [onRefresh]); +} diff --git a/frontend/src/hooks/useStackMenuItems.tsx b/frontend/src/hooks/useStackMenuItems.tsx index 0d6d4446..90b90db9 100644 --- a/frontend/src/hooks/useStackMenuItems.tsx +++ b/frontend/src/hooks/useStackMenuItems.tsx @@ -2,6 +2,7 @@ import { useMemo } from 'react'; import { Activity, ArrowUpRight, + BellOff, BellRing, CalendarClock, Download, @@ -22,6 +23,7 @@ export function useStackMenuItems(_file: string, ctx: StackMenuCtx): MenuGroup[] openAlertSheet, openAutoHeal, checkUpdates, openStackApp, deploy, stop, restart, update, remove, pin, unpin, toggleLabel, menuVisibility, openScheduleTask, + canMuteNotifications, muteStackAll, muteStackDeploySuccess, muteStackMonitor, openStackMuteRules, } = ctx; const { showDeploy, showStop, showRestart, showUpdate } = menuVisibility; @@ -36,6 +38,20 @@ export function useStackMenuItems(_file: string, ctx: StackMenuCtx): MenuGroup[] if (stackStatus === 'running' && canOpenApp) { inspect.push({ id: 'open-app', label: 'Open App', icon: ArrowUpRight, shortcut: '↗', onSelect: openStackApp }); } + if (canMuteNotifications) { + inspect.push({ + id: 'mute', + label: 'Mute', + icon: BellOff, + onSelect: () => {}, + subItems: [ + { id: 'mute-stack-all', label: 'Mute notifications for this stack', icon: BellOff, onSelect: muteStackAll }, + { id: 'mute-stack-deploy', label: 'Mute deploy success noise', icon: BellOff, onSelect: muteStackDeploySuccess }, + { id: 'mute-stack-monitor', label: 'Mute monitor alerts for this stack', icon: BellOff, onSelect: muteStackMonitor }, + { id: 'mute-stack-manage', label: 'Manage stack mute rules', icon: BellOff, onSelect: openStackMuteRules }, + ], + }); + } groups.push({ id: 'inspect', items: inspect }); const organize: MenuItem[] = []; @@ -82,5 +98,6 @@ export function useStackMenuItems(_file: string, ctx: StackMenuCtx): MenuGroup[] showDeploy, showStop, showRestart, showUpdate, openAlertSheet, openAutoHeal, checkUpdates, openStackApp, deploy, stop, restart, update, remove, pin, unpin, toggleLabel, openScheduleTask, + canMuteNotifications, muteStackAll, muteStackDeploySuccess, muteStackMonitor, openStackMuteRules, ]); } diff --git a/frontend/src/lib/capabilities.ts b/frontend/src/lib/capabilities.ts index 65b00ec0..d0eda468 100644 --- a/frontend/src/lib/capabilities.ts +++ b/frontend/src/lib/capabilities.ts @@ -13,6 +13,7 @@ export const CAPABILITIES = [ 'network-topology', 'notifications', 'notification-routing', + 'notification-suppression', 'host-console', 'container-exec', 'audit-log', diff --git a/frontend/src/lib/muteRules.ts b/frontend/src/lib/muteRules.ts new file mode 100644 index 00000000..dba9ba93 --- /dev/null +++ b/frontend/src/lib/muteRules.ts @@ -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 { + 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 { + const draft = muteDraftFromNotification(notif, mode); + if (!draft) { + toast.error('This notification cannot be muted with that shortcut.'); + return false; + } + return createMuteRuleWithToast(draft); +} diff --git a/frontend/src/lib/notificationCategories.ts b/frontend/src/lib/notificationCategories.ts index 844bedee..3d1f1f13 100644 --- a/frontend/src/lib/notificationCategories.ts +++ b/frontend/src/lib/notificationCategories.ts @@ -11,6 +11,11 @@ export const CATEGORY_LABELS: Record = { 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', };