feat: add notification suppression rules (#1525)

* feat: add notification suppression rules

* fix: restore label routing and routing test mocks for suppression

* fix: allow bell mute shortcuts for history-only notification categories

Suppression rule validation used the routable category whitelist, which rejected history-only categories such as update_started that appear in the bell during stack updates.

* feat: expand Mute Rules UX with compose-first entry points and activity badges

* fix: add missing NodeContext mocks for notification suppression tests
This commit is contained in:
Anso
2026-07-02 15:26:48 -04:00
committed by GitHub
parent bc111d28f3
commit b65daf6845
52 changed files with 2794 additions and 51 deletions
@@ -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
@@ -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);
});
});
@@ -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,
@@ -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);
});
});
@@ -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<string, unknown> = {}) {
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' }),
);
});
});
@@ -0,0 +1,68 @@
import type { NotificationCategory } from '../services/NotificationService';
export type NotificationLevel = 'info' | 'warning' | 'error';
export type NotificationAppliesTo = 'bell' | 'external' | 'both';
export interface NotificationFilterRule {
node_id: number | null;
stack_patterns: string[];
label_ids: number[] | null;
categories: string[] | null;
levels?: NotificationLevel[] | null;
}
export interface NotificationMatchContext {
localNodeId: number;
stackName?: string;
category: NotificationCategory;
level: NotificationLevel;
stackLabelIds: number[];
}
/** True when all non-empty matchers on the rule match the alert context (AND). */
export function matchesNotificationFilters(
ctx: NotificationMatchContext,
rule: NotificationFilterRule,
): boolean {
if (rule.node_id != null && rule.node_id !== ctx.localNodeId) return false;
if (
rule.stack_patterns.length > 0
&& (ctx.stackName === undefined || !rule.stack_patterns.includes(ctx.stackName))
) {
return false;
}
if (
rule.label_ids != null
&& rule.label_ids.length > 0
&& !rule.label_ids.some((id) => ctx.stackLabelIds.includes(id))
) {
return false;
}
if (
rule.categories != null
&& rule.categories.length > 0
&& !rule.categories.includes(ctx.category)
) {
return false;
}
if (
rule.levels != null
&& rule.levels.length > 0
&& !rule.levels.includes(ctx.level)
) {
return false;
}
return true;
}
export function ruleNeedsStackLabels(rules: NotificationFilterRule[]): boolean {
return rules.some((r) => r.label_ids != null && r.label_ids.length > 0);
}
export function appliesToBell(appliesTo: NotificationAppliesTo): boolean {
return appliesTo === 'bell' || appliesTo === 'both';
}
export function appliesToExternal(appliesTo: NotificationAppliesTo): boolean {
return appliesTo === 'external' || appliesTo === 'both';
}
@@ -0,0 +1,100 @@
import { DatabaseService, type NotificationSuppressionRule, type Node } from '../services/DatabaseService';
import { NodeRegistry } from '../services/NodeRegistry';
import { LicenseService } from '../services/LicenseService';
import { PROXY_TIER_HEADER } from '../services/license-headers';
import { getErrorMessage } from '../utils/errors';
const SYNC_TIMEOUT_MS = 15_000;
function buildRemoteHeaders(apiToken: string): Record<string, string> {
const proxyHeaders = LicenseService.getInstance().getProxyHeaders();
const headers: Record<string, string> = {
'Content-Type': 'application/json',
[PROXY_TIER_HEADER]: proxyHeaders.tier,
};
if (apiToken) headers.Authorization = `Bearer ${apiToken}`;
return headers;
}
function replicationTargets(rule: NotificationSuppressionRule): Node[] {
const db = DatabaseService.getInstance();
const remotes = db.getNodes().filter((n) => n.type === 'remote');
if (rule.node_id != null) {
const target = remotes.find((n) => n.id === rule.node_id);
return target ? [target] : [];
}
return remotes;
}
async function pushRuleToNode(node: Node, rule: NotificationSuppressionRule): Promise<void> {
const target = NodeRegistry.getInstance().getProxyTarget(node.id);
if (!target?.apiUrl) {
console.warn(`[SuppressionSync] Skipping node "${node.name}": no proxy target`);
return;
}
const baseUrl = target.apiUrl.replace(/\/$/, '');
const res = await fetch(`${baseUrl}/api/notification-suppression-rules/replica`, {
method: 'POST',
headers: buildRemoteHeaders(target.apiToken),
body: JSON.stringify({ rule }),
signal: AbortSignal.timeout(SYNC_TIMEOUT_MS),
});
if (!res.ok) {
const body = await res.text().catch(() => '');
throw new Error(`HTTP ${res.status}${body ? `: ${body.slice(0, 200)}` : ''}`);
}
}
async function deleteRuleOnNode(node: Node, ruleId: number): Promise<void> {
const target = NodeRegistry.getInstance().getProxyTarget(node.id);
if (!target?.apiUrl) {
console.warn(`[SuppressionSync] Skipping node "${node.name}": no proxy target`);
return;
}
const baseUrl = target.apiUrl.replace(/\/$/, '');
const res = await fetch(`${baseUrl}/api/notification-suppression-rules/replica/${ruleId}`, {
method: 'DELETE',
headers: buildRemoteHeaders(target.apiToken),
signal: AbortSignal.timeout(SYNC_TIMEOUT_MS),
});
if (!res.ok && res.status !== 404) {
const body = await res.text().catch(() => '');
throw new Error(`HTTP ${res.status}${body ? `: ${body.slice(0, 200)}` : ''}`);
}
}
/** Best-effort push of a suppression rule to fleet nodes that should evaluate it. */
export function syncSuppressionRuleToFleet(rule: NotificationSuppressionRule): void {
const targets = replicationTargets(rule);
if (targets.length === 0) return;
void Promise.allSettled(
targets.map(async (node) => {
try {
await pushRuleToNode(node, rule);
} catch (err) {
console.error(
`[SuppressionSync] Failed to push rule ${rule.id} to node "${node.name}":`,
getErrorMessage(err, String(err)),
);
}
}),
);
}
/** Best-effort delete of a replicated rule on fleet nodes. */
export function deleteSuppressionRuleFromFleet(rule: NotificationSuppressionRule): void {
const targets = replicationTargets(rule);
if (targets.length === 0) return;
void Promise.allSettled(
targets.map(async (node) => {
try {
await deleteRuleOnNode(node, rule.id);
} catch (err) {
console.error(
`[SuppressionSync] Failed to delete rule ${rule.id} on node "${node.name}":`,
getErrorMessage(err, String(err)),
);
}
}),
);
}
+1
View File
@@ -52,6 +52,7 @@ export const HUB_ONLY_PREFIXES: readonly string[] = [
'/api/scheduled-tasks/',
'/api/audit-log/',
'/api/notification-routes/',
'/api/notification-suppression-rules/',
'/api/logs/global/',
'/api/system/log-stream-metrics/',
'/api/registries/',
+2 -1
View File
@@ -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);
+313 -5
View File
@@ -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<NotificationCategory> = new Set(ALL_NOTIFICATION_CATEGORIES);
const VALID_SUPPRESSION_CATEGORIES: ReadonlySet<NotificationCategory> = new Set(ALL_SUPPRESSIBLE_CATEGORIES);
const VALID_LEVELS = new Set(['info', 'warning', 'error']);
const VALID_APPLIES_TO = new Set<NotificationSuppressionAppliesTo>(['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<NotificationCategory> = 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<NotificationSuppressionRule, 'id' | 'created_at' | 'updated_at'> | 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<void> => {
@@ -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<Omit<NotificationSuppressionRule, 'id' | 'created_at'>> = { 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' });
}
});
@@ -25,6 +25,7 @@ export const CAPABILITIES = [
'network-topology',
'notifications',
'notification-routing',
'notification-suppression',
'host-console',
'container-exec',
'audit-log',
+178
View File
@@ -377,6 +377,7 @@ export interface NotificationHistory {
stack_name?: string;
container_name?: string;
actor_username?: string | null;
suppression_match?: string | null;
}
export interface FleetSnapshot {
@@ -600,6 +601,23 @@ export interface NotificationRoute {
updated_at: number;
}
export type NotificationSuppressionAppliesTo = 'bell' | 'external' | 'both';
export interface NotificationSuppressionRule {
id: number;
name: string;
node_id: number | null;
stack_patterns: string[];
label_ids: number[] | null;
categories: string[] | null;
levels: ('info' | 'warning' | 'error')[] | null;
applies_to: NotificationSuppressionAppliesTo;
enabled: boolean;
expires_at: number | null;
created_at: number;
updated_at: number;
}
export type VulnSeverity = 'CRITICAL' | 'HIGH' | 'MEDIUM' | 'LOW' | 'UNKNOWN';
export type VulnScanStatus = 'in_progress' | 'completed' | 'failed';
export type VulnScanTrigger = 'manual' | 'scheduled' | 'deploy' | 'deploy-preflight';
@@ -871,6 +889,7 @@ export class DatabaseService {
this.migrateNotificationRoutes();
this.migrateNotificationRoutesNodeId();
this.migrateNotificationRoutesMatchers();
this.migrateNotificationSuppressionRules();
this.migrateNotificationHistoryContext();
this.migrateScanPolicyFleetColumns();
this.migrateScanPolicyRiskColumns();
@@ -1814,9 +1833,31 @@ export class DatabaseService {
this.tryAddColumn('notification_routes', 'categories', 'TEXT NULL');
}
private migrateNotificationSuppressionRules(): void {
this.db.exec(`
CREATE TABLE IF NOT EXISTS notification_suppression_rules (
id INTEGER PRIMARY KEY AUTOINCREMENT,
name TEXT NOT NULL,
node_id INTEGER NULL,
stack_patterns TEXT NOT NULL,
label_ids TEXT NULL,
categories TEXT NULL,
levels TEXT NULL,
applies_to TEXT NOT NULL,
enabled INTEGER DEFAULT 1,
expires_at INTEGER NULL,
created_at INTEGER NOT NULL,
updated_at INTEGER NOT NULL
);
CREATE INDEX IF NOT EXISTS idx_notification_suppression_enabled
ON notification_suppression_rules(enabled, expires_at);
`);
}
private migrateNotificationHistoryContext(): void {
this.tryAddColumn('notification_history', 'stack_name', 'TEXT');
this.tryAddColumn('notification_history', 'container_name', 'TEXT');
this.tryAddColumn('notification_history', 'suppression_match', 'TEXT');
}
private migrateStackDossierHashes(): void {
@@ -2293,6 +2334,135 @@ export class DatabaseService {
return this.db.prepare('DELETE FROM notification_routes WHERE id = ?').run(id).changes;
}
// --- Notification Suppression Rules ---
private parseNotificationSuppressionRule(row: Record<string, unknown>): NotificationSuppressionRule {
return {
id: row.id as number,
name: row.name as string,
node_id: row.node_id != null ? (row.node_id as number) : null,
stack_patterns: JSON.parse(row.stack_patterns as string) as string[],
label_ids: row.label_ids ? JSON.parse(row.label_ids as string) as number[] : null,
categories: row.categories ? JSON.parse(row.categories as string) as string[] : null,
levels: row.levels ? JSON.parse(row.levels as string) as ('info' | 'warning' | 'error')[] : null,
applies_to: row.applies_to as NotificationSuppressionAppliesTo,
enabled: row.enabled === 1,
expires_at: row.expires_at != null ? (row.expires_at as number) : null,
created_at: row.created_at as number,
updated_at: row.updated_at as number,
};
}
public getNotificationSuppressionRules(): NotificationSuppressionRule[] {
return this.db.prepare('SELECT * FROM notification_suppression_rules ORDER BY created_at ASC')
.all()
.map((row) => this.parseNotificationSuppressionRule(row as Record<string, unknown>));
}
public getEnabledNotificationSuppressionRules(now = Date.now()): NotificationSuppressionRule[] {
return this.db.prepare(
'SELECT * FROM notification_suppression_rules WHERE enabled = 1 AND (expires_at IS NULL OR expires_at > ?) ORDER BY created_at ASC',
)
.all(now)
.map((row) => this.parseNotificationSuppressionRule(row as Record<string, unknown>));
}
public getNotificationSuppressionRule(id: number): NotificationSuppressionRule | undefined {
const row = this.db.prepare('SELECT * FROM notification_suppression_rules WHERE id = ?').get(id) as Record<string, unknown> | undefined;
return row ? this.parseNotificationSuppressionRule(row) : undefined;
}
public createNotificationSuppressionRule(
rule: Omit<NotificationSuppressionRule, 'id'>,
): NotificationSuppressionRule {
const result = this.db.prepare(
'INSERT INTO notification_suppression_rules (name, node_id, stack_patterns, label_ids, categories, levels, applies_to, enabled, expires_at, created_at, updated_at) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)',
).run(
rule.name,
rule.node_id ?? null,
JSON.stringify(rule.stack_patterns),
rule.label_ids ? JSON.stringify(rule.label_ids) : null,
rule.categories ? JSON.stringify(rule.categories) : null,
rule.levels ? JSON.stringify(rule.levels) : null,
rule.applies_to,
rule.enabled ? 1 : 0,
rule.expires_at ?? null,
rule.created_at,
rule.updated_at,
);
return this.getNotificationSuppressionRule(result.lastInsertRowid as number)!;
}
public upsertNotificationSuppressionRuleReplica(rule: NotificationSuppressionRule): void {
const existing = this.getNotificationSuppressionRule(rule.id);
if (existing) {
this.db.prepare(
`UPDATE notification_suppression_rules SET
name = ?, node_id = ?, stack_patterns = ?, label_ids = ?, categories = ?, levels = ?,
applies_to = ?, enabled = ?, expires_at = ?, updated_at = ?
WHERE id = ?`,
).run(
rule.name,
rule.node_id ?? null,
JSON.stringify(rule.stack_patterns),
rule.label_ids ? JSON.stringify(rule.label_ids) : null,
rule.categories ? JSON.stringify(rule.categories) : null,
rule.levels ? JSON.stringify(rule.levels) : null,
rule.applies_to,
rule.enabled ? 1 : 0,
rule.expires_at ?? null,
rule.updated_at,
rule.id,
);
return;
}
this.db.prepare(
`INSERT INTO notification_suppression_rules
(id, name, node_id, stack_patterns, label_ids, categories, levels, applies_to, enabled, expires_at, created_at, updated_at)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
).run(
rule.id,
rule.name,
rule.node_id ?? null,
JSON.stringify(rule.stack_patterns),
rule.label_ids ? JSON.stringify(rule.label_ids) : null,
rule.categories ? JSON.stringify(rule.categories) : null,
rule.levels ? JSON.stringify(rule.levels) : null,
rule.applies_to,
rule.enabled ? 1 : 0,
rule.expires_at ?? null,
rule.created_at,
rule.updated_at,
);
}
public updateNotificationSuppressionRule(
id: number,
updates: Partial<Omit<NotificationSuppressionRule, 'id' | 'created_at'>>,
): void {
const fields: string[] = [];
const values: unknown[] = [];
if (updates.name !== undefined) { fields.push('name = ?'); values.push(updates.name); }
if ('node_id' in updates) { fields.push('node_id = ?'); values.push(updates.node_id ?? null); }
if (updates.stack_patterns !== undefined) { fields.push('stack_patterns = ?'); values.push(JSON.stringify(updates.stack_patterns)); }
if ('label_ids' in updates) { fields.push('label_ids = ?'); values.push(updates.label_ids ? JSON.stringify(updates.label_ids) : null); }
if ('categories' in updates) { fields.push('categories = ?'); values.push(updates.categories ? JSON.stringify(updates.categories) : null); }
if ('levels' in updates) { fields.push('levels = ?'); values.push(updates.levels ? JSON.stringify(updates.levels) : null); }
if (updates.applies_to !== undefined) { fields.push('applies_to = ?'); values.push(updates.applies_to); }
if (updates.enabled !== undefined) { fields.push('enabled = ?'); values.push(updates.enabled ? 1 : 0); }
if ('expires_at' in updates) { fields.push('expires_at = ?'); values.push(updates.expires_at ?? null); }
if (updates.updated_at !== undefined) { fields.push('updated_at = ?'); values.push(updates.updated_at); }
if (fields.length === 0) return;
values.push(id);
this.db.prepare(`UPDATE notification_suppression_rules SET ${fields.join(', ')} WHERE id = ?`).run(...values);
}
public deleteNotificationSuppressionRule(id: number): number {
return this.db.prepare('DELETE FROM notification_suppression_rules WHERE id = ?').run(id).changes;
}
// --- Global Settings ---
public getGlobalSettings(): Readonly<Record<string, string>> {
@@ -2806,6 +2976,7 @@ export class DatabaseService {
container_name: row.container_name ?? undefined,
category: row.category ?? undefined,
actor_username: row.actor_username ?? null,
suppression_match: row.suppression_match ?? null,
};
}
@@ -2914,6 +3085,13 @@ export class DatabaseService {
this.db.prepare('UPDATE notification_history SET dispatch_error = ? WHERE id = ?').run(error, id);
}
public updateNotificationSuppressionMatch(
id: number,
snapshot: { rules: { id: number; name: string }[]; bellSuppressed: boolean; externalSuppressed: boolean },
): void {
this.db.prepare('UPDATE notification_history SET suppression_match = ? WHERE id = ?').run(JSON.stringify(snapshot), id);
}
public getStackRestartSummary(nodeId: number, days: number): StackRestartSummary[] {
const since = Date.now() - days * 86400 * 1000;
return this.db.prepare(`
+53 -12
View File
@@ -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(
+3
View File
@@ -72,6 +72,9 @@ export const AUDIT_ROUTE_SUMMARIES: Record<string, string> = {
'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',
+30
View File
@@ -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
<Note>
Admin role is required to create, edit, or delete mute rules.
</Note>
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.
+26
View File
@@ -419,6 +419,32 @@ See [Notification Routing](/features/alerts-notifications#notification-routing)
---
## Mute Rules
<Note>
Creating, editing, and deleting mute rules is admin-only.
</Note>
**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)
+11
View File
@@ -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}
@@ -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<typeof useStackMuteActions>;
}
export function EditorView(props: EditorViewProps) {
@@ -270,6 +273,7 @@ export function EditorView(props: EditorViewProps) {
onRefreshState,
onDismissRecovery,
panelStartedAt,
stackMuteActions,
} = props;
const monacoEditorRef = useRef<import('monaco-editor').editor.IStandaloneCodeEditor | null>(null);
@@ -376,6 +380,7 @@ export function EditorView(props: EditorViewProps) {
rollbackStack={rollbackStack}
scanStackConfig={scanStackConfig}
requestDeleteStack={requestDeleteStack}
stackMuteActions={stackMuteActions}
/>
</div>
{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}
/>
)}
</div>
@@ -78,6 +78,7 @@ export function MobileStackDetail(props: EditorViewProps) {
onRefreshState,
onDismissRecovery,
panelStartedAt,
stackMuteActions,
} = props;
const [segment, setSegment] = useState<Segment>('logs');
@@ -152,6 +153,7 @@ export function MobileStackDetail(props: EditorViewProps) {
rollbackStack={rollbackStack}
scanStackConfig={scanStackConfig}
requestDeleteStack={requestDeleteStack}
stackMuteActions={stackMuteActions}
/>
</div>
@@ -243,6 +245,7 @@ export function MobileStackDetail(props: EditorViewProps) {
applying={loadingAction === 'update'}
canEdit={canEditStack}
notifications={notifications}
stackMuteActions={stackMuteActions}
/>
</div>
)}
@@ -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({
<SettingsPage
currentSection={settingsSection}
onSectionChange={onSettingsSectionChange}
muteRulePrefill={muteRulePrefill}
onMutePrefillConsumed={onMutePrefillConsumed}
onOpenMuteRulesWithPrefill={onOpenMuteRulesWithPrefill}
/>
);
}
@@ -186,6 +196,7 @@ export function ViewRouter({
<FleetView
onNavigateToNode={onFleetNavigateToNode}
onOpenSettingsSection={onOpenSettingsSection}
onOpenMuteRulesWithPrefill={onOpenMuteRulesWithPrefill}
fleetUpdatesIntent={fleetUpdatesIntent}
onFleetUpdatesIntentConsumed={onFleetUpdatesIntentConsumed}
fleetTab={fleetTab}
@@ -27,6 +27,8 @@ import {
DropdownMenuSeparator,
DropdownMenuTrigger,
} from '../ui/dropdown-menu';
import { StackMuteSubmenu } from '@/components/mute/MuteMenuItems';
import type { useStackMuteActions } from '@/hooks/useMuteRuleActions';
import { Sparkline } from '../ui/sparkline';
import { ImageSourceMenu } from '../ImageSourceMenu';
import { cn } from '@/lib/utils';
@@ -129,6 +131,7 @@ export interface StackIdentityHeaderProps {
rollbackStack: () => Promise<void>;
scanStackConfig: () => Promise<void>;
requestDeleteStack: () => void;
stackMuteActions?: ReturnType<typeof useStackMuteActions>;
}
// Breadcrumb + serif title + state pill + image ref + action bar. The action
@@ -154,6 +157,7 @@ export function StackIdentityHeader({
rollbackStack,
scanStackConfig,
requestDeleteStack,
stackMuteActions,
}: StackIdentityHeaderProps) {
return (
<div className="flex flex-col gap-3">
@@ -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 (
<div className="flex items-center gap-2 flex-wrap">
@@ -287,7 +292,8 @@ export function StackIdentityHeader({
{stackMisconfigScanning ? 'Scanning...' : 'Scan config'}
</DropdownMenuItem>
)}
{hasOverflowExtras && canDelete && <DropdownMenuSeparator />}
{stackMuteActions && <StackMuteSubmenu actions={stackMuteActions} />}
{(canRollback || canScan || stackMuteActions?.canMute) && canDelete && <DropdownMenuSeparator />}
{canDelete && (
<DropdownMenuItem
className="text-destructive focus:text-destructive focus:bg-destructive/10"
@@ -3,6 +3,10 @@ import { renderHook } from '@testing-library/react';
import { useSidebarContextMenu } from './useSidebarContextMenu';
import type { Node } from '@/context/NodeContext';
vi.mock('@/context/NodeContext', () => ({
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.
@@ -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<typeof useStackListState>;
@@ -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;
@@ -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<FleetTab | null>(null);
const [filterNodeId, setFilterNodeId] = useState<number | null>(null);
const [schedulePrefill, setSchedulePrefill] = useState<ScheduleTaskPrefill | null>(null);
const [muteRulePrefill, setMuteRulePrefill] = useState<MuteRuleDraft | null>(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;
+4 -1
View File
@@ -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}
+12 -2
View File
@@ -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<string[] | null>(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'}
</DropdownMenuItem>
)}
{onOpenMuteRulesWithPrefill && <NodeMuteSubmenu actions={nodeMuteActions} />}
</DropdownMenuContent>
</DropdownMenu>
</div>
@@ -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}
/>
))}
</div>
@@ -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 });
});
+64 -12
View File
@@ -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<NotifFilter>('all');
const [nodeFilter, setNodeFilter] = useState<NodeFilter>(NODE_FILTER_ALL);
const [categoryFilter, setCategoryFilter] = useState<CategoryFilter>(CATEGORY_FILTER_ALL);
@@ -369,6 +379,7 @@ export function NotificationPanel({
onDelete={onDelete}
onNavigate={onNavigate ? handleNavigate : undefined}
onNavigateChangelog={onNavigateChangelog}
canMute={isAdmin}
/>
))}
</div>
@@ -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<void> {
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
) : (
<div className={surfaceClasses}>{content}</div>
)}
<Button
variant="ghost"
size="icon"
className="absolute right-2 top-2 z-20 h-6 w-6 opacity-0 transition-opacity group-hover:opacity-100 focus-visible:opacity-100"
onClick={() => onDelete(notif)}
title="Dismiss"
>
<X className="h-3 w-3" strokeWidth={1.5} />
</Button>
<div className="absolute right-2 top-2 z-20 flex items-center gap-0.5 opacity-0 transition-opacity group-hover:opacity-100 focus-within:opacity-100">
{canMute ? (
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button
variant="ghost"
size="icon"
className="h-6 w-6"
title="Mute options"
aria-label="Mute options"
onClick={(e) => e.stopPropagation()}
>
<MoreHorizontal className="h-3 w-3" strokeWidth={1.5} />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end" className="w-52">
{notif.category ? (
<DropdownMenuItem onClick={() => void createQuickSuppressionRule(notif, 'category')}>
Mute this category
</DropdownMenuItem>
) : null}
<DropdownMenuItem onClick={() => void createQuickSuppressionRule(notif, 'similar')}>
Mute notifications like this
</DropdownMenuItem>
{notif.stack_name ? (
<DropdownMenuItem onClick={() => void createQuickSuppressionRule(notif, 'stack')}>
Mute this stack
</DropdownMenuItem>
) : null}
</DropdownMenuContent>
</DropdownMenu>
) : null}
<Button
variant="ghost"
size="icon"
className="h-6 w-6"
onClick={() => onDelete(notif)}
title="Dismiss"
>
<X className="h-3 w-3" strokeWidth={1.5} />
</Button>
</div>
</div>
);
}
@@ -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<typeof useStackMuteActions>;
}
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
</button>
)}
{stackMuteActions && <ActivityMuteKebab actions={stackMuteActions} />}
</div>
</div>
<TabsContent value="activity" className="flex-1 min-h-0 overflow-y-auto px-3 mt-0">
@@ -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';
@@ -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<typeof useStackMuteActions>;
type LabelMuteActions = ReturnType<typeof useLabelMuteActions>;
type NodeMuteActions = ReturnType<typeof useNodeMuteActions>;
export function StackMuteSubmenu({ actions }: { actions: StackMuteActions }) {
if (!actions.canMute) return null;
return (
<DropdownMenuSub>
<DropdownMenuSubTrigger>
<BellOff className="w-4 h-4 mr-2" strokeWidth={1.5} />
Mute
</DropdownMenuSubTrigger>
<DropdownMenuSubContent>
<DropdownMenuItem onSelect={actions.muteAll}>Mute notifications for this stack</DropdownMenuItem>
<DropdownMenuItem onSelect={actions.muteDeploySuccess}>Mute deploy success noise</DropdownMenuItem>
<DropdownMenuItem onSelect={actions.muteMonitor}>Mute monitor alerts for this stack</DropdownMenuItem>
<DropdownMenuSeparator />
<DropdownMenuItem onSelect={actions.manage}>Manage stack mute rules</DropdownMenuItem>
</DropdownMenuSubContent>
</DropdownMenuSub>
);
}
export function LabelMuteSubmenu({ actions }: { actions: LabelMuteActions }) {
if (!actions.canMute) return null;
return (
<DropdownMenuSub>
<DropdownMenuSubTrigger>
<BellOff className="w-4 h-4 mr-2" strokeWidth={1.5} />
Mute
</DropdownMenuSubTrigger>
<DropdownMenuSubContent>
<DropdownMenuItem onSelect={actions.muteAll}>Mute notifications for this label</DropdownMenuItem>
<DropdownMenuItem onSelect={actions.muteExternal}>Mute external alerts for this label</DropdownMenuItem>
<DropdownMenuItem onSelect={actions.muteLowPriority}>Mute low-priority stack alerts</DropdownMenuItem>
<DropdownMenuSeparator />
<DropdownMenuItem onSelect={actions.manage}>Manage label mute rules</DropdownMenuItem>
</DropdownMenuSubContent>
</DropdownMenuSub>
);
}
export function NodeMuteSubmenu({ actions }: { actions: NodeMuteActions }) {
if (!actions.canMute) return null;
return (
<DropdownMenuSub>
<DropdownMenuSubTrigger>
<BellOff className="w-4 h-4 mr-2" strokeWidth={1.5} />
Mute
</DropdownMenuSubTrigger>
<DropdownMenuSubContent>
<DropdownMenuItem onSelect={actions.muteAll}>Mute node notifications</DropdownMenuItem>
<DropdownMenuItem onSelect={actions.muteUpdates}>Mute update notifications for this node</DropdownMenuItem>
<DropdownMenuItem onSelect={actions.muteMonitor}>Mute monitor alerts on this node</DropdownMenuItem>
<DropdownMenuSeparator />
<DropdownMenuItem onSelect={actions.manage}>Manage node mute rules</DropdownMenuItem>
</DropdownMenuSubContent>
</DropdownMenuSub>
);
}
export function LabelGroupMuteKebab({
actions,
}: {
actions: LabelMuteActions;
}) {
if (!actions.canMute) return null;
return (
<DropdownMenu>
<DropdownMenuTrigger asChild>
<button
type="button"
aria-label="Label mute actions"
className="inline-flex items-center justify-center w-5 h-5 rounded text-stat-icon hover:text-foreground hover:bg-glass-highlight/40 transition-colors"
onClick={(e) => e.stopPropagation()}
>
<MoreVertical className="w-3 h-3" strokeWidth={1.5} />
</button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end" className="w-56" onClick={(e: MouseEvent) => e.stopPropagation()}>
<DropdownMenuItem onSelect={actions.muteAll}>Mute notifications for this label</DropdownMenuItem>
<DropdownMenuItem onSelect={actions.muteExternal}>Mute external alerts for this label</DropdownMenuItem>
<DropdownMenuItem onSelect={actions.muteLowPriority}>Mute low-priority stack alerts</DropdownMenuItem>
<DropdownMenuSeparator />
<DropdownMenuItem onSelect={actions.manage}>Manage label mute rules</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
);
}
export function ActivityMuteKebab({ actions }: { actions: StackMuteActions }) {
if (!actions.canMute) return null;
return (
<DropdownMenu>
<DropdownMenuTrigger asChild>
<button
type="button"
aria-label="Mute stack notifications"
className="inline-flex items-center gap-1 font-mono text-[10px] uppercase tracking-wide text-stat-subtitle hover:text-brand transition-colors"
>
<BellOff className="h-3 w-3" strokeWidth={1.5} />
mute
</button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end" className="w-56">
<DropdownMenuItem onSelect={actions.muteAll}>Mute notifications for this stack</DropdownMenuItem>
<DropdownMenuItem onSelect={actions.muteDeploySuccess}>Mute deploy success noise</DropdownMenuItem>
<DropdownMenuItem onSelect={actions.muteMonitor}>Mute monitor alerts for this stack</DropdownMenuItem>
<DropdownMenuSeparator />
<DropdownMenuItem onSelect={actions.manage}>Manage stack mute rules</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
);
}
@@ -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 = {}) {
<span className="text-xs text-muted-foreground tabular-nums">
{assignmentCounts[label.id] || 0} stack{(assignmentCounts[label.id] || 0) !== 1 ? 's' : ''}
</span>
{canMute && onOpenMuteRulesWithPrefill && isAdmin && (
<Button
variant="ghost"
size="icon"
className="h-7 w-7 opacity-0 group-hover:opacity-100 transition-opacity"
title="Mute notifications for this label"
onClick={() => onOpenMuteRulesWithPrefill(labelMuteAllDraft(label.id, label.name))}
>
<BellOff className="w-3.5 h-3.5" strokeWidth={1.5} />
</Button>
)}
{canMutate && (
<>
<Button
@@ -0,0 +1,573 @@
import { useState, useEffect, useCallback, useMemo } from 'react';
import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input';
import { Label } from '@/components/ui/label';
import { TogglePill } from '@/components/ui/toggle-pill';
import { Badge } from '@/components/ui/badge';
import { Skeleton } from '@/components/ui/skeleton';
import { Combobox } from '@/components/ui/combobox';
import type { ComboboxOption } from '@/components/ui/combobox';
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select';
import { SegmentedControl } from '@/components/ui/segmented-control';
import { Modal, ModalHeader, ModalBody, ModalFooter, ConfirmModal } from '@/components/ui/modal';
import { toast } from '@/components/ui/toast-store';
import { apiFetch } from '@/lib/api';
import { useNodes } from '@/context/NodeContext';
import { CapabilityGate } from '@/components/CapabilityGate';
import type { NotificationCategory } from '@/components/dashboard/types';
import type { Label as StackLabel } from '@/components/label-types';
import { CATEGORY_LABELS } from '@/lib/notificationCategories';
import { emitMuteRulesChanged, type MuteRuleDraft } from '@/lib/muteRules';
import { useMuteRulesRefresh } from '@/hooks/useMuteRulesRefresh';
import { Plus, Trash2, Pencil, RefreshCw, X, BellOff } from 'lucide-react';
import { SettingsCallout } from './SettingsCallout';
import { SettingsPrimaryButton } from './SettingsActions';
import { useMastheadStats } from './MastheadStatsContext';
type NotificationLevel = 'info' | 'warning' | 'error';
type AppliesTo = 'bell' | 'external' | 'both';
type ExpirationPreset = 'forever' | '1h' | '24h' | 'custom';
interface NotificationSuppressionRule {
id: number;
name: string;
node_id: number | null;
stack_patterns: string[];
label_ids: number[] | null;
categories: NotificationCategory[] | null;
levels: NotificationLevel[] | null;
applies_to: AppliesTo;
enabled: boolean;
expires_at: number | null;
created_at: number;
updated_at: number;
}
const LEVEL_LABELS: Record<NotificationLevel, string> = {
info: 'Info',
warning: 'Warning',
error: 'Error',
};
const APPLIES_TO_LABELS: Record<AppliesTo, string> = {
bell: 'Bell only',
external: 'External only',
both: 'Bell and external',
};
function expirationFromPreset(preset: ExpirationPreset, customMs: number | null): number | null {
if (preset === 'forever') return null;
if (preset === '1h') return Date.now() + 3_600_000;
if (preset === '24h') return Date.now() + 86_400_000;
return customMs;
}
function presetFromExpiresAt(expires_at: number | null): { preset: ExpirationPreset; customMs: number | null } {
if (expires_at == null) return { preset: 'forever', customMs: null };
return { preset: 'custom', customMs: expires_at };
}
function formatExpiry(expires_at: number | null): string {
if (expires_at == null) return 'Never';
if (expires_at <= Date.now()) return 'Expired';
return new Date(expires_at).toLocaleString(undefined, { month: 'short', day: 'numeric', hour: '2-digit', minute: '2-digit' });
}
function applyDraftToForm(
draft: MuteRuleDraft,
setters: {
setFormName: (v: string) => void;
setFormNodeId: (v: number | null) => void;
setFormStacks: (v: string[]) => void;
setFormLabelIds: (v: number[]) => void;
setFormCategories: (v: NotificationCategory[]) => void;
setFormLevels: (v: NotificationLevel[]) => void;
setFormAppliesTo: (v: AppliesTo) => void;
setFormEnabled: (v: boolean) => void;
},
) {
setters.setFormName(draft.name);
setters.setFormNodeId(draft.node_id ?? null);
setters.setFormStacks(draft.stack_patterns ?? []);
setters.setFormLabelIds(draft.label_ids ?? []);
setters.setFormCategories(draft.categories ?? []);
setters.setFormLevels(draft.levels ?? []);
setters.setFormAppliesTo(draft.applies_to ?? 'both');
setters.setFormEnabled(draft.enabled ?? true);
}
interface NotificationSuppressionSectionProps {
prefill?: MuteRuleDraft | null;
onPrefillConsumed?: () => void;
}
export function NotificationSuppressionSection({
prefill = null,
onPrefillConsumed,
}: NotificationSuppressionSectionProps) {
const { nodes } = useNodes();
const [rules, setRules] = useState<NotificationSuppressionRule[]>([]);
const [loading, setLoading] = useState(true);
const [saving, setSaving] = useState(false);
const [showForm, setShowForm] = useState(false);
const [editingId, setEditingId] = useState<number | null>(null);
const [deleteRuleId, setDeleteRuleId] = useState<number | null>(null);
const [stackOptions, setStackOptions] = useState<ComboboxOption[]>([]);
const [labelOptions, setLabelOptions] = useState<StackLabel[]>([]);
const [formName, setFormName] = useState('');
const [formNodeId, setFormNodeId] = useState<number | null>(null);
const [formStacks, setFormStacks] = useState<string[]>([]);
const [formLabelIds, setFormLabelIds] = useState<number[]>([]);
const [formCategories, setFormCategories] = useState<NotificationCategory[]>([]);
const [formLevels, setFormLevels] = useState<NotificationLevel[]>([]);
const [formAppliesTo, setFormAppliesTo] = useState<AppliesTo>('both');
const [formEnabled, setFormEnabled] = useState(true);
const [formExpirationPreset, setFormExpirationPreset] = useState<ExpirationPreset>('forever');
const [formCustomExpiry, setFormCustomExpiry] = useState('');
const fetchRules = useCallback(async () => {
try {
const res = await apiFetch('/notification-suppression-rules');
if (res.ok) setRules(await res.json());
} catch {
toast.error('Failed to load mute rules.');
} finally {
setLoading(false);
}
}, []);
const fetchStacks = useCallback(async () => {
try {
const res = await apiFetch('/stacks');
if (res.ok) {
const data: string[] = await res.json();
setStackOptions(data.map((s) => ({ value: s, label: s })));
}
} catch { /* non-critical */ }
}, []);
const fetchLabels = useCallback(async () => {
try {
const res = await apiFetch('/labels');
if (res.ok) setLabelOptions(await res.json());
} catch { /* non-critical */ }
}, []);
useEffect(() => {
void Promise.all([fetchRules(), fetchStacks(), fetchLabels()]);
}, [fetchRules, fetchStacks, fetchLabels]);
useMuteRulesRefresh(fetchRules);
useEffect(() => {
if (!prefill) return;
applyDraftToForm(prefill, {
setFormName,
setFormNodeId,
setFormStacks,
setFormLabelIds,
setFormCategories,
setFormLevels,
setFormAppliesTo,
setFormEnabled,
});
setFormExpirationPreset('forever');
setFormCustomExpiry('');
setEditingId(null);
setShowForm(true);
onPrefillConsumed?.();
}, [prefill, onPrefillConsumed]);
const resetForm = () => {
setFormName('');
setFormNodeId(null);
setFormStacks([]);
setFormLabelIds([]);
setFormCategories([]);
setFormLevels([]);
setFormAppliesTo('both');
setFormEnabled(true);
setFormExpirationPreset('forever');
setFormCustomExpiry('');
setEditingId(null);
setShowForm(false);
};
const startEdit = (rule: NotificationSuppressionRule) => {
const { preset, customMs } = presetFromExpiresAt(rule.expires_at);
setEditingId(rule.id);
setFormName(rule.name);
setFormNodeId(rule.node_id);
setFormStacks([...rule.stack_patterns]);
setFormLabelIds(rule.label_ids ? [...rule.label_ids] : []);
setFormCategories(rule.categories ? [...rule.categories] : []);
setFormLevels(rule.levels ? [...rule.levels] : []);
setFormAppliesTo(rule.applies_to);
setFormEnabled(rule.enabled);
setFormExpirationPreset(preset);
setFormCustomExpiry(customMs != null ? new Date(customMs).toISOString().slice(0, 16) : '');
setShowForm(true);
};
const handleSave = async () => {
if (!formName.trim()) { toast.error('Name is required.'); return; }
const customMs = formCustomExpiry ? new Date(formCustomExpiry).getTime() : null;
if (formExpirationPreset === 'custom' && (customMs == null || Number.isNaN(customMs))) {
toast.error('Choose a valid custom expiration date.');
return;
}
setSaving(true);
try {
const body = {
name: formName.trim(),
node_id: formNodeId,
stack_patterns: formStacks,
label_ids: formLabelIds.length > 0 ? formLabelIds : null,
categories: formCategories.length > 0 ? formCategories : null,
levels: formLevels.length > 0 ? formLevels : null,
applies_to: formAppliesTo,
enabled: formEnabled,
expires_at: expirationFromPreset(formExpirationPreset, customMs),
};
const url = editingId
? `/notification-suppression-rules/${editingId}`
: '/notification-suppression-rules';
const res = await apiFetch(url, {
method: editingId ? 'PUT' : 'POST',
body: JSON.stringify(body),
});
if (res.ok) {
toast.success(editingId ? 'Mute rule updated.' : 'Mute rule created.');
emitMuteRulesChanged();
resetForm();
fetchRules();
} else {
const err = await res.json().catch(() => ({}));
toast.error(err?.error || err?.message || 'Something went wrong.');
}
} catch (e: unknown) {
toast.error((e as Error)?.message || 'Network error.');
} finally {
setSaving(false);
}
};
const handleDelete = async () => {
if (deleteRuleId == null) return;
try {
const res = await apiFetch(`/notification-suppression-rules/${deleteRuleId}`, { method: 'DELETE' });
if (res.ok) {
toast.success('Mute rule deleted.');
emitMuteRulesChanged();
fetchRules();
} else {
const err = await res.json().catch(() => ({}));
toast.error(err?.error || 'Something went wrong.');
}
} catch {
toast.error('Network error.');
} finally {
setDeleteRuleId(null);
}
};
const handleToggleEnabled = async (rule: NotificationSuppressionRule) => {
try {
const res = await apiFetch(`/notification-suppression-rules/${rule.id}`, {
method: 'PUT',
body: JSON.stringify({ enabled: !rule.enabled }),
});
if (res.ok) {
emitMuteRulesChanged();
fetchRules();
}
else {
const err = await res.json().catch(() => ({}));
toast.error(err?.error || 'Something went wrong.');
}
} catch {
toast.error('Network error.');
}
};
const addStack = (stackName: string) => {
if (stackName && !formStacks.includes(stackName)) setFormStacks((prev) => [...prev, stackName]);
};
const removeStack = (stackName: string) => setFormStacks((prev) => prev.filter((s) => s !== stackName));
const addLabel = (idStr: string) => {
const id = Number(idStr);
if (!Number.isNaN(id) && id > 0 && !formLabelIds.includes(id)) setFormLabelIds((prev) => [...prev, id]);
};
const removeLabel = (id: number) => setFormLabelIds((prev) => prev.filter((l) => l !== id));
const addCategory = (cat: string) => {
const c = cat as NotificationCategory;
if (c && !formCategories.includes(c)) setFormCategories((prev) => [...prev, c]);
};
const removeCategory = (cat: NotificationCategory) => setFormCategories((prev) => prev.filter((c) => c !== cat));
const addLevel = (level: string) => {
const l = level as NotificationLevel;
if (l && !formLevels.includes(l)) setFormLevels((prev) => [...prev, l]);
};
const removeLevel = (level: NotificationLevel) => setFormLevels((prev) => prev.filter((x) => x !== level));
const enabledCount = rules.filter((r) => r.enabled && (r.expires_at == null || r.expires_at > Date.now())).length;
useMastheadStats(
loading ? null : [
{ label: 'RULES', value: `${rules.length}` },
{ label: 'ACTIVE', value: `${enabledCount}`, tone: enabledCount > 0 ? 'value' : 'subtitle' },
],
);
const availableStackOptions = stackOptions.filter((o) => !formStacks.includes(o.value));
const availableLabelOptions = useMemo<ComboboxOption[]>(
() => labelOptions.filter((l) => !formLabelIds.includes(l.id)).map((l) => ({ value: String(l.id), label: l.name })),
[labelOptions, formLabelIds],
);
const availableCategoryOptions = useMemo<ComboboxOption[]>(
() => (Object.keys(CATEGORY_LABELS) as NotificationCategory[])
.filter((c) => !formCategories.includes(c))
.map((c) => ({ value: c, label: CATEGORY_LABELS[c] })),
[formCategories],
);
const availableLevelOptions = useMemo<ComboboxOption[]>(
() => (['info', 'warning', 'error'] as NotificationLevel[])
.filter((l) => !formLevels.includes(l))
.map((l) => ({ value: l, label: LEVEL_LABELS[l] })),
[formLevels],
);
const deleteTarget = deleteRuleId != null ? rules.find((r) => r.id === deleteRuleId) : null;
return (
<CapabilityGate capability="notification-suppression" featureName="Mute Rules">
<div className="space-y-6">
<SettingsCallout
icon={<BellOff className="h-4 w-4" strokeWidth={1.5} />}
title="Mute rules vs routing"
subtitle="Routing sends matching alerts to another channel. Mute rules hide or drop delivery to the bell, external channels, or both. Events still appear in stack activity history."
/>
<div className="flex justify-end">
<SettingsPrimaryButton size="sm" onClick={() => { resetForm(); setShowForm(true); }}>
<Plus className="w-4 h-4" /> Add mute rule
</SettingsPrimaryButton>
</div>
<Modal open={showForm} onOpenChange={(open) => { if (!open) resetForm(); }} size="lg">
<ModalHeader
kicker={editingId ? 'MUTE RULES · EDIT RULE' : 'MUTE RULES · NEW RULE'}
title={editingId ? 'Edit mute rule' : 'New mute rule'}
description="Match alerts by node, stack, label, category, or severity, then choose where to mute delivery."
/>
<ModalBody>
<div className="space-y-2">
<Label>Name</Label>
<Input placeholder="e.g. Mute staging deploy noise" value={formName} onChange={(e) => setFormName(e.target.value)} maxLength={100} />
</div>
<div className="space-y-2">
<Label>Node scope</Label>
<Select
value={formNodeId === null ? 'any' : String(formNodeId)}
onValueChange={(v) => setFormNodeId(v === 'any' ? null : Number(v))}
>
<SelectTrigger><SelectValue /></SelectTrigger>
<SelectContent>
<SelectItem value="any">Any node</SelectItem>
{nodes.map((n) => (
<SelectItem key={n.id} value={String(n.id)}>{n.name}</SelectItem>
))}
</SelectContent>
</Select>
</div>
<div className="space-y-2">
<Label>Stacks <span className="text-muted-foreground font-normal text-xs">(optional)</span></Label>
<Combobox options={availableStackOptions} value="" onValueChange={addStack} placeholder="Add a stack..." searchPlaceholder="Search stacks..." emptyText="No stacks found." />
{formStacks.length > 0 && (
<div className="flex flex-wrap gap-1.5 pt-1">
{formStacks.map((s) => (
<Badge key={s} variant="secondary" className="font-mono text-xs gap-1 pr-1">
{s}
<button type="button" onClick={() => removeStack(s)} className="ml-0.5 rounded-full hover:bg-foreground/10 p-0.5"><X className="w-3 h-3" /></button>
</Badge>
))}
</div>
)}
</div>
<div className="space-y-2">
<Label>Labels <span className="text-muted-foreground font-normal text-xs">(optional)</span></Label>
<Combobox options={availableLabelOptions} value="" onValueChange={addLabel} placeholder="Add a label..." searchPlaceholder="Search labels..." emptyText="No labels found." />
{formLabelIds.length > 0 && (
<div className="flex flex-wrap gap-1.5 pt-1">
{formLabelIds.map((id) => {
const lbl = labelOptions.find((l) => l.id === id);
return (
<Badge key={id} variant="secondary" className="text-xs gap-1 pr-1">
{lbl?.name ?? `Label ${id}`}
<button type="button" onClick={() => removeLabel(id)} className="ml-0.5 rounded-full hover:bg-foreground/10 p-0.5"><X className="w-3 h-3" /></button>
</Badge>
);
})}
</div>
)}
</div>
<div className="space-y-2">
<Label>Categories <span className="text-muted-foreground font-normal text-xs">(optional)</span></Label>
<Combobox options={availableCategoryOptions} value="" onValueChange={addCategory} placeholder="Add a category..." searchPlaceholder="Search categories..." emptyText="No categories found." />
{formCategories.length > 0 && (
<div className="flex flex-wrap gap-1.5 pt-1">
{formCategories.map((c) => (
<Badge key={c} variant="secondary" className="text-xs gap-1 pr-1">
{CATEGORY_LABELS[c]}
<button type="button" onClick={() => removeCategory(c)} className="ml-0.5 rounded-full hover:bg-foreground/10 p-0.5"><X className="w-3 h-3" /></button>
</Badge>
))}
</div>
)}
</div>
<div className="space-y-2">
<Label>Severity <span className="text-muted-foreground font-normal text-xs">(optional)</span></Label>
<Combobox options={availableLevelOptions} value="" onValueChange={addLevel} placeholder="Add a severity..." searchPlaceholder="Search..." emptyText="No levels left." />
{formLevels.length > 0 && (
<div className="flex flex-wrap gap-1.5 pt-1">
{formLevels.map((l) => (
<Badge key={l} variant="outline" className="text-xs gap-1 pr-1">
{LEVEL_LABELS[l]}
<button type="button" onClick={() => removeLevel(l)} className="ml-0.5 rounded-full hover:bg-foreground/10 p-0.5"><X className="w-3 h-3" /></button>
</Badge>
))}
</div>
)}
<p className="text-xs text-muted-foreground">Leave matchers blank to match any value. All non-empty filters must match (AND).</p>
</div>
<div className="space-y-2">
<Label>Apply to</Label>
<SegmentedControl
value={formAppliesTo}
options={[
{ value: 'bell', label: 'Bell' },
{ value: 'external', label: 'External' },
{ value: 'both', label: 'Both' },
]}
onChange={setFormAppliesTo}
ariaLabel="Suppression target"
fullWidth
/>
</div>
<div className="space-y-2">
<Label>Expiration</Label>
<Select value={formExpirationPreset} onValueChange={(v) => setFormExpirationPreset(v as ExpirationPreset)}>
<SelectTrigger><SelectValue /></SelectTrigger>
<SelectContent>
<SelectItem value="forever">Forever</SelectItem>
<SelectItem value="1h">1 hour</SelectItem>
<SelectItem value="24h">24 hours</SelectItem>
<SelectItem value="custom">Custom date</SelectItem>
</SelectContent>
</Select>
{formExpirationPreset === 'custom' && (
<Input type="datetime-local" value={formCustomExpiry} onChange={(e) => setFormCustomExpiry(e.target.value)} />
)}
</div>
<div className="flex items-center gap-2">
<TogglePill checked={formEnabled} onChange={setFormEnabled} id="mute-rule-enabled" />
<span className="text-sm text-stat-value select-none">
{formEnabled ? 'Enabled' : 'Disabled'}
</span>
</div>
</ModalBody>
<ModalFooter
secondary={<Button variant="outline" size="sm" onClick={resetForm}>Cancel</Button>}
primary={
<SettingsPrimaryButton size="sm" onClick={handleSave} disabled={saving}>
{saving ? <><RefreshCw className="w-4 h-4 animate-spin" />Saving</> : editingId ? 'Update' : 'Create'}
</SettingsPrimaryButton>
}
/>
</Modal>
{loading && (
<div className="space-y-3">
<Skeleton className="h-20 w-full rounded-xl" />
<Skeleton className="h-20 w-full rounded-xl" />
</div>
)}
{!loading && rules.length === 0 && (
<SettingsCallout
icon={<BellOff className="h-4 w-4" strokeWidth={1.5} />}
title="No mute rules configured"
subtitle="Alerts follow your routing and global channels unless a mute rule matches."
/>
)}
{!loading && rules.map((rule) => (
<div key={rule.id} className="rounded-lg border border-card-border border-t-card-border-top bg-card text-card-foreground shadow-card-bevel transition-colors hover:border-t-card-border-hover p-4 space-y-3">
<div className="flex items-center justify-between gap-2">
<div className="flex items-center gap-2 min-w-0 flex-wrap">
<BellOff className="w-4 h-4 text-muted-foreground shrink-0" strokeWidth={1.5} />
<span className="font-medium text-sm truncate">{rule.name}</span>
<Badge variant="outline" className="text-[10px] shrink-0">{APPLIES_TO_LABELS[rule.applies_to]}</Badge>
{rule.node_id !== null && (
<Badge variant="secondary" className="text-[10px] shrink-0 font-mono">
{nodes.find((n) => n.id === rule.node_id)?.name ?? `node:${rule.node_id}`}
</Badge>
)}
{!rule.enabled && <Badge variant="secondary" className="text-[10px] shrink-0 text-muted-foreground">Disabled</Badge>}
{rule.expires_at != null && rule.expires_at <= Date.now() && (
<Badge variant="secondary" className="text-[10px] shrink-0 text-muted-foreground">Expired</Badge>
)}
</div>
<div className="flex items-center gap-1 shrink-0">
<TogglePill checked={rule.enabled} onChange={() => handleToggleEnabled(rule)} className="scale-75" />
<Button variant="ghost" size="sm" onClick={() => startEdit(rule)} title="Edit"><Pencil className="w-4 h-4" strokeWidth={1.5} /></Button>
<Button variant="ghost" size="sm" className="text-destructive/60 hover:bg-destructive hover:text-destructive-foreground" title="Delete" onClick={() => setDeleteRuleId(rule.id)}>
<Trash2 className="w-4 h-4" strokeWidth={1.5} />
</Button>
</div>
</div>
<div className="flex flex-wrap items-center gap-1.5 text-xs text-muted-foreground">
{rule.stack_patterns.map((s) => <Badge key={s} variant="secondary" className="font-mono text-[10px]">{s}</Badge>)}
{rule.label_ids?.map((id) => {
const lbl = labelOptions.find((l) => l.id === id);
return <Badge key={id} variant="outline" className="text-[10px]">{lbl?.name ?? `label:${id}`}</Badge>;
})}
{rule.categories?.map((c) => <Badge key={c} variant="outline" className="text-[10px] font-mono">{CATEGORY_LABELS[c as NotificationCategory] ?? c}</Badge>)}
{rule.levels?.map((l) => <Badge key={l} variant="outline" className="text-[10px]">{LEVEL_LABELS[l]}</Badge>)}
{rule.stack_patterns.length === 0 && !rule.label_ids?.length && !rule.categories?.length && !rule.levels?.length && (
<span className="text-muted-foreground/50 text-[10px]">Matches all alerts</span>
)}
<span className="text-muted-foreground/50">|</span>
<span className="tabular-nums">Expires: {formatExpiry(rule.expires_at)}</span>
</div>
</div>
))}
<ConfirmModal
open={deleteRuleId != null}
onOpenChange={(open) => { if (!open) setDeleteRuleId(null); }}
variant="destructive"
kicker="MUTE RULES · DELETE · IRREVERSIBLE"
title="Delete mute rule"
confirmLabel="Delete"
onConfirm={handleDelete}
>
<p className="text-sm text-stat-subtitle">
Deletes <span className="font-medium text-stat-value">{deleteTarget?.name ?? 'this rule'}</span>. Matching alerts will deliver normally again.
</p>
</ConfirmModal>
</div>
</CapabilityGate>
);
}
@@ -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}
/>
</div>
</ScrollArea>
@@ -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 <AccountSection />;
@@ -81,7 +88,7 @@ function renderSection(
case 'sso': return <SSOSection />;
case 'api-tokens': return <ApiTokensSection />;
case 'registries': return <RegistriesSection />;
case 'labels': return <LabelsSection />;
case 'labels': return <LabelsSection onOpenMuteRulesWithPrefill={onOpenMuteRulesWithPrefill} />;
case 'host-alerts': return <HostAlertsSection onDirtyChange={(d) => onDirtyChange('host-alerts', d)} />;
case 'container-alerts': return <ContainerAlertsSection onDirtyChange={(d) => onDirtyChange('container-alerts', d)} />;
case 'docker-storage': return <DockerStorageSection onDirtyChange={(d) => onDirtyChange('docker-storage', d)} />;
@@ -89,6 +96,12 @@ function renderSection(
case 'fleet-mesh': return <FleetMeshSection onDirtyChange={(d) => onDirtyChange('fleet-mesh', d)} />;
case 'notifications': return <NotificationsSection />;
case 'notification-routing': return <NotificationRoutingSection />;
case 'notification-suppression': return (
<NotificationSuppressionSection
prefill={muteRulePrefill}
onPrefillConsumed={onMutePrefillConsumed}
/>
);
case 'webhooks': return <WebhooksSection />;
case 'cloud-backup': return <CloudBackupSection />;
case 'developer': return <DeveloperSection onDirtyChange={(d) => 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 (
<>
@@ -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)', () => {
@@ -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',
@@ -69,6 +69,7 @@ export type SectionId =
| 'app-store'
| 'stacks'
| 'notification-routing'
| 'notification-suppression'
| 'recovery'
| 'support'
| 'about';
@@ -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 }) {
<ContextMenuItem onClick={ctx.openLabelManager}>
<span className="text-xs">Manage labels...</span>
</ContextMenuItem>
{ctx.canMuteNotifications && ctx.labels.length > 0 && (
<>
<ContextMenuSeparator />
<ContextMenuSub>
<ContextMenuSubTrigger>
<BellOff className="w-3.5 h-3.5 mr-2" strokeWidth={1.5} />
<span className="text-xs">Mute label</span>
</ContextMenuSubTrigger>
<ContextMenuSubContent className="min-w-[200px]">
{ctx.labels.map(label => (
<ContextMenuSub key={label.id}>
<ContextMenuSubTrigger>
<LabelDot color={label.color} />
<span className="font-mono text-[12px] ml-2">{label.name}</span>
</ContextMenuSubTrigger>
<ContextMenuSubContent>
<ContextMenuItem onClick={() => ctx.muteLabelAll(label.id, label.name)}>
<span className="text-xs">Mute notifications for this label</span>
</ContextMenuItem>
<ContextMenuItem onClick={() => ctx.muteLabelExternal(label.id, label.name)}>
<span className="text-xs">Mute external alerts for this label</span>
</ContextMenuItem>
<ContextMenuItem onClick={() => ctx.muteLabelLowPriority(label.id, label.name)}>
<span className="text-xs">Mute low-priority stack alerts</span>
</ContextMenuItem>
<ContextMenuSeparator />
<ContextMenuItem onClick={() => ctx.openLabelMuteRules(label.id, label.name)}>
<span className="text-xs">Manage label mute rules</span>
</ContextMenuItem>
</ContextMenuSubContent>
</ContextMenuSub>
))}
</ContextMenuSubContent>
</ContextMenuSub>
</>
)}
</>
)}
</ContextMenuSubContent>
@@ -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 (
<MenuSub>
<MenuSubTrigger>
<item.icon className="h-4 w-4 mr-2" strokeWidth={1.5} />
{item.label}
</MenuSubTrigger>
<MenuSubContent className="min-w-[220px]">
{item.subItems?.map((sub) => (
<MenuItem key={sub.id} onClick={() => sub.onSelect()}>
<span className="text-xs">{sub.label}</span>
</MenuItem>
))}
</MenuSubContent>
</MenuSub>
);
}
function renderItem(item: MenuItem, ctx: StackMenuCtx) {
if (item.id === 'labels') return <LabelsSub key={item.id} item={item} ctx={ctx} />;
if (item.subItems?.length) {
return (
<SimpleSub
key={item.id}
item={item}
MenuSub={ContextMenuSub}
MenuSubTrigger={ContextMenuSubTrigger}
MenuSubContent={ContextMenuSubContent}
MenuItem={ContextMenuItem}
/>
);
}
return (
<ContextMenuItem
key={item.id}
@@ -10,10 +10,11 @@ interface StackGroupProps {
collapsed: boolean;
onToggle: () => 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}
</span>
<span className="flex items-center gap-1.5">
{headerActions}
<span className="font-mono text-[9px] tabular-nums text-stat-icon">{count}</span>
{collapsed
? <ChevronRight className="w-3 h-3 text-stat-icon" strokeWidth={1.5} />
@@ -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 }) {
<DropdownMenuItem onClick={ctx.openLabelManager}>
<span className="text-xs">Manage labels...</span>
</DropdownMenuItem>
{ctx.canMuteNotifications && ctx.labels.length > 0 && (
<>
<DropdownMenuSeparator />
<DropdownMenuSub>
<DropdownMenuSubTrigger>
<BellOff className="w-3.5 h-3.5 mr-2" strokeWidth={1.5} />
<span className="text-xs">Mute label</span>
</DropdownMenuSubTrigger>
<DropdownMenuSubContent className="min-w-[200px]">
{ctx.labels.map(label => (
<DropdownMenuSub key={label.id}>
<DropdownMenuSubTrigger>
<LabelDot color={label.color} />
<span className="font-mono text-[12px] ml-2">{label.name}</span>
</DropdownMenuSubTrigger>
<DropdownMenuSubContent>
<DropdownMenuItem onSelect={() => ctx.muteLabelAll(label.id, label.name)}>
<span className="text-xs">Mute notifications for this label</span>
</DropdownMenuItem>
<DropdownMenuItem onSelect={() => ctx.muteLabelExternal(label.id, label.name)}>
<span className="text-xs">Mute external alerts for this label</span>
</DropdownMenuItem>
<DropdownMenuItem onSelect={() => ctx.muteLabelLowPriority(label.id, label.name)}>
<span className="text-xs">Mute low-priority stack alerts</span>
</DropdownMenuItem>
<DropdownMenuSeparator />
<DropdownMenuItem onSelect={() => ctx.openLabelMuteRules(label.id, label.name)}>
<span className="text-xs">Manage label mute rules</span>
</DropdownMenuItem>
</DropdownMenuSubContent>
</DropdownMenuSub>
))}
</DropdownMenuSubContent>
</DropdownMenuSub>
</>
)}
</>
)}
</DropdownMenuSubContent>
@@ -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 (
<MenuSub>
<MenuSubTrigger>
<item.icon className="h-4 w-4 mr-2" strokeWidth={1.5} />
{item.label}
</MenuSubTrigger>
<MenuSubContent className="min-w-[220px]">
{item.subItems?.map((sub) => (
<MenuItem key={sub.id} onSelect={() => sub.onSelect()}>
<span className="text-xs">{sub.label}</span>
</MenuItem>
))}
</MenuSubContent>
</MenuSub>
);
}
function renderItem(item: MenuItem, ctx: StackMenuCtx) {
if (item.id === 'labels') return <LabelsSub key={item.id} item={item} ctx={ctx} />;
if (item.subItems?.length) {
return (
<SimpleSub
key={item.id}
item={item}
MenuSub={DropdownMenuSub}
MenuSubTrigger={DropdownMenuSubTrigger}
MenuSubContent={DropdownMenuSubContent}
MenuItem={DropdownMenuItem}
/>
);
}
return (
<DropdownMenuItem
key={item.id}
@@ -13,6 +13,9 @@ import { StackContextMenu } from './StackContextMenu';
import { StackKebabMenu } from './StackKebabMenu';
import { EmptyStackState } from './EmptyStackState';
import type { StackMenuCtx, FilterChip } from './sidebar-types';
import type { MuteRuleDraft } from '@/lib/muteRules';
import { LabelGroupMuteKebab } from '@/components/mute/MuteMenuItems';
import { useLabelMuteActions } from '@/hooks/useMuteRuleActions';
interface RemoteNodeResult {
nodeId: number;
@@ -54,6 +57,7 @@ export interface StackListProps {
// Open the create dialog on a starting mode. Present only when the user can
// create stacks; drives the zero-stacks empty state.
onOpenCreate?: (mode: 'import' | 'empty') => 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 <LabelGroupMuteKebab actions={actions} />;
}
interface StackListBulkProps {
bulkMode: boolean;
selectedFiles: Set<string>;
@@ -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
? (
<LabelGroupMuteActions
labelId={g.labelId}
labelName={g.labelName}
openMuteRulesWithPrefill={openMuteRulesWithPrefill}
/>
)
: undefined
}
>
{g.files.map(file => {
const ctx = buildMenuCtx(file);
@@ -46,6 +46,15 @@ export interface StackMenuCtx {
unpin: () => void;
toggleLabel: (labelId: number) => void;
createAndAssignLabel: (name: string, color: LabelColor) => Promise<void>;
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;
}
@@ -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<string, unknown>;
@@ -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 (
<div
key={e.id}
@@ -275,6 +307,18 @@ export function StackActivityTimeline({ stackName, liveEvents }: StackActivityTi
{actor.isSystem ? `via ${actor.label}` : `by ${actor.label}`}
</span>
)}
{showSuppressed && suppression && (
<Tooltip>
<TooltipTrigger asChild>
<Badge variant="outline" className="ml-1.5 align-middle text-[9px] font-mono uppercase tracking-wide px-1 py-0 h-4">
Suppressed
</Badge>
</TooltipTrigger>
<TooltipContent side="top" className="font-mono text-[10px] max-w-xs">
{suppressionTooltip(suppression)}
</TooltipContent>
</Tooltip>
)}
</div>
<span className="font-mono text-[10px] text-stat-subtitle shrink-0">{formatTimeAgo(e.timestamp)}</span>
</div>
@@ -32,6 +32,15 @@ function makeCtx(overrides: Partial<StackMenuCtx> = {}): 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' }],
+107
View File
@@ -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],
);
}
+11
View File
@@ -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]);
}
+17
View File
@@ -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,
]);
}
+1
View File
@@ -13,6 +13,7 @@ export const CAPABILITIES = [
'network-topology',
'notifications',
'notification-routing',
'notification-suppression',
'host-console',
'container-exec',
'audit-log',
+188
View File
@@ -0,0 +1,188 @@
import { apiFetch } from '@/lib/api';
import { toast } from '@/components/ui/toast-store';
import { CATEGORY_LABELS } from '@/lib/notificationCategories';
import type { NotificationCategory, NotificationItem } from '@/components/dashboard/types';
export type MuteRuleLevel = 'info' | 'warning' | 'error';
export type MuteRuleAppliesTo = 'bell' | 'external' | 'both';
export type MuteRuleDraft = {
name: string;
node_id?: number | null;
stack_patterns?: string[];
label_ids?: number[] | null;
categories?: NotificationCategory[] | null;
levels?: MuteRuleLevel[] | null;
applies_to?: MuteRuleAppliesTo;
enabled?: boolean;
expires_at?: number | null;
};
export const MUTE_RULES_CHANGED_EVENT = 'sencho:mute-rules-changed';
export function emitMuteRulesChanged(): void {
window.dispatchEvent(new CustomEvent(MUTE_RULES_CHANGED_EVENT));
}
const DEFAULT_BODY = {
applies_to: 'both' as MuteRuleAppliesTo,
enabled: true,
expires_at: null as number | null,
node_id: null as number | null,
stack_patterns: [] as string[],
label_ids: null as number[] | null,
categories: null as NotificationCategory[] | null,
levels: null as MuteRuleLevel[] | null,
};
export async function createMuteRule(draft: MuteRuleDraft): Promise<{ ok: boolean; error?: string }> {
try {
const res = await apiFetch('/notification-suppression-rules', {
method: 'POST',
body: JSON.stringify({ ...DEFAULT_BODY, ...draft }),
});
if (res.ok) {
emitMuteRulesChanged();
return { ok: true };
}
const err = await res.json().catch(() => ({}));
return { ok: false, error: (err as { error?: string })?.error || 'Failed to create mute rule.' };
} catch {
return { ok: false, error: 'Network error.' };
}
}
export async function createMuteRuleWithToast(
draft: MuteRuleDraft,
successMessage = 'Mute rule created. Open Settings · Mute Rules to edit it.',
): Promise<boolean> {
const result = await createMuteRule(draft);
if (result.ok) {
toast.success(successMessage);
return true;
}
toast.error(result.error || 'Failed to create mute rule.');
return false;
}
export function stackMuteAllDraft(stackName: string, nodeId?: number | null): MuteRuleDraft {
return {
name: `Mute ${stackName}`,
node_id: nodeId ?? null,
stack_patterns: [stackName],
};
}
export function stackMuteDeploySuccessDraft(stackName: string, nodeId?: number | null): MuteRuleDraft {
return {
name: `Mute ${stackName} deploy success`,
node_id: nodeId ?? null,
stack_patterns: [stackName],
categories: ['deploy_success'],
};
}
export function stackMuteMonitorDraft(stackName: string, nodeId?: number | null): MuteRuleDraft {
return {
name: `Mute ${stackName} monitor alerts`,
node_id: nodeId ?? null,
stack_patterns: [stackName],
categories: ['monitor_alert'],
};
}
export function nodeMuteAllDraft(nodeId: number, nodeName: string): MuteRuleDraft {
return {
name: `Mute ${nodeName}`,
node_id: nodeId,
};
}
export function nodeMuteUpdatesDraft(nodeId: number, nodeName: string): MuteRuleDraft {
return {
name: `Mute ${nodeName} update notifications`,
node_id: nodeId,
categories: ['image_update_available', 'node_update_available', 'update_started'],
};
}
export function nodeMuteMonitorDraft(nodeId: number, nodeName: string): MuteRuleDraft {
return {
name: `Mute ${nodeName} monitor alerts`,
node_id: nodeId,
categories: ['monitor_alert'],
};
}
export function labelMuteAllDraft(labelId: number, labelName: string, nodeId?: number | null): MuteRuleDraft {
return {
name: `Mute label: ${labelName}`,
node_id: nodeId ?? null,
label_ids: [labelId],
applies_to: 'both',
};
}
export function labelMuteExternalDraft(labelId: number, labelName: string, nodeId?: number | null): MuteRuleDraft {
return {
name: `Mute external for label: ${labelName}`,
node_id: nodeId ?? null,
label_ids: [labelId],
applies_to: 'external',
};
}
export function labelMuteLowPriorityDraft(labelId: number, labelName: string, nodeId?: number | null): MuteRuleDraft {
return {
name: `Mute low-priority alerts for label: ${labelName}`,
node_id: nodeId ?? null,
label_ids: [labelId],
levels: ['info', 'warning'],
};
}
export type BellMuteMode = 'category' | 'similar' | 'stack';
export function muteDraftFromNotification(
notif: NotificationItem,
mode: BellMuteMode,
): MuteRuleDraft | null {
const categoryLabel = notif.category ? CATEGORY_LABELS[notif.category as NotificationCategory] : 'alert';
if (mode === 'category' && notif.category) {
return {
name: `Mute ${categoryLabel}`,
node_id: notif.nodeId ?? null,
categories: [notif.category as NotificationCategory],
};
}
if (mode === 'stack' && notif.stack_name) {
return {
name: `Mute ${notif.stack_name}`,
node_id: notif.nodeId ?? null,
stack_patterns: [notif.stack_name],
};
}
if (mode === 'similar') {
return {
name: 'Mute similar alerts',
node_id: notif.nodeId ?? null,
categories: notif.category ? [notif.category as NotificationCategory] : null,
levels: [notif.level],
stack_patterns: notif.stack_name ? [notif.stack_name] : [],
};
}
return null;
}
export async function createMuteFromNotification(
notif: NotificationItem,
mode: BellMuteMode,
): Promise<boolean> {
const draft = muteDraftFromNotification(notif, mode);
if (!draft) {
toast.error('This notification cannot be muted with that shortcut.');
return false;
}
return createMuteRuleWithToast(draft);
}
@@ -11,6 +11,11 @@ export const CATEGORY_LABELS: Record<NotificationCategory, string> = {
autoheal_triggered: 'Auto-heal',
monitor_alert: 'Monitor alert',
scan_finding: 'Scan finding',
drift_detected: 'Drift detected',
drift_resolved: 'Drift resolved',
update_started: 'Update started',
health_gate_passed: 'Health gate passed',
health_gate_failed: 'Health gate failed',
node_update_available: 'Node update',
system: 'System',
};