mirror of
https://github.com/Studio-Saelix/sencho.git
synced 2026-07-26 11:49:16 +00:00
feat(notifications): add shared notification routing rules (Admiral tier) (#347)
Route stack alerts to specific Discord, Slack, or webhook channels instead of the single global endpoint. Includes per-rule enable/disable, priority ordering, and automatic fallback to global agents when no rule matches. - Add notification_routes table, interface, and CRUD in DatabaseService - Add routing logic in NotificationService.dispatchAlert with optional stackName - Pass stack context from MonitorService (crash/health) and SchedulerService - Add 5 API endpoints gated with requireAdmin + requireAdmiral - Add NotificationRoutingSection UI with Combobox stack picker, channel tabs - Parallel webhook dispatch via Promise.allSettled - 10 unit tests covering routing, fallback, and edge cases - Documentation with screenshots at docs/features/notification-routing.mdx
This commit is contained in:
@@ -46,6 +46,7 @@ npm-debug.log*
|
||||
plans/
|
||||
CLAUDE.md
|
||||
MANUAL_STEPS.md
|
||||
docs/superpowers/
|
||||
|
||||
# Playwright MCP
|
||||
.playwright-mcp/
|
||||
|
||||
@@ -4,6 +4,12 @@ All notable changes to this project will be documented in this file.
|
||||
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),
|
||||
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
|
||||
|
||||
## [Unreleased]
|
||||
|
||||
### Added
|
||||
|
||||
* **notifications:** shared notification routing rules (Admiral tier) — route stack alerts to specific Discord, Slack, or webhook channels instead of the global endpoint. Includes per-rule enable/disable, priority ordering, and fallback to global agents when no rule matches.
|
||||
|
||||
## [0.30.0](https://github.com/AnsoCode/Sencho/compare/v0.29.0...v0.30.0) (2026-04-03)
|
||||
|
||||
|
||||
|
||||
@@ -307,7 +307,7 @@ describe('MonitorService - global crash detection', () => {
|
||||
const svc = MonitorService.getInstance();
|
||||
await (svc as any).evaluateGlobalSettings({ global_crash: '1' });
|
||||
|
||||
expect(mockDispatchAlert).toHaveBeenCalledWith('error', expect.stringContaining('Crash'));
|
||||
expect(mockDispatchAlert).toHaveBeenCalledWith('error', expect.stringContaining('Crash'), undefined);
|
||||
});
|
||||
|
||||
it('ignores exit codes 0, 137, 143, 255', async () => {
|
||||
@@ -340,7 +340,7 @@ describe('MonitorService - global crash detection', () => {
|
||||
const svc = MonitorService.getInstance();
|
||||
await (svc as any).evaluateGlobalSettings({ global_crash: '1' });
|
||||
|
||||
expect(mockDispatchAlert).toHaveBeenCalledWith('error', expect.stringContaining('unhealthy'));
|
||||
expect(mockDispatchAlert).toHaveBeenCalledWith('error', expect.stringContaining('unhealthy'), undefined);
|
||||
});
|
||||
|
||||
it('skips remote nodes', async () => {
|
||||
@@ -386,7 +386,7 @@ describe('MonitorService - breach state machine', () => {
|
||||
const svc = MonitorService.getInstance();
|
||||
await (svc as any).evaluate();
|
||||
|
||||
expect(mockDispatchAlert).toHaveBeenCalledWith('warning', expect.stringContaining('CPU'));
|
||||
expect(mockDispatchAlert).toHaveBeenCalledWith('warning', expect.stringContaining('CPU'), 'my-stack');
|
||||
expect(mockUpdateStackAlertLastFired).toHaveBeenCalledWith(1, expect.any(Number));
|
||||
});
|
||||
|
||||
|
||||
@@ -0,0 +1,248 @@
|
||||
/**
|
||||
* Unit tests for Notification Routing — CRUD operations on notification_routes,
|
||||
* routing logic in NotificationService, and edge cases.
|
||||
*/
|
||||
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
||||
|
||||
// ── Hoisted mocks ──────────────────────────────────────────────────────
|
||||
|
||||
const {
|
||||
mockGetEnabledNotificationRoutes,
|
||||
mockGetEnabledAgents,
|
||||
mockAddNotificationHistory,
|
||||
} = vi.hoisted(() => ({
|
||||
mockGetEnabledNotificationRoutes: vi.fn().mockReturnValue([]),
|
||||
mockGetEnabledAgents: vi.fn().mockReturnValue([]),
|
||||
mockAddNotificationHistory: vi.fn().mockReturnValue({
|
||||
id: 1,
|
||||
level: 'info',
|
||||
message: 'test',
|
||||
timestamp: Date.now(),
|
||||
is_read: 0,
|
||||
}),
|
||||
}));
|
||||
|
||||
vi.mock('../services/DatabaseService', () => ({
|
||||
DatabaseService: {
|
||||
getInstance: () => ({
|
||||
getEnabledNotificationRoutes: mockGetEnabledNotificationRoutes,
|
||||
getEnabledAgents: mockGetEnabledAgents,
|
||||
addNotificationHistory: mockAddNotificationHistory,
|
||||
}),
|
||||
},
|
||||
}));
|
||||
|
||||
// Spy on global fetch for webhook dispatch verification
|
||||
const mockFetch = vi.fn().mockResolvedValue({ ok: true });
|
||||
vi.stubGlobal('fetch', mockFetch);
|
||||
|
||||
import { NotificationService } from '../services/NotificationService';
|
||||
|
||||
// ── Helpers ────────────────────────────────────────────────────────────
|
||||
|
||||
function makeRoute(overrides: Record<string, unknown> = {}) {
|
||||
return {
|
||||
id: 1,
|
||||
name: 'Prod Discord',
|
||||
stack_patterns: ['my-app'],
|
||||
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(),
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
function makeAgent(type: 'discord' | 'slack' | 'webhook' = 'slack') {
|
||||
return {
|
||||
id: 1,
|
||||
type,
|
||||
url: 'https://hooks.slack.com/services/global',
|
||||
enabled: true,
|
||||
};
|
||||
}
|
||||
|
||||
// ── Tests ──────────────────────────────────────────────────────────────
|
||||
|
||||
describe('NotificationService - routing logic', () => {
|
||||
let svc: NotificationService;
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
// Reset singleton so each test gets a fresh instance
|
||||
(NotificationService as any).instance = undefined;
|
||||
svc = NotificationService.getInstance();
|
||||
});
|
||||
|
||||
it('routes to matching route channel and skips global agents', async () => {
|
||||
mockGetEnabledNotificationRoutes.mockReturnValue([makeRoute()]);
|
||||
mockGetEnabledAgents.mockReturnValue([makeAgent()]);
|
||||
|
||||
await svc.dispatchAlert('error', 'Container crashed', 'my-app');
|
||||
|
||||
// Should have called fetch with discord webhook URL
|
||||
expect(mockFetch).toHaveBeenCalledWith(
|
||||
'https://discord.com/api/webhooks/123/abc',
|
||||
expect.objectContaining({ method: 'POST' })
|
||||
);
|
||||
// Should NOT have called the global slack agent
|
||||
expect(mockFetch).not.toHaveBeenCalledWith(
|
||||
'https://hooks.slack.com/services/global',
|
||||
expect.anything()
|
||||
);
|
||||
});
|
||||
|
||||
it('falls back to global agents when no route matches', async () => {
|
||||
mockGetEnabledNotificationRoutes.mockReturnValue([
|
||||
makeRoute({ stack_patterns: ['other-stack'] }),
|
||||
]);
|
||||
mockGetEnabledAgents.mockReturnValue([makeAgent()]);
|
||||
|
||||
await svc.dispatchAlert('error', 'Container crashed', 'my-app');
|
||||
|
||||
// Should NOT have called the route's discord channel
|
||||
expect(mockFetch).not.toHaveBeenCalledWith(
|
||||
'https://discord.com/api/webhooks/123/abc',
|
||||
expect.anything()
|
||||
);
|
||||
// Should have called global slack agent as fallback
|
||||
expect(mockFetch).toHaveBeenCalledWith(
|
||||
'https://hooks.slack.com/services/global',
|
||||
expect.objectContaining({ method: 'POST' })
|
||||
);
|
||||
});
|
||||
|
||||
it('falls back to global agents when no stackName provided', async () => {
|
||||
mockGetEnabledNotificationRoutes.mockReturnValue([makeRoute()]);
|
||||
mockGetEnabledAgents.mockReturnValue([makeAgent()]);
|
||||
|
||||
await svc.dispatchAlert('warning', 'Host CPU high');
|
||||
|
||||
// Should have called global agent (no stackName means skip routing)
|
||||
expect(mockFetch).toHaveBeenCalledWith(
|
||||
'https://hooks.slack.com/services/global',
|
||||
expect.objectContaining({ method: 'POST' })
|
||||
);
|
||||
});
|
||||
|
||||
it('respects priority ordering — first match wins', async () => {
|
||||
mockGetEnabledNotificationRoutes.mockReturnValue([
|
||||
makeRoute({ id: 1, name: 'High priority', priority: 0, stack_patterns: ['my-app'], channel_url: 'https://discord.com/api/webhooks/first' }),
|
||||
makeRoute({ id: 2, name: 'Low priority', priority: 10, stack_patterns: ['my-app'], channel_url: 'https://discord.com/api/webhooks/second' }),
|
||||
]);
|
||||
mockGetEnabledAgents.mockReturnValue([]);
|
||||
|
||||
await svc.dispatchAlert('error', 'Test', 'my-app');
|
||||
|
||||
// Both routes match, both should be dispatched (all matching routes fire)
|
||||
expect(mockFetch).toHaveBeenCalledWith(
|
||||
'https://discord.com/api/webhooks/first',
|
||||
expect.objectContaining({ method: 'POST' })
|
||||
);
|
||||
expect(mockFetch).toHaveBeenCalledWith(
|
||||
'https://discord.com/api/webhooks/second',
|
||||
expect.objectContaining({ method: 'POST' })
|
||||
);
|
||||
// Global agents should still be skipped since routes matched
|
||||
expect(mockGetEnabledAgents).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('skips routes that do not match the stack', async () => {
|
||||
mockGetEnabledNotificationRoutes.mockReturnValue([
|
||||
makeRoute({ stack_patterns: ['staging-app'] }),
|
||||
]);
|
||||
mockGetEnabledAgents.mockReturnValue([makeAgent()]);
|
||||
|
||||
await svc.dispatchAlert('error', 'Test', 'production-app');
|
||||
|
||||
// Route should not fire
|
||||
expect(mockFetch).not.toHaveBeenCalledWith(
|
||||
'https://discord.com/api/webhooks/123/abc',
|
||||
expect.anything()
|
||||
);
|
||||
// Global agent should fire as fallback
|
||||
expect(mockFetch).toHaveBeenCalledWith(
|
||||
'https://hooks.slack.com/services/global',
|
||||
expect.objectContaining({ method: 'POST' })
|
||||
);
|
||||
});
|
||||
|
||||
it('handles multiple stack patterns in a single route', async () => {
|
||||
mockGetEnabledNotificationRoutes.mockReturnValue([
|
||||
makeRoute({ stack_patterns: ['app-a', 'app-b', 'app-c'] }),
|
||||
]);
|
||||
mockGetEnabledAgents.mockReturnValue([]);
|
||||
|
||||
await svc.dispatchAlert('info', 'Update complete', 'app-b');
|
||||
|
||||
expect(mockFetch).toHaveBeenCalledWith(
|
||||
'https://discord.com/api/webhooks/123/abc',
|
||||
expect.objectContaining({ method: 'POST' })
|
||||
);
|
||||
});
|
||||
|
||||
it('gracefully handles fetch errors in route dispatch without crashing', async () => {
|
||||
mockGetEnabledNotificationRoutes.mockReturnValue([makeRoute()]);
|
||||
mockFetch.mockRejectedValueOnce(new Error('Network timeout'));
|
||||
|
||||
// Should not throw
|
||||
await expect(svc.dispatchAlert('error', 'Crash', 'my-app')).resolves.toBeUndefined();
|
||||
});
|
||||
|
||||
it('does not dispatch to global agents when routes array is empty and no stackName', async () => {
|
||||
mockGetEnabledNotificationRoutes.mockReturnValue([]);
|
||||
mockGetEnabledAgents.mockReturnValue([]);
|
||||
|
||||
await svc.dispatchAlert('info', 'Test');
|
||||
|
||||
// No routes, no agents — just logs and broadcasts
|
||||
expect(mockFetch).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('always logs to history regardless of routing', async () => {
|
||||
mockGetEnabledNotificationRoutes.mockReturnValue([]);
|
||||
mockGetEnabledAgents.mockReturnValue([]);
|
||||
|
||||
await svc.dispatchAlert('info', 'Should be logged');
|
||||
|
||||
expect(mockAddNotificationHistory).toHaveBeenCalledWith({
|
||||
level: 'info',
|
||||
message: 'Should be logged',
|
||||
timestamp: expect.any(Number),
|
||||
});
|
||||
});
|
||||
|
||||
it('dispatches to slack channel type correctly via route', async () => {
|
||||
mockGetEnabledNotificationRoutes.mockReturnValue([
|
||||
makeRoute({ channel_type: 'slack', channel_url: 'https://hooks.slack.com/services/route-specific' }),
|
||||
]);
|
||||
|
||||
await svc.dispatchAlert('warning', 'Alert', 'my-app');
|
||||
|
||||
expect(mockFetch).toHaveBeenCalledWith(
|
||||
'https://hooks.slack.com/services/route-specific',
|
||||
expect.objectContaining({
|
||||
method: 'POST',
|
||||
body: expect.stringContaining('Alert'),
|
||||
})
|
||||
);
|
||||
});
|
||||
|
||||
it('dispatches to webhook channel type correctly via route', async () => {
|
||||
mockGetEnabledNotificationRoutes.mockReturnValue([
|
||||
makeRoute({ channel_type: 'webhook', channel_url: 'https://example.com/hook' }),
|
||||
]);
|
||||
|
||||
await svc.dispatchAlert('error', 'Critical failure', 'my-app');
|
||||
|
||||
expect(mockFetch).toHaveBeenCalledWith(
|
||||
'https://example.com/hook',
|
||||
expect.objectContaining({
|
||||
method: 'POST',
|
||||
body: expect.stringContaining('Critical failure'),
|
||||
})
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -583,7 +583,7 @@ describe('SchedulerService - error handling', () => {
|
||||
const svc = SchedulerService.getInstance();
|
||||
await svc.triggerTask(91);
|
||||
|
||||
expect(mockDispatchAlert).toHaveBeenCalledWith('error', expect.stringContaining('failed'));
|
||||
expect(mockDispatchAlert).toHaveBeenCalledWith('error', expect.stringContaining('failed'), undefined);
|
||||
});
|
||||
|
||||
it('dispatches recovery notification when previous status was failure', async () => {
|
||||
@@ -603,7 +603,7 @@ describe('SchedulerService - error handling', () => {
|
||||
const svc = SchedulerService.getInstance();
|
||||
await svc.triggerTask(92);
|
||||
|
||||
expect(mockDispatchAlert).toHaveBeenCalledWith('info', expect.stringContaining('recovered'));
|
||||
expect(mockDispatchAlert).toHaveBeenCalledWith('info', expect.stringContaining('recovered'), 'my-stack');
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -723,6 +723,9 @@ const AUDIT_ROUTE_SUMMARIES: Record<string, string> = {
|
||||
'POST /registries': 'Created registry credential',
|
||||
'PUT /registries': 'Updated registry credential',
|
||||
'DELETE /registries': 'Deleted registry credential',
|
||||
'POST /notification-routes': 'Created notification route',
|
||||
'PUT /notification-routes': 'Updated notification route',
|
||||
'DELETE /notification-routes': 'Deleted notification route',
|
||||
};
|
||||
|
||||
function getAuditSummary(method: string, apiPath: string): string {
|
||||
@@ -3631,6 +3634,143 @@ app.post('/api/notifications/test', authMiddleware, async (req: Request, res: Re
|
||||
}
|
||||
});
|
||||
|
||||
// --- Notification Routes (Admiral) ---
|
||||
|
||||
const NOTIFICATION_CHANNEL_TYPES = ['discord', 'slack', 'webhook'] as const;
|
||||
|
||||
app.get('/api/notification-routes', authMiddleware, (req: Request, res: Response): void => {
|
||||
if (!requireAdmin(req, res)) return;
|
||||
if (!requireAdmiral(req, res)) return;
|
||||
try {
|
||||
const routes = DatabaseService.getInstance().getNotificationRoutes();
|
||||
res.json(routes);
|
||||
} catch (error) {
|
||||
console.error('Failed to fetch notification routes:', error);
|
||||
res.status(500).json({ error: 'Failed to fetch notification routes' });
|
||||
}
|
||||
});
|
||||
|
||||
app.post('/api/notification-routes', authMiddleware, async (req: Request, res: Response): Promise<void> => {
|
||||
if (!requireAdmin(req, res)) return;
|
||||
if (!requireAdmiral(req, res)) return;
|
||||
try {
|
||||
const { name, stack_patterns, channel_type, channel_url, priority, enabled } = req.body;
|
||||
|
||||
if (!name || typeof name !== 'string' || !name.trim()) {
|
||||
res.status(400).json({ error: 'Name is required' });
|
||||
return;
|
||||
}
|
||||
if (!Array.isArray(stack_patterns) || stack_patterns.length === 0 || stack_patterns.some((p: unknown) => typeof p !== 'string' || !(p as string).trim())) {
|
||||
res.status(400).json({ error: 'stack_patterns must be a non-empty array of stack names' });
|
||||
return;
|
||||
}
|
||||
if (!NOTIFICATION_CHANNEL_TYPES.includes(channel_type)) {
|
||||
res.status(400).json({ error: 'channel_type must be discord, slack, or webhook' });
|
||||
return;
|
||||
}
|
||||
if (!channel_url || typeof channel_url !== 'string' || !channel_url.startsWith('https://')) {
|
||||
res.status(400).json({ error: 'channel_url must be a valid HTTPS URL' });
|
||||
return;
|
||||
}
|
||||
|
||||
const now = Date.now();
|
||||
const route = DatabaseService.getInstance().createNotificationRoute({
|
||||
name: name.trim(),
|
||||
stack_patterns: stack_patterns.map((p: string) => p.trim()),
|
||||
channel_type,
|
||||
channel_url: channel_url.trim(),
|
||||
priority: typeof priority === 'number' ? priority : 0,
|
||||
enabled: enabled !== false,
|
||||
created_at: now,
|
||||
updated_at: now,
|
||||
});
|
||||
res.status(201).json(route);
|
||||
} catch (error) {
|
||||
console.error('Failed to create notification route:', error);
|
||||
res.status(500).json({ error: 'Failed to create notification route' });
|
||||
}
|
||||
});
|
||||
|
||||
app.put('/api/notification-routes/:id', authMiddleware, async (req: Request, res: Response): Promise<void> => {
|
||||
if (!requireAdmin(req, res)) return;
|
||||
if (!requireAdmiral(req, res)) return;
|
||||
try {
|
||||
const id = parseInt(req.params.id as string, 10);
|
||||
if (isNaN(id)) { res.status(400).json({ error: 'Invalid route ID' }); return; }
|
||||
|
||||
const existing = DatabaseService.getInstance().getNotificationRoute(id);
|
||||
if (!existing) { res.status(404).json({ error: 'Route not found' }); return; }
|
||||
|
||||
const { name, stack_patterns, channel_type, channel_url, priority, enabled } = req.body;
|
||||
|
||||
if (name !== undefined && (typeof name !== 'string' || !name.trim())) {
|
||||
res.status(400).json({ error: 'Name must be a non-empty string' });
|
||||
return;
|
||||
}
|
||||
if (stack_patterns !== undefined && (!Array.isArray(stack_patterns) || stack_patterns.length === 0 || stack_patterns.some((p: unknown) => typeof p !== 'string' || !(p as string).trim()))) {
|
||||
res.status(400).json({ error: 'stack_patterns must be a non-empty array of stack names' });
|
||||
return;
|
||||
}
|
||||
if (channel_type !== undefined && !NOTIFICATION_CHANNEL_TYPES.includes(channel_type)) {
|
||||
res.status(400).json({ error: 'channel_type must be discord, slack, or webhook' });
|
||||
return;
|
||||
}
|
||||
if (channel_url !== undefined && (typeof channel_url !== 'string' || !channel_url.startsWith('https://'))) {
|
||||
res.status(400).json({ error: 'channel_url must be a valid HTTPS URL' });
|
||||
return;
|
||||
}
|
||||
|
||||
const updates: Record<string, unknown> = { updated_at: Date.now() };
|
||||
if (name !== undefined) updates.name = name.trim();
|
||||
if (stack_patterns !== undefined) updates.stack_patterns = stack_patterns.map((p: string) => p.trim());
|
||||
if (channel_type !== undefined) updates.channel_type = channel_type;
|
||||
if (channel_url !== undefined) updates.channel_url = channel_url.trim();
|
||||
if (priority !== undefined) updates.priority = priority;
|
||||
if (enabled !== undefined) updates.enabled = enabled;
|
||||
|
||||
DatabaseService.getInstance().updateNotificationRoute(id, updates);
|
||||
const updated = DatabaseService.getInstance().getNotificationRoute(id);
|
||||
res.json(updated);
|
||||
} catch (error) {
|
||||
console.error('Failed to update notification route:', error);
|
||||
res.status(500).json({ error: 'Failed to update notification route' });
|
||||
}
|
||||
});
|
||||
|
||||
app.delete('/api/notification-routes/:id', authMiddleware, (req: Request, res: Response): void => {
|
||||
if (!requireAdmin(req, res)) return;
|
||||
if (!requireAdmiral(req, res)) return;
|
||||
try {
|
||||
const id = parseInt(req.params.id as string, 10);
|
||||
if (isNaN(id)) { res.status(400).json({ error: 'Invalid route ID' }); return; }
|
||||
|
||||
const changes = DatabaseService.getInstance().deleteNotificationRoute(id);
|
||||
if (changes === 0) { res.status(404).json({ error: 'Route not found' }); return; }
|
||||
res.json({ success: true });
|
||||
} catch (error) {
|
||||
console.error('Failed to delete notification route:', error);
|
||||
res.status(500).json({ error: 'Failed to delete notification route' });
|
||||
}
|
||||
});
|
||||
|
||||
app.post('/api/notification-routes/:id/test', authMiddleware, async (req: Request, res: Response): Promise<void> => {
|
||||
if (!requireAdmin(req, res)) return;
|
||||
if (!requireAdmiral(req, res)) return;
|
||||
try {
|
||||
const id = parseInt(req.params.id as string, 10);
|
||||
if (isNaN(id)) { res.status(400).json({ error: 'Invalid route ID' }); return; }
|
||||
|
||||
const route = DatabaseService.getInstance().getNotificationRoute(id);
|
||||
if (!route) { res.status(404).json({ error: 'Route not found' }); return; }
|
||||
|
||||
await NotificationService.getInstance().testDispatch(route.channel_type, route.channel_url);
|
||||
res.json({ success: true });
|
||||
} catch (error: unknown) {
|
||||
const msg = error instanceof Error ? error.message : String(error);
|
||||
res.status(500).json({ error: 'Test failed', details: msg });
|
||||
}
|
||||
});
|
||||
|
||||
// Issue a short-lived console session token for WebSocket proxy delegation.
|
||||
// When the gateway needs to proxy an interactive terminal (host console or container exec)
|
||||
// to a remote node, it calls this endpoint (authenticated with the long-lived api_token)
|
||||
|
||||
@@ -202,6 +202,18 @@ export interface Registry {
|
||||
updated_at: number;
|
||||
}
|
||||
|
||||
export interface NotificationRoute {
|
||||
id: number;
|
||||
name: string;
|
||||
stack_patterns: string[];
|
||||
channel_type: 'discord' | 'slack' | 'webhook';
|
||||
channel_url: string;
|
||||
priority: number;
|
||||
enabled: boolean;
|
||||
created_at: number;
|
||||
updated_at: number;
|
||||
}
|
||||
|
||||
export class DatabaseService {
|
||||
private static instance: DatabaseService;
|
||||
private db: Database.Database;
|
||||
@@ -223,6 +235,7 @@ export class DatabaseService {
|
||||
this.migrateSSOColumns();
|
||||
this.migrateRegistries();
|
||||
this.migrateRoleAssignments();
|
||||
this.migrateNotificationRoutes();
|
||||
}
|
||||
|
||||
public static getInstance(): DatabaseService {
|
||||
@@ -609,6 +622,23 @@ export class DatabaseService {
|
||||
}
|
||||
}
|
||||
|
||||
private migrateNotificationRoutes(): void {
|
||||
this.db.exec(`
|
||||
CREATE TABLE IF NOT EXISTS notification_routes (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
name TEXT NOT NULL,
|
||||
stack_patterns TEXT NOT NULL,
|
||||
channel_type TEXT NOT NULL,
|
||||
channel_url TEXT NOT NULL,
|
||||
priority INTEGER NOT NULL DEFAULT 0,
|
||||
enabled INTEGER DEFAULT 1,
|
||||
created_at INTEGER NOT NULL,
|
||||
updated_at INTEGER NOT NULL
|
||||
);
|
||||
CREATE INDEX IF NOT EXISTS idx_notification_routes_priority ON notification_routes(priority);
|
||||
`);
|
||||
}
|
||||
|
||||
// --- Agents ---
|
||||
|
||||
public getAgents(): Agent[] {
|
||||
@@ -638,6 +668,76 @@ export class DatabaseService {
|
||||
}
|
||||
}
|
||||
|
||||
// --- Notification Routes ---
|
||||
|
||||
private parseNotificationRoute(row: Record<string, unknown>): NotificationRoute {
|
||||
return {
|
||||
id: row.id as number,
|
||||
name: row.name as string,
|
||||
stack_patterns: JSON.parse(row.stack_patterns as string) as string[],
|
||||
channel_type: row.channel_type as 'discord' | 'slack' | 'webhook',
|
||||
channel_url: row.channel_url as string,
|
||||
priority: row.priority as number,
|
||||
enabled: row.enabled === 1,
|
||||
created_at: row.created_at as number,
|
||||
updated_at: row.updated_at as number,
|
||||
};
|
||||
}
|
||||
|
||||
public getNotificationRoutes(): NotificationRoute[] {
|
||||
return this.db.prepare('SELECT * FROM notification_routes ORDER BY priority ASC')
|
||||
.all()
|
||||
.map((row) => this.parseNotificationRoute(row as Record<string, unknown>));
|
||||
}
|
||||
|
||||
public getEnabledNotificationRoutes(): NotificationRoute[] {
|
||||
return this.db.prepare('SELECT * FROM notification_routes WHERE enabled = 1 ORDER BY priority ASC')
|
||||
.all()
|
||||
.map((row) => this.parseNotificationRoute(row as Record<string, unknown>));
|
||||
}
|
||||
|
||||
public getNotificationRoute(id: number): NotificationRoute | undefined {
|
||||
const row = this.db.prepare('SELECT * FROM notification_routes WHERE id = ?').get(id) as Record<string, unknown> | undefined;
|
||||
return row ? this.parseNotificationRoute(row) : undefined;
|
||||
}
|
||||
|
||||
public createNotificationRoute(route: Omit<NotificationRoute, 'id'>): NotificationRoute {
|
||||
const result = this.db.prepare(
|
||||
'INSERT INTO notification_routes (name, stack_patterns, channel_type, channel_url, priority, enabled, created_at, updated_at) VALUES (?, ?, ?, ?, ?, ?, ?, ?)'
|
||||
).run(
|
||||
route.name,
|
||||
JSON.stringify(route.stack_patterns),
|
||||
route.channel_type,
|
||||
route.channel_url,
|
||||
route.priority,
|
||||
route.enabled ? 1 : 0,
|
||||
route.created_at,
|
||||
route.updated_at
|
||||
);
|
||||
return this.getNotificationRoute(result.lastInsertRowid as number)!;
|
||||
}
|
||||
|
||||
public updateNotificationRoute(id: number, updates: Partial<Omit<NotificationRoute, 'id' | 'created_at'>>): void {
|
||||
const fields: string[] = [];
|
||||
const values: unknown[] = [];
|
||||
|
||||
if (updates.name !== undefined) { fields.push('name = ?'); values.push(updates.name); }
|
||||
if (updates.stack_patterns !== undefined) { fields.push('stack_patterns = ?'); values.push(JSON.stringify(updates.stack_patterns)); }
|
||||
if (updates.channel_type !== undefined) { fields.push('channel_type = ?'); values.push(updates.channel_type); }
|
||||
if (updates.channel_url !== undefined) { fields.push('channel_url = ?'); values.push(updates.channel_url); }
|
||||
if (updates.priority !== undefined) { fields.push('priority = ?'); values.push(updates.priority); }
|
||||
if (updates.enabled !== undefined) { fields.push('enabled = ?'); values.push(updates.enabled ? 1 : 0); }
|
||||
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_routes SET ${fields.join(', ')} WHERE id = ?`).run(...values);
|
||||
}
|
||||
|
||||
public deleteNotificationRoute(id: number): number {
|
||||
return this.db.prepare('DELETE FROM notification_routes WHERE id = ?').run(id).changes;
|
||||
}
|
||||
|
||||
// --- Global Settings ---
|
||||
|
||||
public getGlobalSettings(): Record<string, string> {
|
||||
|
||||
@@ -128,17 +128,18 @@ export class MonitorService {
|
||||
const containers = await docker.getAllContainers();
|
||||
for (const c of containers) {
|
||||
if (c.State === 'exited' || String(c.Status).includes('unhealthy')) {
|
||||
const containerStack = c.Labels?.['com.docker.compose.project'] || undefined;
|
||||
if (c.State === 'exited') {
|
||||
if (c.Status.includes('seconds ago')) {
|
||||
const match = c.Status.match(/Exited \((\d+)\)/i);
|
||||
const exitCode = match ? parseInt(match[1], 10) : null;
|
||||
const intentionalExitCodes = [0, 137, 143, 255];
|
||||
if (exitCode !== null && !intentionalExitCodes.includes(exitCode)) {
|
||||
await notifier.dispatchAlert('error', `[Node: ${node.name}] Container Crash Detected: ${c.Names[0]} exited unexpectedly (Code: ${exitCode}).`);
|
||||
await notifier.dispatchAlert('error', `[Node: ${node.name}] Container Crash Detected: ${c.Names[0]} exited unexpectedly (Code: ${exitCode}).`, containerStack);
|
||||
}
|
||||
}
|
||||
} else if (String(c.Status).includes('unhealthy')) {
|
||||
await notifier.dispatchAlert('error', `[Node: ${node.name}] Healthcheck Failed: Container ${c.Names[0]} is unhealthy.`);
|
||||
await notifier.dispatchAlert('error', `[Node: ${node.name}] Healthcheck Failed: Container ${c.Names[0]} is unhealthy.`, containerStack);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -277,7 +278,8 @@ export class MonitorService {
|
||||
|
||||
await NotificationService.getInstance().dispatchAlert(
|
||||
'warning',
|
||||
message
|
||||
message,
|
||||
rule.stack_name
|
||||
);
|
||||
|
||||
// Update last fired
|
||||
|
||||
@@ -21,7 +21,7 @@ export class NotificationService {
|
||||
this.broadcaster = fn;
|
||||
}
|
||||
|
||||
public async dispatchAlert(level: 'info' | 'warning' | 'error', message: string) {
|
||||
public async dispatchAlert(level: 'info' | 'warning' | 'error', message: string, stackName?: string) {
|
||||
// 1. Log to history and get the full inserted record (with id)
|
||||
const notification = this.dbService.addNotificationHistory({
|
||||
level,
|
||||
@@ -34,33 +34,37 @@ export class NotificationService {
|
||||
this.broadcaster(notification);
|
||||
}
|
||||
|
||||
// 3. Fetch enabled agents
|
||||
// 3. Check notification routing rules if a stack context is available
|
||||
if (stackName) {
|
||||
const routes = this.dbService.getEnabledNotificationRoutes();
|
||||
const matched = routes.filter(r => r.stack_patterns.includes(stackName));
|
||||
if (matched.length > 0) {
|
||||
await Promise.allSettled(
|
||||
matched.map(route =>
|
||||
this.sendToChannel(route.channel_type, route.channel_url, level, message)
|
||||
.catch(error => console.error(`Failed to dispatch notification via route "${route.name}":`, error))
|
||||
)
|
||||
);
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
// 4. Fall back to global agents
|
||||
const agents = this.dbService.getEnabledAgents();
|
||||
if (agents.length === 0) {
|
||||
console.log('No active notification agents found. Skipping external dispatch.');
|
||||
return;
|
||||
}
|
||||
|
||||
// 3. Dispatch to each agent
|
||||
for (const agent of agents) {
|
||||
try {
|
||||
if (agent.type === 'discord') {
|
||||
await this.sendDiscordWebhook(agent.url, level, message);
|
||||
} else if (agent.type === 'slack') {
|
||||
await this.sendSlackWebhook(agent.url, level, message);
|
||||
} else if (agent.type === 'webhook') {
|
||||
await this.sendCustomWebhook(agent.url, level, message);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error(`Failed to dispatch notification to ${agent.type}:`, error);
|
||||
}
|
||||
}
|
||||
await Promise.allSettled(
|
||||
agents.map(agent =>
|
||||
this.sendToChannel(agent.type, agent.url, level, message)
|
||||
.catch(error => console.error(`Failed to dispatch notification to ${agent.type}:`, error))
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
public async testDispatch(type: 'discord' | 'slack' | 'webhook', url: string) {
|
||||
const level = 'info';
|
||||
const message = '🔌 Test Notification from Sencho!';
|
||||
|
||||
private async sendToChannel(type: string, url: string, level: 'info' | 'warning' | 'error', message: string): Promise<void> {
|
||||
if (type === 'discord') {
|
||||
await this.sendDiscordWebhook(url, level, message);
|
||||
} else if (type === 'slack') {
|
||||
@@ -70,6 +74,10 @@ export class NotificationService {
|
||||
}
|
||||
}
|
||||
|
||||
public async testDispatch(type: 'discord' | 'slack' | 'webhook', url: string) {
|
||||
await this.sendToChannel(type, url, 'info', '🔌 Test Notification from Sencho!');
|
||||
}
|
||||
|
||||
private async sendDiscordWebhook(url: string, level: 'info' | 'warning' | 'error', message: string) {
|
||||
const colorMap = {
|
||||
info: 3447003, // Blue
|
||||
|
||||
@@ -142,7 +142,8 @@ export class SchedulerService {
|
||||
if (task.last_status === 'failure') {
|
||||
NotificationService.getInstance().dispatchAlert(
|
||||
'info',
|
||||
`Scheduled task "${task.name}" (${task.action}) recovered successfully`
|
||||
`Scheduled task "${task.name}" (${task.action}) recovered successfully`,
|
||||
task.target_id ?? undefined
|
||||
);
|
||||
}
|
||||
} catch (error: unknown) {
|
||||
@@ -168,7 +169,8 @@ export class SchedulerService {
|
||||
console.error(`[SchedulerService] Task "${task.name}" (id=${task.id}) failed:`, errMsg);
|
||||
NotificationService.getInstance().dispatchAlert(
|
||||
'error',
|
||||
`Scheduled task "${task.name}" (${task.action}) failed: ${errMsg}`
|
||||
`Scheduled task "${task.name}" (${task.action}) failed: ${errMsg}`,
|
||||
task.target_id ?? undefined
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -444,7 +446,8 @@ export class SchedulerService {
|
||||
|
||||
NotificationService.getInstance().dispatchAlert(
|
||||
'info',
|
||||
`Auto-update: stack "${stackName}" updated with new images`
|
||||
`Auto-update: stack "${stackName}" updated with new images`,
|
||||
stackName
|
||||
);
|
||||
|
||||
return `Stack "${stackName}": updated (${updatedImages.join(', ')}).`;
|
||||
|
||||
Binary file not shown.
|
Before Width: | Height: | Size: 419 KiB |
@@ -105,6 +105,7 @@
|
||||
"features/fleet-view",
|
||||
"features/stack-labels",
|
||||
"features/alerts-notifications",
|
||||
"features/notification-routing",
|
||||
"features/webhooks",
|
||||
"features/rbac",
|
||||
"features/atomic-deployments",
|
||||
|
||||
@@ -0,0 +1,95 @@
|
||||
---
|
||||
title: Notification Routing
|
||||
description: Route stack alerts to specific Discord, Slack, or webhook channels with per-stack routing rules.
|
||||
---
|
||||
|
||||
<Note>
|
||||
Notification Routing requires a **Sencho Admiral** license. Community and Skipper users can configure global notification channels in **Settings → Notifications**.
|
||||
</Note>
|
||||
|
||||
Notification Routing lets you direct alerts from specific stacks to dedicated channels. Instead of all alerts going to a single global endpoint, you can send production alerts to one Slack channel and staging alerts to another.
|
||||
|
||||
<Frame>
|
||||
<img src="/images/notification-routing/notification-routing-overview.png" alt="Notification Routing settings with a configured route" />
|
||||
</Frame>
|
||||
|
||||
## How routing works
|
||||
|
||||
When Sencho dispatches an alert (container crash, threshold breach, scheduled task failure), the routing engine:
|
||||
|
||||
1. **Checks routing rules** — sorted by priority (lowest number first). If the alert's stack matches a rule's stack list, the notification is sent to that rule's channel.
|
||||
2. **Falls back to global agents** — if no routing rule matches (or the alert has no stack context, such as host resource warnings), the notification goes to the global channels configured in **Settings → Notifications**.
|
||||
|
||||
Routing rules and global agents are independent — a matched route **replaces** the global dispatch for that alert, it does not send to both.
|
||||
|
||||
## Setting up routing rules
|
||||
|
||||
1. Go to **Settings → Routing** (requires admin and Admiral tier)
|
||||
2. Click **Add Route**
|
||||
3. Fill in:
|
||||
|
||||
| Field | Description |
|
||||
|-------|-------------|
|
||||
| **Name** | A label for this rule (e.g., "Production to Discord") |
|
||||
| **Stacks** | One or more stacks this rule applies to. Use the dropdown to search and select. |
|
||||
| **Channel** | Choose Discord, Slack, or Webhook and provide the endpoint URL |
|
||||
| **Priority** | Lower values are evaluated first. Default is 0. |
|
||||
| **Enabled** | Toggle the rule on or off without deleting it |
|
||||
|
||||
4. Click **Create** to save the rule
|
||||
|
||||
<Frame>
|
||||
<img src="/images/notification-routing/notification-routing-dialog.png" alt="New Routing Rule dialog with stack selection" />
|
||||
</Frame>
|
||||
|
||||
## Managing rules
|
||||
|
||||
From the routing rules list, you can:
|
||||
|
||||
- **Toggle** a rule on/off with the switch
|
||||
- **Test** a rule by clicking the lightning bolt icon — sends a test notification to the rule's channel
|
||||
- **Edit** a rule by clicking the pencil icon
|
||||
- **Delete** a rule via the trash icon (with confirmation)
|
||||
|
||||
## Priority and matching
|
||||
|
||||
Rules are evaluated in ascending priority order. If multiple rules match the same stack, **all matching rules fire** — this allows you to send the same alert to multiple channels (e.g., both a Slack channel and a custom webhook).
|
||||
|
||||
If any rule matches, global agents are skipped for that alert.
|
||||
|
||||
## Fallback behavior
|
||||
|
||||
Alerts without a stack context always use global agents. These include:
|
||||
|
||||
- Host CPU, memory, and disk threshold warnings
|
||||
- Docker data accumulation (janitor) notifications
|
||||
|
||||
Stack-scoped alerts that don't match any routing rule also fall back to global agents.
|
||||
|
||||
## Example setup
|
||||
|
||||
**Scenario:** You want production stack crashes in `#prod-alerts` on Slack, and all staging stacks in a Discord channel.
|
||||
|
||||
| Rule | Stacks | Channel | Priority |
|
||||
|------|--------|---------|----------|
|
||||
| Prod to Slack | `prod-api`, `prod-web` | Slack: `https://hooks.slack.com/...` | 0 |
|
||||
| Staging to Discord | `staging-api`, `staging-web` | Discord: `https://discord.com/api/webhooks/...` | 10 |
|
||||
|
||||
Any other stack alerts (e.g., `dev-tools`) would fall back to your global notification settings.
|
||||
|
||||
## Troubleshooting
|
||||
|
||||
<AccordionGroup>
|
||||
<Accordion title="I created a route but alerts still go to global channels">
|
||||
Verify the **stack name** in the route matches exactly. Stack names are case-sensitive and correspond to the Docker Compose project name (the directory name). Check that the route is **enabled**.
|
||||
</Accordion>
|
||||
<Accordion title="Test notification works but real alerts don't arrive">
|
||||
Real alerts are dispatched by the monitoring service, which evaluates every 30 seconds. Make sure you have at least one alert rule or crash detection enabled. Also verify the stack name in your routing rule matches the stack that triggers the alert.
|
||||
</Accordion>
|
||||
<Accordion title="I see 'This feature requires Sencho Admiral' when opening Routing">
|
||||
Notification Routing is an Admiral-tier feature. Activate an Admiral license in **Settings → License** or visit [sencho.io/pricing](https://sencho.io/pricing) to upgrade.
|
||||
</Accordion>
|
||||
<Accordion title="Can I route host-level alerts (CPU/disk) to a specific channel?">
|
||||
Not currently. Host resource alerts don't have a stack context and always use global notification agents. Routing rules only apply to stack-scoped alerts.
|
||||
</Accordion>
|
||||
</AccordionGroup>
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 23 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 41 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 41 KiB |
@@ -12,7 +12,7 @@ import { toast } from '@/components/ui/toast-store';
|
||||
import { apiFetch } from '@/lib/api';
|
||||
import {
|
||||
Shield, Activity, Bell, Code, Server, Package,
|
||||
Info, Crown, Webhook, Users, Zap, Database, LifeBuoy, Lock, Tag,
|
||||
Info, Crown, Webhook, Users, Zap, Database, LifeBuoy, Lock, Tag, GitBranch,
|
||||
} from 'lucide-react';
|
||||
import { NodeManager } from './NodeManager';
|
||||
import { useNodes } from '@/context/NodeContext';
|
||||
@@ -27,6 +27,7 @@ import {
|
||||
UsersSection,
|
||||
SystemSection,
|
||||
NotificationsSection,
|
||||
NotificationRoutingSection,
|
||||
WebhooksSection,
|
||||
DeveloperSection,
|
||||
AppStoreSection,
|
||||
@@ -56,7 +57,7 @@ export function SettingsModal({ isOpen, onClose, initialSection }: SettingsModal
|
||||
|
||||
// When switching to a remote node, reset to a node-scoped section if on a global-only one
|
||||
useEffect(() => {
|
||||
if (isRemote && (activeSection === 'account' || activeSection === 'license' || activeSection === 'users' || activeSection === 'sso' || activeSection === 'api-tokens' || activeSection === 'registries' || activeSection === 'labels' || activeSection === 'notifications' || activeSection === 'webhooks' || activeSection === 'nodes' || activeSection === 'appstore')) {
|
||||
if (isRemote && (activeSection === 'account' || activeSection === 'license' || activeSection === 'users' || activeSection === 'sso' || activeSection === 'api-tokens' || activeSection === 'registries' || activeSection === 'labels' || activeSection === 'notifications' || activeSection === 'notification-routing' || activeSection === 'webhooks' || activeSection === 'nodes' || activeSection === 'appstore')) {
|
||||
setActiveSection('system');
|
||||
}
|
||||
}, [isRemote]); // eslint-disable-line react-hooks/exhaustive-deps
|
||||
@@ -269,6 +270,8 @@ export function SettingsModal({ isOpen, onClose, initialSection }: SettingsModal
|
||||
);
|
||||
case 'notifications':
|
||||
return <NotificationsSection />;
|
||||
case 'notification-routing':
|
||||
return <NotificationRoutingSection />;
|
||||
case 'webhooks':
|
||||
return <WebhooksSection isPro={isPro} />;
|
||||
case 'developer':
|
||||
@@ -354,6 +357,9 @@ export function SettingsModal({ isOpen, onClose, initialSection }: SettingsModal
|
||||
showDot={hasSystemChanges}
|
||||
/>
|
||||
<NavButton section="notifications" icon={<Bell className="w-4 h-4 mr-2" />} label="Notifications" />
|
||||
{!isRemote && isAdmin && (
|
||||
<NavButton section="notification-routing" icon={<GitBranch className="w-4 h-4 mr-2" />} label="Routing" locked={!isTeamPro} />
|
||||
)}
|
||||
{!isRemote && (
|
||||
<NavButton section="webhooks" icon={<Webhook className="w-4 h-4 mr-2" />} label="Webhooks" locked={!isPro} />
|
||||
)}
|
||||
|
||||
@@ -0,0 +1,457 @@
|
||||
import { useState, useEffect, useCallback } from 'react';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Input } from '@/components/ui/input';
|
||||
import { Label } from '@/components/ui/label';
|
||||
import { Switch } from '@/components/ui/switch';
|
||||
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 { Tabs, TabsList, TabsTrigger, TabsHighlight, TabsHighlightItem } from '@/components/ui/tabs';
|
||||
import { springs } from '@/lib/motion';
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogTitle,
|
||||
DialogDescription,
|
||||
} from '@/components/ui/dialog';
|
||||
import {
|
||||
AlertDialog,
|
||||
AlertDialogAction,
|
||||
AlertDialogCancel,
|
||||
AlertDialogContent,
|
||||
AlertDialogDescription,
|
||||
AlertDialogFooter,
|
||||
AlertDialogHeader,
|
||||
AlertDialogTitle,
|
||||
AlertDialogTrigger,
|
||||
} from '@/components/ui/alert-dialog';
|
||||
import { toast } from '@/components/ui/toast-store';
|
||||
import { apiFetch } from '@/lib/api';
|
||||
import { AdmiralGate } from '@/components/AdmiralGate';
|
||||
import { TierBadge } from '@/components/TierBadge';
|
||||
import { Plus, Trash2, Pencil, RefreshCw, Zap, X, GitBranch } from 'lucide-react';
|
||||
|
||||
interface NotificationRoute {
|
||||
id: number;
|
||||
name: string;
|
||||
stack_patterns: string[];
|
||||
channel_type: 'discord' | 'slack' | 'webhook';
|
||||
channel_url: string;
|
||||
priority: number;
|
||||
enabled: boolean;
|
||||
created_at: number;
|
||||
updated_at: number;
|
||||
}
|
||||
|
||||
const CHANNEL_LABELS: Record<string, string> = {
|
||||
discord: 'Discord',
|
||||
slack: 'Slack',
|
||||
webhook: 'Webhook',
|
||||
};
|
||||
|
||||
const CHANNEL_PLACEHOLDERS: Record<string, string> = {
|
||||
discord: 'https://discord.com/api/webhooks/...',
|
||||
slack: 'https://hooks.slack.com/services/...',
|
||||
webhook: 'https://example.com/webhook',
|
||||
};
|
||||
|
||||
export function NotificationRoutingSection() {
|
||||
const [routes, setRoutes] = useState<NotificationRoute[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [saving, setSaving] = useState(false);
|
||||
const [showForm, setShowForm] = useState(false);
|
||||
const [editingId, setEditingId] = useState<number | null>(null);
|
||||
const [testingId, setTestingId] = useState<number | null>(null);
|
||||
const [stackOptions, setStackOptions] = useState<ComboboxOption[]>([]);
|
||||
|
||||
// Form state
|
||||
const [formName, setFormName] = useState('');
|
||||
const [formStacks, setFormStacks] = useState<string[]>([]);
|
||||
const [formChannelType, setFormChannelType] = useState<'discord' | 'slack' | 'webhook'>('discord');
|
||||
const [formChannelUrl, setFormChannelUrl] = useState('');
|
||||
const [formPriority, setFormPriority] = useState(0);
|
||||
const [formEnabled, setFormEnabled] = useState(true);
|
||||
|
||||
const fetchRoutes = useCallback(async () => {
|
||||
try {
|
||||
const res = await apiFetch('/notification-routes');
|
||||
if (res.ok) {
|
||||
setRoutes(await res.json());
|
||||
}
|
||||
} catch {
|
||||
toast.error('Failed to load notification routes.');
|
||||
} 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 {
|
||||
// Stacks may fail on remote nodes, non-critical
|
||||
}
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
fetchRoutes();
|
||||
fetchStacks();
|
||||
}, [fetchRoutes, fetchStacks]);
|
||||
|
||||
const resetForm = () => {
|
||||
setFormName('');
|
||||
setFormStacks([]);
|
||||
setFormChannelType('discord');
|
||||
setFormChannelUrl('');
|
||||
setFormPriority(0);
|
||||
setFormEnabled(true);
|
||||
setEditingId(null);
|
||||
setShowForm(false);
|
||||
};
|
||||
|
||||
const startEdit = (route: NotificationRoute) => {
|
||||
setEditingId(route.id);
|
||||
setFormName(route.name);
|
||||
setFormStacks([...route.stack_patterns]);
|
||||
setFormChannelType(route.channel_type);
|
||||
setFormChannelUrl(route.channel_url);
|
||||
setFormPriority(route.priority);
|
||||
setFormEnabled(route.enabled);
|
||||
setShowForm(true);
|
||||
};
|
||||
|
||||
const handleSave = async () => {
|
||||
if (!formName.trim()) { toast.error('Name is required.'); return; }
|
||||
if (formStacks.length === 0) { toast.error('At least one stack must be selected.'); return; }
|
||||
if (!formChannelUrl.trim() || !formChannelUrl.startsWith('https://')) {
|
||||
toast.error('Channel URL must be a valid HTTPS URL.');
|
||||
return;
|
||||
}
|
||||
|
||||
setSaving(true);
|
||||
try {
|
||||
const body = {
|
||||
name: formName.trim(),
|
||||
stack_patterns: formStacks,
|
||||
channel_type: formChannelType,
|
||||
channel_url: formChannelUrl.trim(),
|
||||
priority: formPriority,
|
||||
enabled: formEnabled,
|
||||
};
|
||||
|
||||
const url = editingId ? `/notification-routes/${editingId}` : '/notification-routes';
|
||||
const method = editingId ? 'PUT' : 'POST';
|
||||
|
||||
const res = await apiFetch(url, {
|
||||
method,
|
||||
body: JSON.stringify(body),
|
||||
});
|
||||
|
||||
if (res.ok) {
|
||||
toast.success(editingId ? 'Route updated.' : 'Route created.');
|
||||
resetForm();
|
||||
fetchRoutes();
|
||||
} else {
|
||||
const err = await res.json().catch(() => ({}));
|
||||
toast.error(err?.error || err?.message || err?.data?.error || 'Something went wrong.');
|
||||
}
|
||||
} catch (e: unknown) {
|
||||
toast.error((e as Error)?.message || 'Network error.');
|
||||
} finally {
|
||||
setSaving(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleDelete = async (id: number) => {
|
||||
try {
|
||||
const res = await apiFetch(`/notification-routes/${id}`, { method: 'DELETE' });
|
||||
if (res.ok) {
|
||||
toast.success('Route deleted.');
|
||||
fetchRoutes();
|
||||
} else {
|
||||
const err = await res.json().catch(() => ({}));
|
||||
toast.error(err?.error || err?.message || err?.data?.error || 'Something went wrong.');
|
||||
}
|
||||
} catch {
|
||||
toast.error('Network error.');
|
||||
}
|
||||
};
|
||||
|
||||
const handleTest = async (id: number) => {
|
||||
setTestingId(id);
|
||||
try {
|
||||
const res = await apiFetch(`/notification-routes/${id}/test`, { method: 'POST' });
|
||||
if (res.ok) {
|
||||
toast.success('Test notification sent!');
|
||||
} else {
|
||||
const err = await res.json().catch(() => ({}));
|
||||
toast.error(err?.details || err?.error || 'Test failed.');
|
||||
}
|
||||
} catch {
|
||||
toast.error('Network error.');
|
||||
} finally {
|
||||
setTestingId(null);
|
||||
}
|
||||
};
|
||||
|
||||
const handleToggleEnabled = async (route: NotificationRoute) => {
|
||||
try {
|
||||
const res = await apiFetch(`/notification-routes/${route.id}`, {
|
||||
method: 'PUT',
|
||||
body: JSON.stringify({ enabled: !route.enabled }),
|
||||
});
|
||||
if (res.ok) {
|
||||
fetchRoutes();
|
||||
} else {
|
||||
const err = await res.json().catch(() => ({}));
|
||||
toast.error(err?.error || err?.message || err?.data?.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 availableStackOptions = stackOptions.filter(o => !formStacks.includes(o.value));
|
||||
|
||||
return (
|
||||
<AdmiralGate featureName="Notification Routing">
|
||||
<div className="space-y-6">
|
||||
<div className="flex items-start justify-between pr-8">
|
||||
<div>
|
||||
<h3 className="text-lg font-medium tracking-tight flex items-center gap-2">
|
||||
Notification Routing <TierBadge />
|
||||
</h3>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
Route stack alerts to specific channels instead of your global notification endpoints.
|
||||
</p>
|
||||
</div>
|
||||
<Button size="sm" onClick={() => { resetForm(); setShowForm(true); }}>
|
||||
<Plus className="w-4 h-4 mr-1.5" /> Add Route
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<Dialog open={showForm} onOpenChange={(open) => { if (!open) resetForm(); }}>
|
||||
<DialogContent className="sm:max-w-[500px]">
|
||||
<DialogTitle>{editingId ? 'Edit Route' : 'New Routing Rule'}</DialogTitle>
|
||||
<DialogDescription className="sr-only">
|
||||
{editingId ? 'Edit a notification routing rule' : 'Create a notification routing rule'}
|
||||
</DialogDescription>
|
||||
|
||||
<div className="space-y-4 pt-2">
|
||||
<div className="space-y-2">
|
||||
<Label>Name</Label>
|
||||
<Input
|
||||
placeholder="e.g. Production alerts"
|
||||
value={formName}
|
||||
onChange={e => setFormName(e.target.value)}
|
||||
maxLength={100}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label>Stacks</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>Channel</Label>
|
||||
<Tabs value={formChannelType} onValueChange={(v) => setFormChannelType(v as 'discord' | 'slack' | 'webhook')}>
|
||||
<TabsList className="w-full grid grid-cols-3">
|
||||
<TabsHighlight className="rounded-md bg-glass-highlight" transition={springs.snappy}>
|
||||
<TabsHighlightItem value="discord">
|
||||
<TabsTrigger value="discord">Discord</TabsTrigger>
|
||||
</TabsHighlightItem>
|
||||
<TabsHighlightItem value="slack">
|
||||
<TabsTrigger value="slack">Slack</TabsTrigger>
|
||||
</TabsHighlightItem>
|
||||
<TabsHighlightItem value="webhook">
|
||||
<TabsTrigger value="webhook">Webhook</TabsTrigger>
|
||||
</TabsHighlightItem>
|
||||
</TabsHighlight>
|
||||
</TabsList>
|
||||
</Tabs>
|
||||
<Input
|
||||
placeholder={CHANNEL_PLACEHOLDERS[formChannelType]}
|
||||
value={formChannelUrl}
|
||||
onChange={e => setFormChannelUrl(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<div className="space-y-2">
|
||||
<Label>Priority</Label>
|
||||
<Input
|
||||
type="number"
|
||||
min={0}
|
||||
value={formPriority}
|
||||
onChange={e => setFormPriority(parseInt(e.target.value, 10) || 0)}
|
||||
/>
|
||||
<p className="text-xs text-muted-foreground">Lower values are evaluated first.</p>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label>Enabled</Label>
|
||||
<div className="pt-2">
|
||||
<Switch checked={formEnabled} onCheckedChange={setFormEnabled} />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex justify-end gap-2 pt-2">
|
||||
<Button variant="outline" size="sm" onClick={resetForm}>Cancel</Button>
|
||||
<Button size="sm" onClick={handleSave} disabled={saving}>
|
||||
{saving ? <><RefreshCw className="w-4 h-4 mr-1.5 animate-spin" />Saving...</> : editingId ? 'Update' : 'Create'}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
|
||||
{loading && (
|
||||
<div className="space-y-3">
|
||||
<Skeleton className="h-20 w-full rounded-xl" />
|
||||
<Skeleton className="h-20 w-full rounded-xl" />
|
||||
</div>
|
||||
)}
|
||||
|
||||
{!loading && routes.length === 0 && (
|
||||
<div className="flex flex-col items-center justify-center py-12 text-center">
|
||||
<GitBranch className="w-10 h-10 text-muted-foreground/50 mb-3" strokeWidth={1.5} />
|
||||
<p className="text-sm text-muted-foreground">No routing rules configured.</p>
|
||||
<p className="text-xs text-muted-foreground mt-1">
|
||||
Alerts will use your global notification channels. Add a route to direct specific stack alerts to dedicated channels.
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{!loading && routes.map(route => (
|
||||
<div
|
||||
key={route.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">
|
||||
<div className="flex items-center gap-2 min-w-0">
|
||||
<GitBranch className="w-4 h-4 text-muted-foreground shrink-0" strokeWidth={1.5} />
|
||||
<span className="font-medium text-sm truncate">{route.name}</span>
|
||||
<Badge variant="outline" className="text-[10px] shrink-0">
|
||||
{CHANNEL_LABELS[route.channel_type]}
|
||||
</Badge>
|
||||
{!route.enabled && (
|
||||
<Badge variant="secondary" className="text-[10px] shrink-0 text-muted-foreground">
|
||||
Disabled
|
||||
</Badge>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex items-center gap-1 shrink-0">
|
||||
<Switch
|
||||
checked={route.enabled}
|
||||
onCheckedChange={() => handleToggleEnabled(route)}
|
||||
className="scale-75"
|
||||
/>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={() => handleTest(route.id)}
|
||||
disabled={testingId === route.id}
|
||||
title="Send test notification"
|
||||
>
|
||||
{testingId === route.id ? (
|
||||
<RefreshCw className="w-4 h-4 animate-spin" />
|
||||
) : (
|
||||
<Zap className="w-4 h-4" strokeWidth={1.5} />
|
||||
)}
|
||||
</Button>
|
||||
<Button variant="ghost" size="sm" onClick={() => startEdit(route)} title="Edit">
|
||||
<Pencil className="w-4 h-4" strokeWidth={1.5} />
|
||||
</Button>
|
||||
<AlertDialog>
|
||||
<AlertDialogTrigger asChild>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="text-destructive/60 hover:bg-destructive hover:text-destructive-foreground"
|
||||
title="Delete"
|
||||
>
|
||||
<Trash2 className="w-4 h-4" strokeWidth={1.5} />
|
||||
</Button>
|
||||
</AlertDialogTrigger>
|
||||
<AlertDialogContent>
|
||||
<AlertDialogHeader>
|
||||
<AlertDialogTitle>Delete routing rule?</AlertDialogTitle>
|
||||
<AlertDialogDescription>
|
||||
Deleting <strong>{route.name}</strong> will remove this routing rule. Alerts for the associated stacks will fall back to your global notification channels.
|
||||
</AlertDialogDescription>
|
||||
</AlertDialogHeader>
|
||||
<AlertDialogFooter>
|
||||
<AlertDialogCancel>Cancel</AlertDialogCancel>
|
||||
<AlertDialogAction
|
||||
onClick={() => handleDelete(route.id)}
|
||||
className="bg-destructive text-destructive-foreground hover:bg-destructive/90"
|
||||
>
|
||||
Delete
|
||||
</AlertDialogAction>
|
||||
</AlertDialogFooter>
|
||||
</AlertDialogContent>
|
||||
</AlertDialog>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center gap-3 text-xs text-muted-foreground">
|
||||
<div className="flex flex-wrap gap-1">
|
||||
{route.stack_patterns.map(s => (
|
||||
<Badge key={s} variant="secondary" className="font-mono text-[10px]">{s}</Badge>
|
||||
))}
|
||||
</div>
|
||||
<span className="text-muted-foreground/50">|</span>
|
||||
<span className="font-mono truncate max-w-[200px]" title={route.channel_url}>
|
||||
{route.channel_url}
|
||||
</span>
|
||||
{route.priority !== 0 && (
|
||||
<>
|
||||
<span className="text-muted-foreground/50">|</span>
|
||||
<span className="tabular-nums">Priority: {route.priority}</span>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</AdmiralGate>
|
||||
);
|
||||
}
|
||||
@@ -9,5 +9,6 @@ export { AppStoreSection } from './AppStoreSection';
|
||||
export { SupportSection } from './SupportSection';
|
||||
export { AboutSection } from './AboutSection';
|
||||
export { LabelsSection } from './LabelsSection';
|
||||
export { NotificationRoutingSection } from './NotificationRoutingSection';
|
||||
export { DEFAULT_SETTINGS } from './types';
|
||||
export type { PatchableSettings, SectionId, Agent } from './types';
|
||||
|
||||
@@ -40,6 +40,7 @@ export type SectionId =
|
||||
| 'developer'
|
||||
| 'nodes'
|
||||
| 'appstore'
|
||||
| 'notification-routing'
|
||||
| 'support'
|
||||
| 'about';
|
||||
|
||||
|
||||
Binary file not shown.
|
Before Width: | Height: | Size: 156 KiB |
Binary file not shown.
|
Before Width: | Height: | Size: 176 KiB |
Reference in New Issue
Block a user