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:
Anso
2026-04-02 22:22:27 -04:00
committed by GitHub
parent 08c541ecaf
commit 1b573f542a
22 changed files with 1102 additions and 33 deletions
+100
View File
@@ -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> {
+5 -3
View File
@@ -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
+28 -20
View File
@@ -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
+6 -3
View File
@@ -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(', ')}).`;