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
+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