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