fix(notifications): scope routing rules to nodes via node_id column (#775)

Adds a nullable node_id column to notification_routes (null = any
node, integer = fire only when the alert originates from that specific
node). This fixes a multi-node fleet defect where a route scoped to
"my-app" would fire on every node that hosts a stack with that name.

Backend changes:
- DatabaseService: idempotent migration adds node_id INTEGER NULL and
  a composite index on (node_id, enabled, priority); the two statements
  are in separate try-catch blocks so the index is always created even
  when the column was added in an earlier run
- NotificationService: route matcher now pre-filters by node_id before
  checking stack_patterns (== null matches any node)
- notifications route: POST/PUT accept optional node_id, validated to
  be null or the local node's ID; NodeRegistry guards against
  cross-node misroutes

Frontend changes:
- NotificationRoutingSection: node scope Select field uses useNodes()
  from NodeContext (no extra API call) to populate the local node option
- Route cards show a node badge when node_id is set

Tests: 3 new tests covering node-match, node-mismatch, and null-scope;
all 75 files (1413 tests) passing.
This commit is contained in:
Anso
2026-04-25 13:56:48 -04:00
committed by GitHub
parent 44dba59cab
commit fcbdd59ec2
5 changed files with 124 additions and 5 deletions
@@ -58,6 +58,7 @@ function makeRoute(overrides: Record<string, unknown> = {}) {
return {
id: 1,
name: 'Prod Discord',
node_id: null as number | null,
stack_patterns: ['my-app'],
channel_type: 'discord' as const,
channel_url: 'https://discord.com/api/webhooks/123/abc',
@@ -312,4 +313,48 @@ describe('NotificationService - routing logic', () => {
expect(mockUpdateNotificationDispatchError).not.toHaveBeenCalled();
});
it('fires a node-scoped route when node_id matches the local node', async () => {
// getDefaultNodeId returns 1 (mocked above)
mockGetEnabledNotificationRoutes.mockReturnValue([makeRoute({ node_id: 1 })]);
mockGetEnabledAgents.mockReturnValue([]);
await svc.dispatchAlert('info', 'Test', 'my-app');
expect(mockFetch).toHaveBeenCalledWith(
'https://discord.com/api/webhooks/123/abc',
expect.objectContaining({ method: 'POST' })
);
});
it('skips a node-scoped route when node_id does not match the local node', async () => {
// getDefaultNodeId returns 1; route is scoped to node 99
mockGetEnabledNotificationRoutes.mockReturnValue([makeRoute({ node_id: 99 })]);
mockGetEnabledAgents.mockReturnValue([makeAgent()]);
await svc.dispatchAlert('info', 'Test', 'my-app');
// Route should be skipped; falls back to global agent
expect(mockFetch).not.toHaveBeenCalledWith(
'https://discord.com/api/webhooks/123/abc',
expect.anything()
);
expect(mockFetch).toHaveBeenCalledWith(
'https://hooks.slack.com/services/global',
expect.objectContaining({ method: 'POST' })
);
});
it('fires a null-scoped route regardless of which node emits the alert', async () => {
// node_id=null means "any node"
mockGetEnabledNotificationRoutes.mockReturnValue([makeRoute({ node_id: null })]);
mockGetEnabledAgents.mockReturnValue([]);
await svc.dispatchAlert('warning', 'Global alert', 'my-app');
expect(mockFetch).toHaveBeenCalledWith(
'https://discord.com/api/webhooks/123/abc',
expect.objectContaining({ method: 'POST' })
);
});
});