mirror of
https://github.com/Studio-Saelix/sencho.git
synced 2026-08-19 23:06:49 +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');
|
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
|
// Regression for the Global Observability admin gate: the logs feed's
|
||||||
// `requireAdmin` lives in the local route handler, which the proxy skips when
|
// `requireAdmin` lives in the local route handler, which the proxy skips when
|
||||||
// forwarding a remote nodeId. Without these prefixes the guard would let the
|
// 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 {
|
const {
|
||||||
mockGetEnabledNotificationRoutes,
|
mockGetEnabledNotificationRoutes,
|
||||||
|
mockGetEnabledNotificationSuppressionRules,
|
||||||
mockGetEnabledAgents,
|
mockGetEnabledAgents,
|
||||||
mockGetStackLabelIds,
|
mockGetStackLabelIds,
|
||||||
mockAddNotificationHistory,
|
mockAddNotificationHistory,
|
||||||
mockUpdateNotificationDispatchError,
|
mockUpdateNotificationDispatchError,
|
||||||
} = vi.hoisted(() => ({
|
} = vi.hoisted(() => ({
|
||||||
mockGetEnabledNotificationRoutes: vi.fn().mockReturnValue([]),
|
mockGetEnabledNotificationRoutes: vi.fn().mockReturnValue([]),
|
||||||
|
mockGetEnabledNotificationSuppressionRules: vi.fn().mockReturnValue([]),
|
||||||
mockGetEnabledAgents: vi.fn().mockReturnValue([]),
|
mockGetEnabledAgents: vi.fn().mockReturnValue([]),
|
||||||
mockGetStackLabelIds: vi.fn().mockReturnValue([]),
|
mockGetStackLabelIds: vi.fn().mockReturnValue([]),
|
||||||
mockAddNotificationHistory: vi.fn().mockReturnValue({
|
mockAddNotificationHistory: vi.fn().mockReturnValue({
|
||||||
@@ -30,6 +32,7 @@ vi.mock('../services/DatabaseService', () => ({
|
|||||||
DatabaseService: {
|
DatabaseService: {
|
||||||
getInstance: () => ({
|
getInstance: () => ({
|
||||||
getEnabledNotificationRoutes: mockGetEnabledNotificationRoutes,
|
getEnabledNotificationRoutes: mockGetEnabledNotificationRoutes,
|
||||||
|
getEnabledNotificationSuppressionRules: mockGetEnabledNotificationSuppressionRules,
|
||||||
getEnabledAgents: mockGetEnabledAgents,
|
getEnabledAgents: mockGetEnabledAgents,
|
||||||
getStackLabelIds: mockGetStackLabelIds,
|
getStackLabelIds: mockGetStackLabelIds,
|
||||||
addNotificationHistory: mockAddNotificationHistory,
|
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/scheduled-tasks/',
|
||||||
'/api/audit-log/',
|
'/api/audit-log/',
|
||||||
'/api/notification-routes/',
|
'/api/notification-routes/',
|
||||||
|
'/api/notification-suppression-rules/',
|
||||||
'/api/logs/global/',
|
'/api/logs/global/',
|
||||||
'/api/system/log-stream-metrics/',
|
'/api/system/log-stream-metrics/',
|
||||||
'/api/registries/',
|
'/api/registries/',
|
||||||
|
|||||||
@@ -36,7 +36,7 @@ import { agentsRouter } from './routes/agents';
|
|||||||
import { metricsRouter } from './routes/metrics';
|
import { metricsRouter } from './routes/metrics';
|
||||||
import { imageUpdatesRouter, autoUpdateRouter } from './routes/imageUpdates';
|
import { imageUpdatesRouter, autoUpdateRouter } from './routes/imageUpdates';
|
||||||
import { autoHealRouter } from './routes/autoHeal';
|
import { autoHealRouter } from './routes/autoHeal';
|
||||||
import { notificationsRouter, notificationRoutesRouter } from './routes/notifications';
|
import { notificationsRouter, notificationRoutesRouter, notificationSuppressionRouter } from './routes/notifications';
|
||||||
import { consoleRouter } from './routes/console';
|
import { consoleRouter } from './routes/console';
|
||||||
import { ssoConfigRouter } from './routes/ssoConfig';
|
import { ssoConfigRouter } from './routes/ssoConfig';
|
||||||
import { registriesRouter } from './routes/registries';
|
import { registriesRouter } from './routes/registries';
|
||||||
@@ -134,6 +134,7 @@ app.use('/api/auto-update', autoUpdateRouter);
|
|||||||
app.use('/api/auto-heal', autoHealRouter);
|
app.use('/api/auto-heal', autoHealRouter);
|
||||||
app.use('/api/notifications', notificationsRouter);
|
app.use('/api/notifications', notificationsRouter);
|
||||||
app.use('/api/notification-routes', notificationRoutesRouter);
|
app.use('/api/notification-routes', notificationRoutesRouter);
|
||||||
|
app.use('/api/notification-suppression-rules', notificationSuppressionRouter);
|
||||||
app.use('/api/system', consoleRouter);
|
app.use('/api/system', consoleRouter);
|
||||||
app.use('/api/sso/config', ssoConfigRouter);
|
app.use('/api/sso/config', ssoConfigRouter);
|
||||||
app.use('/api/registries', registriesRouter);
|
app.use('/api/registries', registriesRouter);
|
||||||
|
|||||||
@@ -1,22 +1,29 @@
|
|||||||
import { Router, type Request, type Response } from 'express';
|
import { Router, type Request, type Response } from 'express';
|
||||||
import { DatabaseService } from '../services/DatabaseService';
|
import { DatabaseService, type NotificationSuppressionAppliesTo, type NotificationSuppressionRule } from '../services/DatabaseService';
|
||||||
import { NotificationService, ALL_NOTIFICATION_CATEGORIES } from '../services/NotificationService';
|
import { NotificationService, ALL_NOTIFICATION_CATEGORIES, ALL_SUPPRESSIBLE_CATEGORIES } from '../services/NotificationService';
|
||||||
import type { NotificationCategory } from '../services/NotificationService';
|
import type { NotificationCategory } from '../services/NotificationService';
|
||||||
import { NodeRegistry } from '../services/NodeRegistry';
|
import { NodeRegistry } from '../services/NodeRegistry';
|
||||||
import { authMiddleware } from '../middleware/auth';
|
import { authMiddleware } from '../middleware/auth';
|
||||||
import { requireAdmin } from '../middleware/tierGates';
|
import { requireAdmin, requireNodeProxy } from '../middleware/tierGates';
|
||||||
import {
|
import {
|
||||||
NOTIFICATION_CHANNEL_TYPES,
|
NOTIFICATION_CHANNEL_TYPES,
|
||||||
validateHttpsUrl,
|
validateHttpsUrl,
|
||||||
cleanStackPatterns,
|
cleanStackPatterns,
|
||||||
maskWebhookUrl,
|
maskWebhookUrl,
|
||||||
} from '../helpers/notificationChannels';
|
} from '../helpers/notificationChannels';
|
||||||
|
import {
|
||||||
|
deleteSuppressionRuleFromFleet,
|
||||||
|
syncSuppressionRuleToFleet,
|
||||||
|
} from '../helpers/notificationSuppressionSync';
|
||||||
import { isDebugEnabled } from '../utils/debug';
|
import { isDebugEnabled } from '../utils/debug';
|
||||||
import { getErrorMessage } from '../utils/errors';
|
import { getErrorMessage } from '../utils/errors';
|
||||||
import { sanitizeForLog } from '../utils/safeLog';
|
import { sanitizeForLog } from '../utils/safeLog';
|
||||||
import { parseIntParam } from '../utils/parseIntParam';
|
import { parseIntParam } from '../utils/parseIntParam';
|
||||||
|
|
||||||
const VALID_CATEGORIES: ReadonlySet<NotificationCategory> = new Set(ALL_NOTIFICATION_CATEGORIES);
|
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 {
|
function validateNodeId(nodeId: unknown, res: Response): number | null | false {
|
||||||
if (nodeId === undefined || nodeId === null) return null;
|
if (nodeId === undefined || nodeId === null) return null;
|
||||||
@@ -41,15 +48,138 @@ function validateLabelIds(label_ids: unknown, res: Response): boolean {
|
|||||||
return true;
|
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 (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' });
|
res.status(400).json({ error: 'categories must be an array of valid category names' });
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
return true;
|
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();
|
export const notificationsRouter = Router();
|
||||||
|
|
||||||
notificationsRouter.get('/', authMiddleware, async (req: Request, res: Response): Promise<void> => {
|
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',
|
'network-topology',
|
||||||
'notifications',
|
'notifications',
|
||||||
'notification-routing',
|
'notification-routing',
|
||||||
|
'notification-suppression',
|
||||||
'host-console',
|
'host-console',
|
||||||
'container-exec',
|
'container-exec',
|
||||||
'audit-log',
|
'audit-log',
|
||||||
|
|||||||
@@ -377,6 +377,7 @@ export interface NotificationHistory {
|
|||||||
stack_name?: string;
|
stack_name?: string;
|
||||||
container_name?: string;
|
container_name?: string;
|
||||||
actor_username?: string | null;
|
actor_username?: string | null;
|
||||||
|
suppression_match?: string | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface FleetSnapshot {
|
export interface FleetSnapshot {
|
||||||
@@ -600,6 +601,23 @@ export interface NotificationRoute {
|
|||||||
updated_at: number;
|
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 VulnSeverity = 'CRITICAL' | 'HIGH' | 'MEDIUM' | 'LOW' | 'UNKNOWN';
|
||||||
export type VulnScanStatus = 'in_progress' | 'completed' | 'failed';
|
export type VulnScanStatus = 'in_progress' | 'completed' | 'failed';
|
||||||
export type VulnScanTrigger = 'manual' | 'scheduled' | 'deploy' | 'deploy-preflight';
|
export type VulnScanTrigger = 'manual' | 'scheduled' | 'deploy' | 'deploy-preflight';
|
||||||
@@ -871,6 +889,7 @@ export class DatabaseService {
|
|||||||
this.migrateNotificationRoutes();
|
this.migrateNotificationRoutes();
|
||||||
this.migrateNotificationRoutesNodeId();
|
this.migrateNotificationRoutesNodeId();
|
||||||
this.migrateNotificationRoutesMatchers();
|
this.migrateNotificationRoutesMatchers();
|
||||||
|
this.migrateNotificationSuppressionRules();
|
||||||
this.migrateNotificationHistoryContext();
|
this.migrateNotificationHistoryContext();
|
||||||
this.migrateScanPolicyFleetColumns();
|
this.migrateScanPolicyFleetColumns();
|
||||||
this.migrateScanPolicyRiskColumns();
|
this.migrateScanPolicyRiskColumns();
|
||||||
@@ -1814,9 +1833,31 @@ export class DatabaseService {
|
|||||||
this.tryAddColumn('notification_routes', 'categories', 'TEXT NULL');
|
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 {
|
private migrateNotificationHistoryContext(): void {
|
||||||
this.tryAddColumn('notification_history', 'stack_name', 'TEXT');
|
this.tryAddColumn('notification_history', 'stack_name', 'TEXT');
|
||||||
this.tryAddColumn('notification_history', 'container_name', 'TEXT');
|
this.tryAddColumn('notification_history', 'container_name', 'TEXT');
|
||||||
|
this.tryAddColumn('notification_history', 'suppression_match', 'TEXT');
|
||||||
}
|
}
|
||||||
|
|
||||||
private migrateStackDossierHashes(): void {
|
private migrateStackDossierHashes(): void {
|
||||||
@@ -2293,6 +2334,135 @@ export class DatabaseService {
|
|||||||
return this.db.prepare('DELETE FROM notification_routes WHERE id = ?').run(id).changes;
|
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 ---
|
// --- Global Settings ---
|
||||||
|
|
||||||
public getGlobalSettings(): Readonly<Record<string, string>> {
|
public getGlobalSettings(): Readonly<Record<string, string>> {
|
||||||
@@ -2806,6 +2976,7 @@ export class DatabaseService {
|
|||||||
container_name: row.container_name ?? undefined,
|
container_name: row.container_name ?? undefined,
|
||||||
category: row.category ?? undefined,
|
category: row.category ?? undefined,
|
||||||
actor_username: row.actor_username ?? null,
|
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);
|
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[] {
|
public getStackRestartSummary(nodeId: number, days: number): StackRestartSummary[] {
|
||||||
const since = Date.now() - days * 86400 * 1000;
|
const since = Date.now() - days * 86400 * 1000;
|
||||||
return this.db.prepare(`
|
return this.db.prepare(`
|
||||||
|
|||||||
@@ -6,6 +6,12 @@ import { getErrorMessage } from '../utils/errors';
|
|||||||
import { sanitizeForLog } from '../utils/safeLog';
|
import { sanitizeForLog } from '../utils/safeLog';
|
||||||
import { sanitizeNotificationMessage } from '../utils/notificationMessage';
|
import { sanitizeNotificationMessage } from '../utils/notificationMessage';
|
||||||
import { StackActivityMetricsService } from './StackActivityMetricsService';
|
import { StackActivityMetricsService } from './StackActivityMetricsService';
|
||||||
|
import {
|
||||||
|
appliesToBell,
|
||||||
|
appliesToExternal,
|
||||||
|
matchesNotificationFilters,
|
||||||
|
ruleNeedsStackLabels,
|
||||||
|
} from '../helpers/notificationMatchers';
|
||||||
|
|
||||||
export type NotificationCategory =
|
export type NotificationCategory =
|
||||||
| 'deploy_success'
|
| 'deploy_success'
|
||||||
@@ -44,6 +50,13 @@ export const ALL_NOTIFICATION_CATEGORIES: readonly NotificationCategory[] = [
|
|||||||
'node_update_available', 'system',
|
'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. */
|
/** Webhook timeout: 10 seconds per external dispatch call. */
|
||||||
const WEBHOOK_TIMEOUT_MS = 10_000;
|
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
|
// 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
|
// 3. Check notification routing rules — always evaluated, matchers compose AND
|
||||||
const errors: string[] = [];
|
const errors: string[] = [];
|
||||||
|
|
||||||
const routes = this.dbService.getEnabledNotificationRoutes();
|
const matched = routes.filter(r => matchesNotificationFilters(matchCtx, r));
|
||||||
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;
|
|
||||||
});
|
|
||||||
if (matched.length > 0) {
|
if (matched.length > 0) {
|
||||||
if (isDebugEnabled()) console.log(`[Notify:diag] Matched ${matched.length} route(s) for stack "${sanitizeForLog(stackName ?? '(none)')}", category="${sanitizeForLog(category)}"`);
|
if (isDebugEnabled()) console.log(`[Notify:diag] Matched ${matched.length} route(s) for stack "${sanitizeForLog(stackName ?? '(none)')}", category="${sanitizeForLog(category)}"`);
|
||||||
await Promise.allSettled(
|
await Promise.allSettled(
|
||||||
|
|||||||
@@ -72,6 +72,9 @@ export const AUDIT_ROUTE_SUMMARIES: Record<string, string> = {
|
|||||||
'PUT /notification-routes': 'Updated notification route',
|
'PUT /notification-routes': 'Updated notification route',
|
||||||
'DELETE /notification-routes': 'Deleted notification route',
|
'DELETE /notification-routes': 'Deleted notification route',
|
||||||
'POST /notification-routes/*/test': 'Tested 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
|
// Webhooks
|
||||||
'POST /webhooks': 'Created webhook',
|
'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).
|
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
|
## 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.
|
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
|
## Image update checks
|
||||||
|
|
||||||
**Scope:** Per-node (applies to the currently selected node)
|
**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 { useComposeDiffPreviewEnabled } from '@/hooks/use-compose-diff-preview-enabled';
|
||||||
import { useTopNavLabels } from '@/hooks/use-top-nav-labels';
|
import { useTopNavLabels } from '@/hooks/use-top-nav-labels';
|
||||||
import { useTopNavAlign } from '@/hooks/use-top-nav-align';
|
import { useTopNavAlign } from '@/hooks/use-top-nav-align';
|
||||||
|
import { useStackMuteActions } from '@/hooks/useMuteRuleActions';
|
||||||
import { toast } from '@/components/ui/toast-store';
|
import { toast } from '@/components/ui/toast-store';
|
||||||
import { useIsMobile } from '@/hooks/use-is-mobile';
|
import { useIsMobile } from '@/hooks/use-is-mobile';
|
||||||
import { MobileTabBar } from './MobileTabBar';
|
import { MobileTabBar } from './MobileTabBar';
|
||||||
@@ -179,11 +180,14 @@ export default function EditorLayout() {
|
|||||||
fleetTab, setFleetTab,
|
fleetTab, setFleetTab,
|
||||||
filterNodeId, setFilterNodeId,
|
filterNodeId, setFilterNodeId,
|
||||||
schedulePrefill,
|
schedulePrefill,
|
||||||
|
muteRulePrefill,
|
||||||
mobileNavOpen, setMobileNavOpen,
|
mobileNavOpen, setMobileNavOpen,
|
||||||
handleOpenSettings,
|
handleOpenSettings,
|
||||||
handlePrefillConsumed,
|
handlePrefillConsumed,
|
||||||
|
handleMutePrefillConsumed,
|
||||||
handleNavigate,
|
handleNavigate,
|
||||||
navItems,
|
navItems,
|
||||||
|
openMuteRulesWithPrefill,
|
||||||
} = navState;
|
} = navState;
|
||||||
|
|
||||||
const {
|
const {
|
||||||
@@ -284,6 +288,8 @@ export default function EditorLayout() {
|
|||||||
|
|
||||||
const loadingAction = selectedFile ? (stackActionMap[selectedFile] ?? null) : null;
|
const loadingAction = selectedFile ? (stackActionMap[selectedFile] ?? null) : null;
|
||||||
const stackName = selectedFile || '';
|
const stackName = selectedFile || '';
|
||||||
|
const stackDisplayName = selectedFile ? selectedFile.replace(/\.(yml|yaml)$/, '') : '';
|
||||||
|
const stackMuteActions = useStackMuteActions(stackDisplayName, openMuteRulesWithPrefill);
|
||||||
|
|
||||||
const { isDarkMode } = useTheme();
|
const { isDarkMode } = useTheme();
|
||||||
|
|
||||||
@@ -500,6 +506,7 @@ export default function EditorLayout() {
|
|||||||
onMobileBack={goToMobileList}
|
onMobileBack={goToMobileList}
|
||||||
onCloseEditor={() => stackActions.attemptLeaveEditor(() => setEditingCompose(false))}
|
onCloseEditor={() => stackActions.attemptLeaveEditor(() => setEditingCompose(false))}
|
||||||
hasUnsavedChanges={stackActions.hasUnsavedChanges}
|
hasUnsavedChanges={stackActions.hasUnsavedChanges}
|
||||||
|
stackMuteActions={selectedFile ? stackMuteActions : undefined}
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
|
|
||||||
@@ -680,6 +687,7 @@ export default function EditorLayout() {
|
|||||||
},
|
},
|
||||||
filterChip,
|
filterChip,
|
||||||
onOpenCreate: can('stack:create') ? openCreateDialog : undefined,
|
onOpenCreate: can('stack:create') ? openCreateDialog : undefined,
|
||||||
|
openMuteRulesWithPrefill,
|
||||||
}}
|
}}
|
||||||
activitySummary={activitySummary}
|
activitySummary={activitySummary}
|
||||||
onActivityAction={handleActivityAction}
|
onActivityAction={handleActivityAction}
|
||||||
@@ -755,9 +763,12 @@ export default function EditorLayout() {
|
|||||||
onClearScheduledOpsFilter={() => setFilterNodeId(null)}
|
onClearScheduledOpsFilter={() => setFilterNodeId(null)}
|
||||||
schedulePrefill={schedulePrefill}
|
schedulePrefill={schedulePrefill}
|
||||||
onPrefillConsumed={handlePrefillConsumed}
|
onPrefillConsumed={handlePrefillConsumed}
|
||||||
|
muteRulePrefill={muteRulePrefill}
|
||||||
|
onMutePrefillConsumed={handleMutePrefillConsumed}
|
||||||
notifications={notifications}
|
notifications={notifications}
|
||||||
onNavigateToStack={(stackFile) => { void stackActions.loadFile(stackFile); }}
|
onNavigateToStack={(stackFile) => { void stackActions.loadFile(stackFile); }}
|
||||||
onOpenSettingsSection={(section) => openSettings(section)}
|
onOpenSettingsSection={(section) => openSettings(section)}
|
||||||
|
onOpenMuteRulesWithPrefill={openMuteRulesWithPrefill}
|
||||||
onClearNotifications={clearAllNotifications}
|
onClearNotifications={clearAllNotifications}
|
||||||
fleetUpdatesIntent={fleetUpdatesIntent}
|
fleetUpdatesIntent={fleetUpdatesIntent}
|
||||||
onFleetUpdatesIntentConsumed={handleFleetUpdatesIntentConsumed}
|
onFleetUpdatesIntentConsumed={handleFleetUpdatesIntentConsumed}
|
||||||
|
|||||||
@@ -46,6 +46,7 @@ import { retryHandlerFor } from './recovery-retry';
|
|||||||
import type { NotificationItem } from '../dashboard/types';
|
import type { NotificationItem } from '../dashboard/types';
|
||||||
import type { Node } from '@/context/NodeContext';
|
import type { Node } from '@/context/NodeContext';
|
||||||
import type { useAuth } from '@/context/AuthContext';
|
import type { useAuth } from '@/context/AuthContext';
|
||||||
|
import type { useStackMuteActions } from '@/hooks/useMuteRuleActions';
|
||||||
|
|
||||||
export interface ContainerInfo {
|
export interface ContainerInfo {
|
||||||
Id: string;
|
Id: string;
|
||||||
@@ -214,6 +215,8 @@ export interface EditorViewProps {
|
|||||||
// Mobile-only: notifications + more-menu cluster for the detail header right
|
// Mobile-only: notifications + more-menu cluster for the detail header right
|
||||||
// slot (the global TopBar is dropped on the full-screen detail surface).
|
// slot (the global TopBar is dropped on the full-screen detail surface).
|
||||||
headerActions?: React.ReactNode;
|
headerActions?: React.ReactNode;
|
||||||
|
|
||||||
|
stackMuteActions?: ReturnType<typeof useStackMuteActions>;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function EditorView(props: EditorViewProps) {
|
export function EditorView(props: EditorViewProps) {
|
||||||
@@ -270,6 +273,7 @@ export function EditorView(props: EditorViewProps) {
|
|||||||
onRefreshState,
|
onRefreshState,
|
||||||
onDismissRecovery,
|
onDismissRecovery,
|
||||||
panelStartedAt,
|
panelStartedAt,
|
||||||
|
stackMuteActions,
|
||||||
} = props;
|
} = props;
|
||||||
const monacoEditorRef = useRef<import('monaco-editor').editor.IStandaloneCodeEditor | null>(null);
|
const monacoEditorRef = useRef<import('monaco-editor').editor.IStandaloneCodeEditor | null>(null);
|
||||||
|
|
||||||
@@ -376,6 +380,7 @@ export function EditorView(props: EditorViewProps) {
|
|||||||
rollbackStack={rollbackStack}
|
rollbackStack={rollbackStack}
|
||||||
scanStackConfig={scanStackConfig}
|
scanStackConfig={scanStackConfig}
|
||||||
requestDeleteStack={requestDeleteStack}
|
requestDeleteStack={requestDeleteStack}
|
||||||
|
stackMuteActions={stackMuteActions}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
{recoveryResult && loadingAction == null && (
|
{recoveryResult && loadingAction == null && (
|
||||||
@@ -616,6 +621,7 @@ export function EditorView(props: EditorViewProps) {
|
|||||||
applying={loadingAction === 'update'}
|
applying={loadingAction === 'update'}
|
||||||
canEdit={can('stack:edit', 'stack', stackName)}
|
canEdit={can('stack:edit', 'stack', stackName)}
|
||||||
notifications={notifications}
|
notifications={notifications}
|
||||||
|
stackMuteActions={stackMuteActions}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -78,6 +78,7 @@ export function MobileStackDetail(props: EditorViewProps) {
|
|||||||
onRefreshState,
|
onRefreshState,
|
||||||
onDismissRecovery,
|
onDismissRecovery,
|
||||||
panelStartedAt,
|
panelStartedAt,
|
||||||
|
stackMuteActions,
|
||||||
} = props;
|
} = props;
|
||||||
|
|
||||||
const [segment, setSegment] = useState<Segment>('logs');
|
const [segment, setSegment] = useState<Segment>('logs');
|
||||||
@@ -152,6 +153,7 @@ export function MobileStackDetail(props: EditorViewProps) {
|
|||||||
rollbackStack={rollbackStack}
|
rollbackStack={rollbackStack}
|
||||||
scanStackConfig={scanStackConfig}
|
scanStackConfig={scanStackConfig}
|
||||||
requestDeleteStack={requestDeleteStack}
|
requestDeleteStack={requestDeleteStack}
|
||||||
|
stackMuteActions={stackMuteActions}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -243,6 +245,7 @@ export function MobileStackDetail(props: EditorViewProps) {
|
|||||||
applying={loadingAction === 'update'}
|
applying={loadingAction === 'update'}
|
||||||
canEdit={canEditStack}
|
canEdit={canEditStack}
|
||||||
notifications={notifications}
|
notifications={notifications}
|
||||||
|
stackMuteActions={stackMuteActions}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -12,6 +12,7 @@ import ResourcesView from '../ResourcesView';
|
|||||||
import HomeDashboard from '../HomeDashboard';
|
import HomeDashboard from '../HomeDashboard';
|
||||||
import type { NotificationItem } from '../dashboard/types';
|
import type { NotificationItem } from '../dashboard/types';
|
||||||
import type { ScheduleTaskPrefill } from '../ScheduledOperationsView';
|
import type { ScheduleTaskPrefill } from '../ScheduledOperationsView';
|
||||||
|
import type { MuteRuleDraft } from '@/lib/muteRules';
|
||||||
import type { ActiveView } from './hooks/useViewNavigationState';
|
import type { ActiveView } from './hooks/useViewNavigationState';
|
||||||
import type { SecurityTab, FleetTab } from '@/lib/events';
|
import type { SecurityTab, FleetTab } from '@/lib/events';
|
||||||
|
|
||||||
@@ -81,9 +82,12 @@ export interface ViewRouterProps {
|
|||||||
onClearScheduledOpsFilter: () => void;
|
onClearScheduledOpsFilter: () => void;
|
||||||
schedulePrefill: ScheduleTaskPrefill | null;
|
schedulePrefill: ScheduleTaskPrefill | null;
|
||||||
onPrefillConsumed: () => void;
|
onPrefillConsumed: () => void;
|
||||||
|
muteRulePrefill: MuteRuleDraft | null;
|
||||||
|
onMutePrefillConsumed: () => void;
|
||||||
notifications: NotificationItem[];
|
notifications: NotificationItem[];
|
||||||
onNavigateToStack: (stackFile: string) => void;
|
onNavigateToStack: (stackFile: string) => void;
|
||||||
onOpenSettingsSection: (section: SectionId) => void;
|
onOpenSettingsSection: (section: SectionId) => void;
|
||||||
|
onOpenMuteRulesWithPrefill?: (draft: MuteRuleDraft) => void;
|
||||||
onClearNotifications: () => void;
|
onClearNotifications: () => void;
|
||||||
securityTab: SecurityTab;
|
securityTab: SecurityTab;
|
||||||
onSecurityTabChange: (tab: SecurityTab) => void;
|
onSecurityTabChange: (tab: SecurityTab) => void;
|
||||||
@@ -110,9 +114,12 @@ export function ViewRouter({
|
|||||||
onClearScheduledOpsFilter,
|
onClearScheduledOpsFilter,
|
||||||
schedulePrefill,
|
schedulePrefill,
|
||||||
onPrefillConsumed,
|
onPrefillConsumed,
|
||||||
|
muteRulePrefill,
|
||||||
|
onMutePrefillConsumed,
|
||||||
notifications,
|
notifications,
|
||||||
onNavigateToStack,
|
onNavigateToStack,
|
||||||
onOpenSettingsSection,
|
onOpenSettingsSection,
|
||||||
|
onOpenMuteRulesWithPrefill,
|
||||||
onClearNotifications,
|
onClearNotifications,
|
||||||
securityTab,
|
securityTab,
|
||||||
onSecurityTabChange,
|
onSecurityTabChange,
|
||||||
@@ -128,6 +135,9 @@ export function ViewRouter({
|
|||||||
<SettingsPage
|
<SettingsPage
|
||||||
currentSection={settingsSection}
|
currentSection={settingsSection}
|
||||||
onSectionChange={onSettingsSectionChange}
|
onSectionChange={onSettingsSectionChange}
|
||||||
|
muteRulePrefill={muteRulePrefill}
|
||||||
|
onMutePrefillConsumed={onMutePrefillConsumed}
|
||||||
|
onOpenMuteRulesWithPrefill={onOpenMuteRulesWithPrefill}
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -186,6 +196,7 @@ export function ViewRouter({
|
|||||||
<FleetView
|
<FleetView
|
||||||
onNavigateToNode={onFleetNavigateToNode}
|
onNavigateToNode={onFleetNavigateToNode}
|
||||||
onOpenSettingsSection={onOpenSettingsSection}
|
onOpenSettingsSection={onOpenSettingsSection}
|
||||||
|
onOpenMuteRulesWithPrefill={onOpenMuteRulesWithPrefill}
|
||||||
fleetUpdatesIntent={fleetUpdatesIntent}
|
fleetUpdatesIntent={fleetUpdatesIntent}
|
||||||
onFleetUpdatesIntentConsumed={onFleetUpdatesIntentConsumed}
|
onFleetUpdatesIntentConsumed={onFleetUpdatesIntentConsumed}
|
||||||
fleetTab={fleetTab}
|
fleetTab={fleetTab}
|
||||||
|
|||||||
@@ -27,6 +27,8 @@ import {
|
|||||||
DropdownMenuSeparator,
|
DropdownMenuSeparator,
|
||||||
DropdownMenuTrigger,
|
DropdownMenuTrigger,
|
||||||
} from '../ui/dropdown-menu';
|
} from '../ui/dropdown-menu';
|
||||||
|
import { StackMuteSubmenu } from '@/components/mute/MuteMenuItems';
|
||||||
|
import type { useStackMuteActions } from '@/hooks/useMuteRuleActions';
|
||||||
import { Sparkline } from '../ui/sparkline';
|
import { Sparkline } from '../ui/sparkline';
|
||||||
import { ImageSourceMenu } from '../ImageSourceMenu';
|
import { ImageSourceMenu } from '../ImageSourceMenu';
|
||||||
import { cn } from '@/lib/utils';
|
import { cn } from '@/lib/utils';
|
||||||
@@ -129,6 +131,7 @@ export interface StackIdentityHeaderProps {
|
|||||||
rollbackStack: () => Promise<void>;
|
rollbackStack: () => Promise<void>;
|
||||||
scanStackConfig: () => Promise<void>;
|
scanStackConfig: () => Promise<void>;
|
||||||
requestDeleteStack: () => void;
|
requestDeleteStack: () => void;
|
||||||
|
stackMuteActions?: ReturnType<typeof useStackMuteActions>;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Breadcrumb + serif title + state pill + image ref + action bar. The action
|
// Breadcrumb + serif title + state pill + image ref + action bar. The action
|
||||||
@@ -154,6 +157,7 @@ export function StackIdentityHeader({
|
|||||||
rollbackStack,
|
rollbackStack,
|
||||||
scanStackConfig,
|
scanStackConfig,
|
||||||
requestDeleteStack,
|
requestDeleteStack,
|
||||||
|
stackMuteActions,
|
||||||
}: StackIdentityHeaderProps) {
|
}: StackIdentityHeaderProps) {
|
||||||
return (
|
return (
|
||||||
<div className="flex flex-col gap-3">
|
<div className="flex flex-col gap-3">
|
||||||
@@ -228,8 +232,9 @@ export function StackIdentityHeader({
|
|||||||
const canDelete = can('stack:delete', 'stack', stackName);
|
const canDelete = can('stack:delete', 'stack', stackName);
|
||||||
const canRollback = canDeploy && backupInfo.exists;
|
const canRollback = canDeploy && backupInfo.exists;
|
||||||
const canScan = trivy.available && isAdmin;
|
const canScan = trivy.available && isAdmin;
|
||||||
|
const canMute = stackMuteActions?.canMute ?? false;
|
||||||
const hasOverflowExtras = canRollback || canScan;
|
const hasOverflowExtras = canRollback || canScan;
|
||||||
const hasOverflow = hasOverflowExtras || canDelete;
|
const hasOverflow = hasOverflowExtras || canDelete || canMute;
|
||||||
if (!canDeploy && !hasOverflow) return null;
|
if (!canDeploy && !hasOverflow) return null;
|
||||||
return (
|
return (
|
||||||
<div className="flex items-center gap-2 flex-wrap">
|
<div className="flex items-center gap-2 flex-wrap">
|
||||||
@@ -287,7 +292,8 @@ export function StackIdentityHeader({
|
|||||||
{stackMisconfigScanning ? 'Scanning...' : 'Scan config'}
|
{stackMisconfigScanning ? 'Scanning...' : 'Scan config'}
|
||||||
</DropdownMenuItem>
|
</DropdownMenuItem>
|
||||||
)}
|
)}
|
||||||
{hasOverflowExtras && canDelete && <DropdownMenuSeparator />}
|
{stackMuteActions && <StackMuteSubmenu actions={stackMuteActions} />}
|
||||||
|
{(canRollback || canScan || stackMuteActions?.canMute) && canDelete && <DropdownMenuSeparator />}
|
||||||
{canDelete && (
|
{canDelete && (
|
||||||
<DropdownMenuItem
|
<DropdownMenuItem
|
||||||
className="text-destructive focus:text-destructive focus:bg-destructive/10"
|
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 { useSidebarContextMenu } from './useSidebarContextMenu';
|
||||||
import type { Node } from '@/context/NodeContext';
|
import type { Node } from '@/context/NodeContext';
|
||||||
|
|
||||||
|
vi.mock('@/context/NodeContext', () => ({
|
||||||
|
useNodes: () => ({ hasCapability: () => false }),
|
||||||
|
}));
|
||||||
|
|
||||||
// buildMenuCtx derives canOpenApp from the active node plus the stack's
|
// buildMenuCtx derives canOpenApp from the active node plus the stack's
|
||||||
// published port; only the fields it reads need to be real, the handler
|
// published port; only the fields it reads need to be real, the handler
|
||||||
// closures are never invoked here.
|
// closures are never invoked here.
|
||||||
|
|||||||
@@ -2,6 +2,15 @@ import { useCallback } from 'react';
|
|||||||
import { apiFetch } from '@/lib/api';
|
import { apiFetch } from '@/lib/api';
|
||||||
import { toast } from '@/components/ui/toast-store';
|
import { toast } from '@/components/ui/toast-store';
|
||||||
import { buildServiceUrl } from '@/lib/serviceUrl';
|
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 { StackMenuCtx } from '@/components/sidebar/sidebar-types';
|
||||||
import type { Label as StackLabel, LabelColor } from '../../label-types';
|
import type { Label as StackLabel, LabelColor } from '../../label-types';
|
||||||
import type { OverlayState } from './useOverlayState';
|
import type { OverlayState } from './useOverlayState';
|
||||||
@@ -9,6 +18,7 @@ import type { StackActionsHook } from './useStackActions';
|
|||||||
import type { useStackListState } from './useStackListState';
|
import type { useStackListState } from './useStackListState';
|
||||||
import type { useViewNavigationState } from './useViewNavigationState';
|
import type { useViewNavigationState } from './useViewNavigationState';
|
||||||
import type { Node } from '@/context/NodeContext';
|
import type { Node } from '@/context/NodeContext';
|
||||||
|
import { useNodes } from '@/context/NodeContext';
|
||||||
import type { PermissionAction } from '@/context/AuthContext';
|
import type { PermissionAction } from '@/context/AuthContext';
|
||||||
|
|
||||||
type StackListState = ReturnType<typeof useStackListState>;
|
type StackListState = ReturnType<typeof useStackListState>;
|
||||||
@@ -33,6 +43,7 @@ export function useSidebarContextMenu({
|
|||||||
isAdmin,
|
isAdmin,
|
||||||
can,
|
can,
|
||||||
}: UseSidebarContextMenuOptions) {
|
}: UseSidebarContextMenuOptions) {
|
||||||
|
const { hasCapability } = useNodes();
|
||||||
const buildMenuCtx = useCallback((file: string): StackMenuCtx => {
|
const buildMenuCtx = useCallback((file: string): StackMenuCtx => {
|
||||||
const sName = file.replace(/\.(yml|yaml)$/, '');
|
const sName = file.replace(/\.(yml|yaml)$/, '');
|
||||||
const mainPort = stackListState.stackPorts[file];
|
const mainPort = stackListState.stackPorts[file];
|
||||||
@@ -40,6 +51,8 @@ export function useSidebarContextMenu({
|
|||||||
// lifecycle affordances; the menu's status union stays three-state.
|
// lifecycle affordances; the menu's status union stays three-state.
|
||||||
const rawStatus = stackListState.stackStatuses[file] ?? 'unknown';
|
const rawStatus = stackListState.stackStatuses[file] ?? 'unknown';
|
||||||
const stackStatus = rawStatus === 'partial' ? 'running' : rawStatus;
|
const stackStatus = rawStatus === 'partial' ? 'running' : rawStatus;
|
||||||
|
const nodeId = activeNode?.id ?? null;
|
||||||
|
const canMuteNotifications = isAdmin && hasCapability('notification-suppression');
|
||||||
return {
|
return {
|
||||||
stackStatus,
|
stackStatus,
|
||||||
// Only offer "Open App" when a browser-reachable URL can actually be built
|
// 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.setSchedulePrefill({ stackName: sName, nodeId: activeNode?.id ?? null });
|
||||||
navState.setActiveView('scheduled-ops');
|
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
|
// Handlers from useStackActions, useOverlayState, useViewNavigationState are
|
||||||
// useCallback-stabilized at their owner hooks, so listing the menu surface
|
// useCallback-stabilized at their owner hooks, so listing the menu surface
|
||||||
@@ -134,7 +172,8 @@ export function useSidebarContextMenu({
|
|||||||
}, [
|
}, [
|
||||||
stackListState.stackStatuses, stackListState.stackPorts, isAdmin,
|
stackListState.stackStatuses, stackListState.stackPorts, isAdmin,
|
||||||
stackListState.isPinned, stackListState.labels, stackListState.stackLabelMap,
|
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;
|
return buildMenuCtx;
|
||||||
|
|||||||
@@ -12,6 +12,7 @@ import type { SenchoNavigateDetail } from '@/components/NodeManager';
|
|||||||
import type { SecurityTab, FleetTab } from '@/lib/events';
|
import type { SecurityTab, FleetTab } from '@/lib/events';
|
||||||
import type { SectionId } from '@/components/settings/types';
|
import type { SectionId } from '@/components/settings/types';
|
||||||
import type { ScheduleTaskPrefill } from '@/components/ScheduledOperationsView';
|
import type { ScheduleTaskPrefill } from '@/components/ScheduledOperationsView';
|
||||||
|
import type { MuteRuleDraft } from '@/lib/muteRules';
|
||||||
|
|
||||||
export type ActiveView =
|
export type ActiveView =
|
||||||
| 'dashboard'
|
| 'dashboard'
|
||||||
@@ -64,6 +65,7 @@ export function useViewNavigationState(options?: UseViewNavigationStateOptions)
|
|||||||
const [fleetTab, setFleetTab] = useState<FleetTab | null>(null);
|
const [fleetTab, setFleetTab] = useState<FleetTab | null>(null);
|
||||||
const [filterNodeId, setFilterNodeId] = useState<number | null>(null);
|
const [filterNodeId, setFilterNodeId] = useState<number | null>(null);
|
||||||
const [schedulePrefill, setSchedulePrefill] = useState<ScheduleTaskPrefill | null>(null);
|
const [schedulePrefill, setSchedulePrefill] = useState<ScheduleTaskPrefill | null>(null);
|
||||||
|
const [muteRulePrefill, setMuteRulePrefill] = useState<MuteRuleDraft | null>(null);
|
||||||
const [mobileNavOpen, setMobileNavOpen] = useState(false);
|
const [mobileNavOpen, setMobileNavOpen] = useState(false);
|
||||||
|
|
||||||
const handleOpenSettings = useCallback((section?: SectionId) => {
|
const handleOpenSettings = useCallback((section?: SectionId) => {
|
||||||
@@ -73,6 +75,14 @@ export function useViewNavigationState(options?: UseViewNavigationStateOptions)
|
|||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
const handlePrefillConsumed = useCallback(() => setSchedulePrefill(null), []);
|
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) => {
|
const handleNavigate = useCallback((value: string) => {
|
||||||
if (value === activeView) return;
|
if (value === activeView) return;
|
||||||
@@ -166,9 +176,12 @@ export function useViewNavigationState(options?: UseViewNavigationStateOptions)
|
|||||||
fleetTab, setFleetTab,
|
fleetTab, setFleetTab,
|
||||||
filterNodeId, setFilterNodeId,
|
filterNodeId, setFilterNodeId,
|
||||||
schedulePrefill, setSchedulePrefill,
|
schedulePrefill, setSchedulePrefill,
|
||||||
|
muteRulePrefill, setMuteRulePrefill,
|
||||||
mobileNavOpen, setMobileNavOpen,
|
mobileNavOpen, setMobileNavOpen,
|
||||||
handleOpenSettings,
|
handleOpenSettings,
|
||||||
handlePrefillConsumed,
|
handlePrefillConsumed,
|
||||||
|
handleMutePrefillConsumed,
|
||||||
|
openMuteRulesWithPrefill,
|
||||||
handleNavigate,
|
handleNavigate,
|
||||||
navItems,
|
navItems,
|
||||||
} as const;
|
} as const;
|
||||||
|
|||||||
@@ -32,11 +32,13 @@ import { DependencyMapTab } from './fleet/DependencyMapTab';
|
|||||||
import { useNodeActions } from './nodes/useNodeActions';
|
import { useNodeActions } from './nodes/useNodeActions';
|
||||||
import type { FleetTab } from '@/lib/events';
|
import type { FleetTab } from '@/lib/events';
|
||||||
import type { SectionId } from '@/components/settings/types';
|
import type { SectionId } from '@/components/settings/types';
|
||||||
|
import type { MuteRuleDraft } from '@/lib/muteRules';
|
||||||
|
|
||||||
interface FleetViewProps {
|
interface FleetViewProps {
|
||||||
onNavigateToNode: (nodeId: number, stackName: string) => void;
|
onNavigateToNode: (nodeId: number, stackName: string) => void;
|
||||||
/** Opens a Settings section (used to send "Add node" to Settings > Nodes). */
|
/** Opens a Settings section (used to send "Add node" to Settings > Nodes). */
|
||||||
onOpenSettingsSection?: (section: SectionId) => void;
|
onOpenSettingsSection?: (section: SectionId) => void;
|
||||||
|
onOpenMuteRulesWithPrefill?: (draft: MuteRuleDraft) => void;
|
||||||
fleetUpdatesIntent?: { tab: 'nodes' | 'changelog' } | null;
|
fleetUpdatesIntent?: { tab: 'nodes' | 'changelog' } | null;
|
||||||
onFleetUpdatesIntentConsumed?: () => void;
|
onFleetUpdatesIntentConsumed?: () => void;
|
||||||
/** Deep-link target tab (e.g. 'snapshots' from the stack storage warning). */
|
/** Deep-link target tab (e.g. 'snapshots' from the stack storage warning). */
|
||||||
@@ -44,7 +46,7 @@ interface FleetViewProps {
|
|||||||
onFleetTabConsumed?: () => void;
|
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 { isPaid } = useLicense();
|
||||||
const { isAdmin } = useAuth();
|
const { isAdmin } = useAuth();
|
||||||
|
|
||||||
@@ -221,6 +223,7 @@ export function FleetView({ onNavigateToNode, onOpenSettingsSection, fleetUpdate
|
|||||||
onCordonChange={() => { void overview.fetchOverview(true); }}
|
onCordonChange={() => { void overview.fetchOverview(true); }}
|
||||||
onEditNode={isAdmin ? openEdit : undefined}
|
onEditNode={isAdmin ? openEdit : undefined}
|
||||||
onDeleteNode={isAdmin ? openDelete : undefined}
|
onDeleteNode={isAdmin ? openDelete : undefined}
|
||||||
|
onOpenMuteRulesWithPrefill={onOpenMuteRulesWithPrefill}
|
||||||
onAddNode={isAdmin && onOpenSettingsSection ? () => onOpenSettingsSection('nodes') : undefined}
|
onAddNode={isAdmin && onOpenSettingsSection ? () => onOpenSettingsSection('nodes') : undefined}
|
||||||
onCheckUpdates={updateStatus.checkUpdates}
|
onCheckUpdates={updateStatus.checkUpdates}
|
||||||
checkingUpdates={updateStatus.checkingUpdates}
|
checkingUpdates={updateStatus.checkingUpdates}
|
||||||
|
|||||||
@@ -14,6 +14,9 @@ import {
|
|||||||
DropdownMenuItem,
|
DropdownMenuItem,
|
||||||
DropdownMenuTrigger,
|
DropdownMenuTrigger,
|
||||||
} from '@/components/ui/dropdown-menu';
|
} 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 { formatBytes } from '@/lib/utils';
|
||||||
import { apiFetch } from '@/lib/api';
|
import { apiFetch } from '@/lib/api';
|
||||||
import { toast } from '@/components/ui/toast-store';
|
import { toast } from '@/components/ui/toast-store';
|
||||||
@@ -42,6 +45,7 @@ export interface NodeCardProps {
|
|||||||
onCordonChange?: () => void;
|
onCordonChange?: () => void;
|
||||||
onEdit?: (node: Node) => void;
|
onEdit?: (node: Node) => void;
|
||||||
onDelete?: (node: Node) => void;
|
onDelete?: (node: Node) => void;
|
||||||
|
onOpenMuteRulesWithPrefill?: (draft: MuteRuleDraft) => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
// --- Sub-Components ---
|
// --- Sub-Components ---
|
||||||
@@ -59,7 +63,7 @@ function UsageBar({ percent, color }: { percent: number; color: string }) {
|
|||||||
|
|
||||||
// --- Main Export ---
|
// --- 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 [expanded, setExpanded] = useState(false);
|
||||||
const [stacks, setStacks] = useState<string[] | null>(node.stacks);
|
const [stacks, setStacks] = useState<string[] | null>(node.stacks);
|
||||||
const [loadingStacks, setLoadingStacks] = useState(false);
|
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
|
// (requirePermission('node:manage','node',id) + requirePaid). Gating on tier
|
||||||
// alone would surface the control to deployer/viewer/auditor users whose calls 403.
|
// alone would surface the control to deployer/viewer/auditor users whose calls 403.
|
||||||
const canCordon = isPaid && can('node:manage', 'node', String(node.id));
|
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 isOnline = node.status === 'online';
|
||||||
const isLocal = node.type === 'local';
|
const isLocal = node.type === 'local';
|
||||||
@@ -182,6 +191,7 @@ export function NodeCard({ node, onNavigate, labelMap, updateStatus, onUpdate, u
|
|||||||
{node.cordoned ? 'Uncordon node' : 'Cordon node'}
|
{node.cordoned ? 'Uncordon node' : 'Cordon node'}
|
||||||
</DropdownMenuItem>
|
</DropdownMenuItem>
|
||||||
)}
|
)}
|
||||||
|
{onOpenMuteRulesWithPrefill && <NodeMuteSubmenu actions={nodeMuteActions} />}
|
||||||
</DropdownMenuContent>
|
</DropdownMenuContent>
|
||||||
</DropdownMenu>
|
</DropdownMenu>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ import type { FleetTopologyNode, LayoutMode, SavedPositions } from '@/lib/fleet-
|
|||||||
import type { Label as StackLabel } from '../label-types';
|
import type { Label as StackLabel } from '../label-types';
|
||||||
import type { Node } from '@/context/NodeContext';
|
import type { Node } from '@/context/NodeContext';
|
||||||
import type { FleetNode, NodeUpdateStatus, ViewMode, FleetPreferences, FleetPaletteEntry } from './types';
|
import type { FleetNode, NodeUpdateStatus, ViewMode, FleetPreferences, FleetPaletteEntry } from './types';
|
||||||
|
import type { MuteRuleDraft } from '@/lib/muteRules';
|
||||||
|
|
||||||
interface OverviewTabProps {
|
interface OverviewTabProps {
|
||||||
loading: boolean;
|
loading: boolean;
|
||||||
@@ -35,6 +36,7 @@ interface OverviewTabProps {
|
|||||||
onCordonChange?: () => void;
|
onCordonChange?: () => void;
|
||||||
onEditNode?: (node: Node) => void;
|
onEditNode?: (node: Node) => void;
|
||||||
onDeleteNode?: (node: Node) => void;
|
onDeleteNode?: (node: Node) => void;
|
||||||
|
onOpenMuteRulesWithPrefill?: (draft: MuteRuleDraft) => void;
|
||||||
topologyMode: LayoutMode;
|
topologyMode: LayoutMode;
|
||||||
onTopologyModeChange: (mode: LayoutMode) => void;
|
onTopologyModeChange: (mode: LayoutMode) => void;
|
||||||
topologyPositions: SavedPositions;
|
topologyPositions: SavedPositions;
|
||||||
@@ -70,6 +72,7 @@ export function OverviewTab({
|
|||||||
onCordonChange,
|
onCordonChange,
|
||||||
onEditNode,
|
onEditNode,
|
||||||
onDeleteNode,
|
onDeleteNode,
|
||||||
|
onOpenMuteRulesWithPrefill,
|
||||||
topologyMode,
|
topologyMode,
|
||||||
onTopologyModeChange,
|
onTopologyModeChange,
|
||||||
topologyPositions,
|
topologyPositions,
|
||||||
@@ -149,6 +152,7 @@ export function OverviewTab({
|
|||||||
onCordonChange={onCordonChange}
|
onCordonChange={onCordonChange}
|
||||||
onEdit={onEditNode}
|
onEdit={onEditNode}
|
||||||
onDelete={onDeleteNode}
|
onDelete={onDeleteNode}
|
||||||
|
onOpenMuteRulesWithPrefill={onOpenMuteRulesWithPrefill}
|
||||||
/>
|
/>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -34,7 +34,7 @@ function baseProps(node: FleetNode) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
useNodesMock.mockReturnValue({ nodes: [] });
|
useNodesMock.mockReturnValue({ nodes: [], hasCapability: vi.fn(() => false) });
|
||||||
useAuthMock.mockReturnValue({ isAdmin: true, can: vi.fn(() => true) });
|
useAuthMock.mockReturnValue({ isAdmin: true, can: vi.fn(() => true) });
|
||||||
useLicenseMock.mockReturnValue({ isPaid: false });
|
useLicenseMock.mockReturnValue({ isPaid: false });
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -9,6 +9,7 @@ import {
|
|||||||
Trash2,
|
Trash2,
|
||||||
SlidersHorizontal,
|
SlidersHorizontal,
|
||||||
CheckCheck,
|
CheckCheck,
|
||||||
|
MoreHorizontal,
|
||||||
} from 'lucide-react';
|
} from 'lucide-react';
|
||||||
import type { LucideIcon } from 'lucide-react';
|
import type { LucideIcon } from 'lucide-react';
|
||||||
import { Button } from '@/components/ui/button';
|
import { Button } from '@/components/ui/button';
|
||||||
@@ -22,10 +23,18 @@ import {
|
|||||||
SelectValue,
|
SelectValue,
|
||||||
} from '@/components/ui/select';
|
} from '@/components/ui/select';
|
||||||
import { cn } from '@/lib/utils';
|
import { cn } from '@/lib/utils';
|
||||||
import type { NotificationCategory, NotificationItem } from './dashboard/types';
|
import { createMuteFromNotification } from '@/lib/muteRules';
|
||||||
import type { Node } from '@/context/NodeContext';
|
import { useAuth } from '@/context/AuthContext';
|
||||||
|
import {
|
||||||
|
DropdownMenu,
|
||||||
|
DropdownMenuContent,
|
||||||
|
DropdownMenuItem,
|
||||||
|
DropdownMenuTrigger,
|
||||||
|
} from '@/components/ui/dropdown-menu';
|
||||||
import { CATEGORY_LABELS } from '@/lib/notificationCategories';
|
import { CATEGORY_LABELS } from '@/lib/notificationCategories';
|
||||||
import { countVisibleUnread, filterPanelVisible } from '@/lib/notificationVisibility';
|
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 NODE_FILTER_ALL = 'all' as const;
|
||||||
const CATEGORY_FILTER_ALL = 'all' as const;
|
const CATEGORY_FILTER_ALL = 'all' as const;
|
||||||
@@ -128,6 +137,7 @@ export function NotificationPanel({
|
|||||||
onNavigate,
|
onNavigate,
|
||||||
onNavigateChangelog,
|
onNavigateChangelog,
|
||||||
}: NotificationPanelProps) {
|
}: NotificationPanelProps) {
|
||||||
|
const { isAdmin } = useAuth();
|
||||||
const [filter, setFilter] = useState<NotifFilter>('all');
|
const [filter, setFilter] = useState<NotifFilter>('all');
|
||||||
const [nodeFilter, setNodeFilter] = useState<NodeFilter>(NODE_FILTER_ALL);
|
const [nodeFilter, setNodeFilter] = useState<NodeFilter>(NODE_FILTER_ALL);
|
||||||
const [categoryFilter, setCategoryFilter] = useState<CategoryFilter>(CATEGORY_FILTER_ALL);
|
const [categoryFilter, setCategoryFilter] = useState<CategoryFilter>(CATEGORY_FILTER_ALL);
|
||||||
@@ -369,6 +379,7 @@ export function NotificationPanel({
|
|||||||
onDelete={onDelete}
|
onDelete={onDelete}
|
||||||
onNavigate={onNavigate ? handleNavigate : undefined}
|
onNavigate={onNavigate ? handleNavigate : undefined}
|
||||||
onNavigateChangelog={onNavigateChangelog}
|
onNavigateChangelog={onNavigateChangelog}
|
||||||
|
canMute={isAdmin}
|
||||||
/>
|
/>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
@@ -386,9 +397,17 @@ interface NotificationRowProps {
|
|||||||
onDelete: (notif: NotificationItem) => void;
|
onDelete: (notif: NotificationItem) => void;
|
||||||
onNavigate?: (notif: NotificationItem) => void;
|
onNavigate?: (notif: NotificationItem) => void;
|
||||||
onNavigateChangelog?: (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 config = LEVEL_CONFIG[notif.level];
|
||||||
const Icon = config.icon;
|
const Icon = config.icon;
|
||||||
const isUnread = !notif.is_read;
|
const isUnread = !notif.is_read;
|
||||||
@@ -470,15 +489,48 @@ function NotificationRow({ notif, showNodeName, onDelete, onNavigate, onNavigate
|
|||||||
) : (
|
) : (
|
||||||
<div className={surfaceClasses}>{content}</div>
|
<div className={surfaceClasses}>{content}</div>
|
||||||
)}
|
)}
|
||||||
<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">
|
||||||
variant="ghost"
|
{canMute ? (
|
||||||
size="icon"
|
<DropdownMenu>
|
||||||
className="absolute right-2 top-2 z-20 h-6 w-6 opacity-0 transition-opacity group-hover:opacity-100 focus-visible:opacity-100"
|
<DropdownMenuTrigger asChild>
|
||||||
onClick={() => onDelete(notif)}
|
<Button
|
||||||
title="Dismiss"
|
variant="ghost"
|
||||||
>
|
size="icon"
|
||||||
<X className="h-3 w-3" strokeWidth={1.5} />
|
className="h-6 w-6"
|
||||||
</Button>
|
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>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -18,6 +18,8 @@ import EnvironmentPanel from './stack/EnvironmentPanel';
|
|||||||
import StackNetworkingPanel from './stack/StackNetworkingPanel';
|
import StackNetworkingPanel from './stack/StackNetworkingPanel';
|
||||||
import { useNodes } from '@/context/NodeContext';
|
import { useNodes } from '@/context/NodeContext';
|
||||||
import type { NotificationItem } from '@/components/dashboard/types';
|
import type { NotificationItem } from '@/components/dashboard/types';
|
||||||
|
import { ActivityMuteKebab } from '@/components/mute/MuteMenuItems';
|
||||||
|
import type { useStackMuteActions } from '@/hooks/useMuteRuleActions';
|
||||||
|
|
||||||
interface StackAnatomyPanelProps {
|
interface StackAnatomyPanelProps {
|
||||||
stackName: string;
|
stackName: string;
|
||||||
@@ -32,6 +34,7 @@ interface StackAnatomyPanelProps {
|
|||||||
canEdit: boolean;
|
canEdit: boolean;
|
||||||
applying?: boolean;
|
applying?: boolean;
|
||||||
notifications?: NotificationItem[];
|
notifications?: NotificationItem[];
|
||||||
|
stackMuteActions?: ReturnType<typeof useStackMuteActions>;
|
||||||
}
|
}
|
||||||
|
|
||||||
type SemverBump = 'none' | 'patch' | 'minor' | 'major' | 'unknown';
|
type SemverBump = 'none' | 'patch' | 'minor' | 'major' | 'unknown';
|
||||||
@@ -82,6 +85,7 @@ export default function StackAnatomyPanel({
|
|||||||
canEdit,
|
canEdit,
|
||||||
applying = false,
|
applying = false,
|
||||||
notifications,
|
notifications,
|
||||||
|
stackMuteActions,
|
||||||
}: StackAnatomyPanelProps) {
|
}: StackAnatomyPanelProps) {
|
||||||
const anatomy = useMemo(() => parseAnatomy(content), [content]);
|
const anatomy = useMemo(() => parseAnatomy(content), [content]);
|
||||||
const envKeys = useMemo(() => parseEnvKeys(envContent), [envContent]);
|
const envKeys = useMemo(() => parseEnvKeys(envContent), [envContent]);
|
||||||
@@ -409,6 +413,7 @@ export default function StackAnatomyPanel({
|
|||||||
edit
|
edit
|
||||||
</button>
|
</button>
|
||||||
)}
|
)}
|
||||||
|
{stackMuteActions && <ActivityMuteKebab actions={stackMuteActions} />}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<TabsContent value="activity" className="flex-1 min-h-0 overflow-y-auto px-3 mt-0">
|
<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'
|
| 'autoheal_triggered'
|
||||||
| 'monitor_alert'
|
| 'monitor_alert'
|
||||||
| 'scan_finding'
|
| 'scan_finding'
|
||||||
|
| 'drift_detected'
|
||||||
|
| 'drift_resolved'
|
||||||
|
| 'update_started'
|
||||||
|
| 'health_gate_passed'
|
||||||
|
| 'health_gate_failed'
|
||||||
| 'node_update_available'
|
| 'node_update_available'
|
||||||
| 'system';
|
| '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 { 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 { Button } from '@/components/ui/button';
|
||||||
import { Input } from '@/components/ui/input';
|
import { Input } from '@/components/ui/input';
|
||||||
import { Modal, ModalHeader, ModalBody, ModalFooter, ConfirmModal } from '@/components/ui/modal';
|
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 { SettingsCallout } from './SettingsCallout';
|
||||||
import { SettingsPrimaryButton } from './SettingsActions';
|
import { SettingsPrimaryButton } from './SettingsActions';
|
||||||
import { useMastheadStats } from './MastheadStatsContext';
|
import { useMastheadStats } from './MastheadStatsContext';
|
||||||
|
import { labelMuteAllDraft, type MuteRuleDraft } from '@/lib/muteRules';
|
||||||
|
import { useCanMuteNotifications } from '@/hooks/useMuteRuleActions';
|
||||||
|
|
||||||
interface LabelsSectionProps {
|
interface LabelsSectionProps {
|
||||||
onLabelsChanged?: () => void;
|
onLabelsChanged?: () => void;
|
||||||
|
onOpenMuteRulesWithPrefill?: (draft: MuteRuleDraft) => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function LabelsSection({ onLabelsChanged }: LabelsSectionProps = {}) {
|
export function LabelsSection({ onLabelsChanged, onOpenMuteRulesWithPrefill }: LabelsSectionProps = {}) {
|
||||||
const { can } = useAuth();
|
const { can, isAdmin } = useAuth();
|
||||||
|
const canMute = useCanMuteNotifications();
|
||||||
// Mirrors the backend `requirePermission('stack:edit')` guard on
|
// Mirrors the backend `requirePermission('stack:edit')` guard on
|
||||||
// POST/PUT/DELETE /api/labels, keeping a viewer/deployer/auditor from
|
// POST/PUT/DELETE /api/labels, keeping a viewer/deployer/auditor from
|
||||||
// clicking through to a guaranteed 403.
|
// clicking through to a guaranteed 403.
|
||||||
@@ -161,6 +165,17 @@ export function LabelsSection({ onLabelsChanged }: LabelsSectionProps = {}) {
|
|||||||
<span className="text-xs text-muted-foreground tabular-nums">
|
<span className="text-xs text-muted-foreground tabular-nums">
|
||||||
{assignmentCounts[label.id] || 0} stack{(assignmentCounts[label.id] || 0) !== 1 ? 's' : ''}
|
{assignmentCounts[label.id] || 0} stack{(assignmentCounts[label.id] || 0) !== 1 ? 's' : ''}
|
||||||
</span>
|
</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 && (
|
{canMutate && (
|
||||||
<>
|
<>
|
||||||
<Button
|
<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,
|
scopeLabel,
|
||||||
} from './index';
|
} from './index';
|
||||||
import type { SectionId, SettingsItemMeta, VisibilityContext } from './index';
|
import type { SectionId, SettingsItemMeta, VisibilityContext } from './index';
|
||||||
|
import type { MuteRuleDraft } from '@/lib/muteRules';
|
||||||
import { SettingsSidebar } from './SettingsSidebar';
|
import { SettingsSidebar } from './SettingsSidebar';
|
||||||
import { SettingsSectionContent } from './SettingsSectionContent';
|
import { SettingsSectionContent } from './SettingsSectionContent';
|
||||||
import { MastheadStatsProvider, useMastheadStatsValue } from './MastheadStatsContext';
|
import { MastheadStatsProvider, useMastheadStatsValue } from './MastheadStatsContext';
|
||||||
@@ -31,6 +32,9 @@ import { MastheadStatsProvider, useMastheadStatsValue } from './MastheadStatsCon
|
|||||||
interface SettingsPageProps {
|
interface SettingsPageProps {
|
||||||
currentSection: SectionId;
|
currentSection: SectionId;
|
||||||
onSectionChange: (section: SectionId) => void;
|
onSectionChange: (section: SectionId) => void;
|
||||||
|
muteRulePrefill?: MuteRuleDraft | null;
|
||||||
|
onMutePrefillConsumed?: () => void;
|
||||||
|
onOpenMuteRulesWithPrefill?: (draft: MuteRuleDraft) => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function SettingsPage(props: SettingsPageProps) {
|
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 { isAdmin } = useAuth();
|
||||||
const { isPaid } = useLicense();
|
const { isPaid } = useLicense();
|
||||||
const { activeNode } = useNodes();
|
const { activeNode } = useNodes();
|
||||||
@@ -197,6 +207,9 @@ function SettingsPageInner({ currentSection, onSectionChange }: SettingsPageProp
|
|||||||
sectionId={safeSection}
|
sectionId={safeSection}
|
||||||
onDirtyChange={handleDirtyChange}
|
onDirtyChange={handleDirtyChange}
|
||||||
showDescription
|
showDescription
|
||||||
|
muteRulePrefill={muteRulePrefill}
|
||||||
|
onMutePrefillConsumed={onMutePrefillConsumed}
|
||||||
|
onOpenMuteRulesWithPrefill={onOpenMuteRulesWithPrefill}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</ScrollArea>
|
</ScrollArea>
|
||||||
|
|||||||
@@ -22,6 +22,7 @@ import {
|
|||||||
getSettingsItem,
|
getSettingsItem,
|
||||||
} from './index';
|
} from './index';
|
||||||
import type { SectionId } from './index';
|
import type { SectionId } from './index';
|
||||||
|
import type { MuteRuleDraft } from '@/lib/muteRules';
|
||||||
import LazyBoundary from '../LazyBoundary';
|
import LazyBoundary from '../LazyBoundary';
|
||||||
import { SectionGate } from './SectionGate';
|
import { SectionGate } from './SectionGate';
|
||||||
|
|
||||||
@@ -44,6 +45,9 @@ const LabelsSection = lazy(() =>
|
|||||||
const NotificationRoutingSection = lazy(() =>
|
const NotificationRoutingSection = lazy(() =>
|
||||||
import('./NotificationRoutingSection').then(m => ({ default: m.NotificationRoutingSection })),
|
import('./NotificationRoutingSection').then(m => ({ default: m.NotificationRoutingSection })),
|
||||||
);
|
);
|
||||||
|
const NotificationSuppressionSection = lazy(() =>
|
||||||
|
import('./NotificationSuppressionSection').then(m => ({ default: m.NotificationSuppressionSection })),
|
||||||
|
);
|
||||||
const CloudBackupSection = lazy(() =>
|
const CloudBackupSection = lazy(() =>
|
||||||
import('./CloudBackupSection').then(m => ({ default: m.CloudBackupSection })),
|
import('./CloudBackupSection').then(m => ({ default: m.CloudBackupSection })),
|
||||||
);
|
);
|
||||||
@@ -72,6 +76,9 @@ function SectionSkeleton() {
|
|||||||
function renderSection(
|
function renderSection(
|
||||||
sectionId: SectionId,
|
sectionId: SectionId,
|
||||||
onDirtyChange: (section: SectionId, dirty: boolean) => void,
|
onDirtyChange: (section: SectionId, dirty: boolean) => void,
|
||||||
|
muteRulePrefill: MuteRuleDraft | null | undefined,
|
||||||
|
onMutePrefillConsumed: (() => void) | undefined,
|
||||||
|
onOpenMuteRulesWithPrefill: ((draft: MuteRuleDraft) => void) | undefined,
|
||||||
) {
|
) {
|
||||||
switch (sectionId) {
|
switch (sectionId) {
|
||||||
case 'account': return <AccountSection />;
|
case 'account': return <AccountSection />;
|
||||||
@@ -81,7 +88,7 @@ function renderSection(
|
|||||||
case 'sso': return <SSOSection />;
|
case 'sso': return <SSOSection />;
|
||||||
case 'api-tokens': return <ApiTokensSection />;
|
case 'api-tokens': return <ApiTokensSection />;
|
||||||
case 'registries': return <RegistriesSection />;
|
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 'host-alerts': return <HostAlertsSection onDirtyChange={(d) => onDirtyChange('host-alerts', d)} />;
|
||||||
case 'container-alerts': return <ContainerAlertsSection onDirtyChange={(d) => onDirtyChange('container-alerts', d)} />;
|
case 'container-alerts': return <ContainerAlertsSection onDirtyChange={(d) => onDirtyChange('container-alerts', d)} />;
|
||||||
case 'docker-storage': return <DockerStorageSection onDirtyChange={(d) => onDirtyChange('docker-storage', 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 'fleet-mesh': return <FleetMeshSection onDirtyChange={(d) => onDirtyChange('fleet-mesh', d)} />;
|
||||||
case 'notifications': return <NotificationsSection />;
|
case 'notifications': return <NotificationsSection />;
|
||||||
case 'notification-routing': return <NotificationRoutingSection />;
|
case 'notification-routing': return <NotificationRoutingSection />;
|
||||||
|
case 'notification-suppression': return (
|
||||||
|
<NotificationSuppressionSection
|
||||||
|
prefill={muteRulePrefill}
|
||||||
|
onPrefillConsumed={onMutePrefillConsumed}
|
||||||
|
/>
|
||||||
|
);
|
||||||
case 'webhooks': return <WebhooksSection />;
|
case 'webhooks': return <WebhooksSection />;
|
||||||
case 'cloud-backup': return <CloudBackupSection />;
|
case 'cloud-backup': return <CloudBackupSection />;
|
||||||
case 'developer': return <DeveloperSection onDirtyChange={(d) => onDirtyChange('developer', d)} />;
|
case 'developer': return <DeveloperSection onDirtyChange={(d) => onDirtyChange('developer', d)} />;
|
||||||
@@ -109,6 +122,9 @@ interface SettingsSectionContentProps {
|
|||||||
onDirtyChange: (section: SectionId, dirty: boolean) => void;
|
onDirtyChange: (section: SectionId, dirty: boolean) => void;
|
||||||
/** Render the section's lead description paragraph above the content. */
|
/** Render the section's lead description paragraph above the content. */
|
||||||
showDescription?: boolean;
|
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,
|
* the desktop SettingsPage and the mobile settings screen so the section switch,
|
||||||
* lazy splitting, and gating live in exactly one place.
|
* 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);
|
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(
|
const element = useMemo(
|
||||||
() => renderSection(sectionId, onDirtyChange),
|
() => renderSection(sectionId, onDirtyChange, muteRulePrefill, onMutePrefillConsumed, onOpenMuteRulesWithPrefill),
|
||||||
[sectionId, onDirtyChange],
|
[sectionId, onDirtyChange, muteRulePrefill, onMutePrefillConsumed, onOpenMuteRulesWithPrefill],
|
||||||
);
|
);
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
|
|||||||
@@ -82,6 +82,7 @@ describe('settings registry', () => {
|
|||||||
const byId = new Map(SETTINGS_ITEMS.map(i => [i.id, i]));
|
const byId = new Map(SETTINGS_ITEMS.map(i => [i.id, i]));
|
||||||
expect(byId.get('notifications')?.label).toBe('Channels');
|
expect(byId.get('notifications')?.label).toBe('Channels');
|
||||||
expect(byId.get('notification-routing')?.label).toBe('Notification Routing');
|
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)', () => {
|
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,
|
adminOnly: true,
|
||||||
hiddenOnRemote: 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
|
// Automation
|
||||||
{
|
{
|
||||||
id: 'image-updates',
|
id: 'image-updates',
|
||||||
|
|||||||
@@ -69,6 +69,7 @@ export type SectionId =
|
|||||||
| 'app-store'
|
| 'app-store'
|
||||||
| 'stacks'
|
| 'stacks'
|
||||||
| 'notification-routing'
|
| 'notification-routing'
|
||||||
|
| 'notification-suppression'
|
||||||
| 'recovery'
|
| 'recovery'
|
||||||
| 'support'
|
| 'support'
|
||||||
| 'about';
|
| 'about';
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import { useState, type ReactNode } from 'react';
|
import { useState, type ReactNode } from 'react';
|
||||||
import { Check, Plus } from 'lucide-react';
|
import { Check, Plus, BellOff } from 'lucide-react';
|
||||||
import {
|
import {
|
||||||
ContextMenu, ContextMenuContent, ContextMenuItem, ContextMenuSeparator,
|
ContextMenu, ContextMenuContent, ContextMenuItem, ContextMenuSeparator,
|
||||||
ContextMenuSub, ContextMenuSubContent, ContextMenuSubTrigger, ContextMenuTrigger,
|
ContextMenuSub, ContextMenuSubContent, ContextMenuSubTrigger, ContextMenuTrigger,
|
||||||
@@ -72,6 +72,42 @@ function LabelsSub({ item, ctx }: { item: MenuItem; ctx: StackMenuCtx }) {
|
|||||||
<ContextMenuItem onClick={ctx.openLabelManager}>
|
<ContextMenuItem onClick={ctx.openLabelManager}>
|
||||||
<span className="text-xs">Manage labels...</span>
|
<span className="text-xs">Manage labels...</span>
|
||||||
</ContextMenuItem>
|
</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>
|
</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) {
|
function renderItem(item: MenuItem, ctx: StackMenuCtx) {
|
||||||
if (item.id === 'labels') return <LabelsSub key={item.id} item={item} ctx={ctx} />;
|
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 (
|
return (
|
||||||
<ContextMenuItem
|
<ContextMenuItem
|
||||||
key={item.id}
|
key={item.id}
|
||||||
|
|||||||
@@ -10,10 +10,11 @@ interface StackGroupProps {
|
|||||||
collapsed: boolean;
|
collapsed: boolean;
|
||||||
onToggle: () => void;
|
onToggle: () => void;
|
||||||
variant?: 'default' | 'pinned';
|
variant?: 'default' | 'pinned';
|
||||||
|
headerActions?: ReactNode;
|
||||||
children: 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 isPinned = variant === 'pinned';
|
||||||
const labelColor = isPinned ? 'text-brand/90' : 'text-stat-subtitle';
|
const labelColor = isPinned ? 'text-brand/90' : 'text-stat-subtitle';
|
||||||
return (
|
return (
|
||||||
@@ -32,6 +33,7 @@ export function StackGroup({ id, label, count, collapsed, onToggle, variant = 'd
|
|||||||
{isPinned ? '★ ' : ''}{label}
|
{isPinned ? '★ ' : ''}{label}
|
||||||
</span>
|
</span>
|
||||||
<span className="flex items-center gap-1.5">
|
<span className="flex items-center gap-1.5">
|
||||||
|
{headerActions}
|
||||||
<span className="font-mono text-[9px] tabular-nums text-stat-icon">{count}</span>
|
<span className="font-mono text-[9px] tabular-nums text-stat-icon">{count}</span>
|
||||||
{collapsed
|
{collapsed
|
||||||
? <ChevronRight className="w-3 h-3 text-stat-icon" strokeWidth={1.5} />
|
? <ChevronRight className="w-3 h-3 text-stat-icon" strokeWidth={1.5} />
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import { useState } from 'react';
|
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 { Button } from '@/components/ui/button';
|
||||||
import {
|
import {
|
||||||
DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuSeparator,
|
DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuSeparator,
|
||||||
@@ -72,6 +72,42 @@ function LabelsSub({ item, ctx }: { item: MenuItem; ctx: StackMenuCtx }) {
|
|||||||
<DropdownMenuItem onClick={ctx.openLabelManager}>
|
<DropdownMenuItem onClick={ctx.openLabelManager}>
|
||||||
<span className="text-xs">Manage labels...</span>
|
<span className="text-xs">Manage labels...</span>
|
||||||
</DropdownMenuItem>
|
</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>
|
</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) {
|
function renderItem(item: MenuItem, ctx: StackMenuCtx) {
|
||||||
if (item.id === 'labels') return <LabelsSub key={item.id} item={item} ctx={ctx} />;
|
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 (
|
return (
|
||||||
<DropdownMenuItem
|
<DropdownMenuItem
|
||||||
key={item.id}
|
key={item.id}
|
||||||
|
|||||||
@@ -13,6 +13,9 @@ import { StackContextMenu } from './StackContextMenu';
|
|||||||
import { StackKebabMenu } from './StackKebabMenu';
|
import { StackKebabMenu } from './StackKebabMenu';
|
||||||
import { EmptyStackState } from './EmptyStackState';
|
import { EmptyStackState } from './EmptyStackState';
|
||||||
import type { StackMenuCtx, FilterChip } from './sidebar-types';
|
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 {
|
interface RemoteNodeResult {
|
||||||
nodeId: number;
|
nodeId: number;
|
||||||
@@ -54,6 +57,7 @@ export interface StackListProps {
|
|||||||
// Open the create dialog on a starting mode. Present only when the user can
|
// Open the create dialog on a starting mode. Present only when the user can
|
||||||
// create stacks; drives the zero-stacks empty state.
|
// create stacks; drives the zero-stacks empty state.
|
||||||
onOpenCreate?: (mode: 'import' | 'empty') => void;
|
onOpenCreate?: (mode: 'import' | 'empty') => void;
|
||||||
|
openMuteRulesWithPrefill?: (draft: MuteRuleDraft) => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
interface BuiltGroup {
|
interface BuiltGroup {
|
||||||
@@ -63,6 +67,8 @@ interface BuiltGroup {
|
|||||||
count: number;
|
count: number;
|
||||||
files: string[];
|
files: string[];
|
||||||
variant?: 'default' | 'pinned';
|
variant?: 'default' | 'pinned';
|
||||||
|
labelId?: number;
|
||||||
|
labelName?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
function buildGroups(
|
function buildGroups(
|
||||||
@@ -101,6 +107,8 @@ function buildGroups(
|
|||||||
result.push({
|
result.push({
|
||||||
kind: 'labeled', id: `label:${label.id}`,
|
kind: 'labeled', id: `label:${label.id}`,
|
||||||
label: label.name.toUpperCase(), count: bucketFiles.length, files: bucketFiles,
|
label: label.name.toUpperCase(), count: bucketFiles.length, files: bucketFiles,
|
||||||
|
labelId: label.id,
|
||||||
|
labelName: label.name,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -111,6 +119,19 @@ function buildGroups(
|
|||||||
return result;
|
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 {
|
interface StackListBulkProps {
|
||||||
bulkMode: boolean;
|
bulkMode: boolean;
|
||||||
selectedFiles: Set<string>;
|
selectedFiles: Set<string>;
|
||||||
@@ -125,6 +146,7 @@ export function StackList(props: StackListProps & StackListBulkProps) {
|
|||||||
bulkMode, selectedFiles, onToggleSelect,
|
bulkMode, selectedFiles, onToggleSelect,
|
||||||
remoteResults, remoteLoading, remoteFailedNodes, onSelectRemoteFile,
|
remoteResults, remoteLoading, remoteFailedNodes, onSelectRemoteFile,
|
||||||
filterChip, onOpenCreate,
|
filterChip, onOpenCreate,
|
||||||
|
openMuteRulesWithPrefill,
|
||||||
} = props;
|
} = props;
|
||||||
|
|
||||||
const [failedNodesExpanded, setFailedNodesExpanded] = useState(false);
|
const [failedNodesExpanded, setFailedNodesExpanded] = useState(false);
|
||||||
@@ -164,6 +186,17 @@ export function StackList(props: StackListProps & StackListBulkProps) {
|
|||||||
collapsed={isCollapsed(g.id)}
|
collapsed={isCollapsed(g.id)}
|
||||||
onToggle={() => toggleCollapse(g.id)}
|
onToggle={() => toggleCollapse(g.id)}
|
||||||
variant={g.variant}
|
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 => {
|
{g.files.map(file => {
|
||||||
const ctx = buildMenuCtx(file);
|
const ctx = buildMenuCtx(file);
|
||||||
|
|||||||
@@ -46,6 +46,15 @@ export interface StackMenuCtx {
|
|||||||
unpin: () => void;
|
unpin: () => void;
|
||||||
toggleLabel: (labelId: number) => void;
|
toggleLabel: (labelId: number) => void;
|
||||||
createAndAssignLabel: (name: string, color: LabelColor) => Promise<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;
|
openLabelManager: () => void;
|
||||||
openScheduleTask: () => void;
|
openScheduleTask: () => void;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -5,6 +5,8 @@ import {
|
|||||||
} from 'lucide-react';
|
} from 'lucide-react';
|
||||||
import type { LucideIcon } from 'lucide-react';
|
import type { LucideIcon } from 'lucide-react';
|
||||||
import { Button } from '@/components/ui/button';
|
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 { toast } from '@/components/ui/toast-store';
|
||||||
import { apiFetch } from '@/lib/api';
|
import { apiFetch } from '@/lib/api';
|
||||||
import { formatTimeAgo } from '@/lib/relativeTime';
|
import { formatTimeAgo } from '@/lib/relativeTime';
|
||||||
@@ -13,6 +15,12 @@ import type { NotificationItem } from '@/components/dashboard/types';
|
|||||||
type ActivityLevel = 'info' | 'warning' | 'error';
|
type ActivityLevel = 'info' | 'warning' | 'error';
|
||||||
const ACTIVITY_LEVELS: readonly string[] = ['info', 'warning', 'error'];
|
const ACTIVITY_LEVELS: readonly string[] = ['info', 'warning', 'error'];
|
||||||
|
|
||||||
|
interface SuppressionMatchSnapshot {
|
||||||
|
rules: { id: number; name: string }[];
|
||||||
|
bellSuppressed: boolean;
|
||||||
|
externalSuppressed: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
interface ActivityEvent {
|
interface ActivityEvent {
|
||||||
id: number;
|
id: number;
|
||||||
level: ActivityLevel;
|
level: ActivityLevel;
|
||||||
@@ -21,6 +29,7 @@ interface ActivityEvent {
|
|||||||
timestamp: number;
|
timestamp: number;
|
||||||
stack_name?: string;
|
stack_name?: string;
|
||||||
actor_username?: string | null;
|
actor_username?: string | null;
|
||||||
|
suppression_match?: string | SuppressionMatchSnapshot | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
interface StackActivityTimelineProps {
|
interface StackActivityTimelineProps {
|
||||||
@@ -61,6 +70,25 @@ function formatActor(actor: string): { label: string; isSystem: boolean } {
|
|||||||
return { label: SYSTEM_ACTOR_LABEL[actor] ?? actor, isSystem };
|
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 {
|
function isActivityEvent(value: unknown): value is ActivityEvent {
|
||||||
if (!value || typeof value !== 'object') return false;
|
if (!value || typeof value !== 'object') return false;
|
||||||
const v = value as Record<string, unknown>;
|
const v = value as Record<string, unknown>;
|
||||||
@@ -261,6 +289,10 @@ export function StackActivityTimeline({ stackName, liveEvents }: StackActivityTi
|
|||||||
{g.events.map(e => {
|
{g.events.map(e => {
|
||||||
const Icon = CATEGORY_ICON[e.category ?? ''] ?? Activity;
|
const Icon = CATEGORY_ICON[e.category ?? ''] ?? Activity;
|
||||||
const actor = e.actor_username ? formatActor(e.actor_username) : null;
|
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 (
|
return (
|
||||||
<div
|
<div
|
||||||
key={e.id}
|
key={e.id}
|
||||||
@@ -275,6 +307,18 @@ export function StackActivityTimeline({ stackName, liveEvents }: StackActivityTi
|
|||||||
{actor.isSystem ? `via ${actor.label}` : `by ${actor.label}`}
|
{actor.isSystem ? `via ${actor.label}` : `by ${actor.label}`}
|
||||||
</span>
|
</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>
|
</div>
|
||||||
<span className="font-mono text-[10px] text-stat-subtitle shrink-0">{formatTimeAgo(e.timestamp)}</span>
|
<span className="font-mono text-[10px] text-stat-subtitle shrink-0">{formatTimeAgo(e.timestamp)}</span>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -32,6 +32,15 @@ function makeCtx(overrides: Partial<StackMenuCtx> = {}): StackMenuCtx {
|
|||||||
createAndAssignLabel: vi.fn(),
|
createAndAssignLabel: vi.fn(),
|
||||||
openLabelManager: vi.fn(),
|
openLabelManager: vi.fn(),
|
||||||
openScheduleTask: 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,
|
...overrides,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
@@ -111,6 +120,25 @@ describe('useStackMenuItems', () => {
|
|||||||
expect(lifecycle?.items.some(i => i.id === 'schedule')).toBeFalsy();
|
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', () => {
|
it('keeps label assignment available for any tier', () => {
|
||||||
const { result } = renderHook(() => useStackMenuItems('web.yml', makeCtx({
|
const { result } = renderHook(() => useStackMenuItems('web.yml', makeCtx({
|
||||||
labels: [{ id: 1, node_id: 0, name: 'prod', color: 'teal' }],
|
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 {
|
import {
|
||||||
Activity,
|
Activity,
|
||||||
ArrowUpRight,
|
ArrowUpRight,
|
||||||
|
BellOff,
|
||||||
BellRing,
|
BellRing,
|
||||||
CalendarClock,
|
CalendarClock,
|
||||||
Download,
|
Download,
|
||||||
@@ -22,6 +23,7 @@ export function useStackMenuItems(_file: string, ctx: StackMenuCtx): MenuGroup[]
|
|||||||
openAlertSheet, openAutoHeal, checkUpdates, openStackApp,
|
openAlertSheet, openAutoHeal, checkUpdates, openStackApp,
|
||||||
deploy, stop, restart, update, remove, pin, unpin, toggleLabel,
|
deploy, stop, restart, update, remove, pin, unpin, toggleLabel,
|
||||||
menuVisibility, openScheduleTask,
|
menuVisibility, openScheduleTask,
|
||||||
|
canMuteNotifications, muteStackAll, muteStackDeploySuccess, muteStackMonitor, openStackMuteRules,
|
||||||
} = ctx;
|
} = ctx;
|
||||||
const { showDeploy, showStop, showRestart, showUpdate } = menuVisibility;
|
const { showDeploy, showStop, showRestart, showUpdate } = menuVisibility;
|
||||||
|
|
||||||
@@ -36,6 +38,20 @@ export function useStackMenuItems(_file: string, ctx: StackMenuCtx): MenuGroup[]
|
|||||||
if (stackStatus === 'running' && canOpenApp) {
|
if (stackStatus === 'running' && canOpenApp) {
|
||||||
inspect.push({ id: 'open-app', label: 'Open App', icon: ArrowUpRight, shortcut: '↗', onSelect: openStackApp });
|
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 });
|
groups.push({ id: 'inspect', items: inspect });
|
||||||
|
|
||||||
const organize: MenuItem[] = [];
|
const organize: MenuItem[] = [];
|
||||||
@@ -82,5 +98,6 @@ export function useStackMenuItems(_file: string, ctx: StackMenuCtx): MenuGroup[]
|
|||||||
showDeploy, showStop, showRestart, showUpdate,
|
showDeploy, showStop, showRestart, showUpdate,
|
||||||
openAlertSheet, openAutoHeal, checkUpdates, openStackApp,
|
openAlertSheet, openAutoHeal, checkUpdates, openStackApp,
|
||||||
deploy, stop, restart, update, remove, pin, unpin, toggleLabel, openScheduleTask,
|
deploy, stop, restart, update, remove, pin, unpin, toggleLabel, openScheduleTask,
|
||||||
|
canMuteNotifications, muteStackAll, muteStackDeploySuccess, muteStackMonitor, openStackMuteRules,
|
||||||
]);
|
]);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -13,6 +13,7 @@ export const CAPABILITIES = [
|
|||||||
'network-topology',
|
'network-topology',
|
||||||
'notifications',
|
'notifications',
|
||||||
'notification-routing',
|
'notification-routing',
|
||||||
|
'notification-suppression',
|
||||||
'host-console',
|
'host-console',
|
||||||
'container-exec',
|
'container-exec',
|
||||||
'audit-log',
|
'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',
|
autoheal_triggered: 'Auto-heal',
|
||||||
monitor_alert: 'Monitor alert',
|
monitor_alert: 'Monitor alert',
|
||||||
scan_finding: 'Scan finding',
|
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',
|
node_update_available: 'Node update',
|
||||||
system: 'System',
|
system: 'System',
|
||||||
};
|
};
|
||||||
|
|||||||
Reference in New Issue
Block a user