mirror of
https://github.com/Studio-Saelix/sencho.git
synced 2026-08-27 18:57:09 +00:00
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:
@@ -58,6 +58,7 @@ function makeRoute(overrides: Record<string, unknown> = {}) {
|
|||||||
return {
|
return {
|
||||||
id: 1,
|
id: 1,
|
||||||
name: 'Prod Discord',
|
name: 'Prod Discord',
|
||||||
|
node_id: null as number | null,
|
||||||
stack_patterns: ['my-app'],
|
stack_patterns: ['my-app'],
|
||||||
channel_type: 'discord' as const,
|
channel_type: 'discord' as const,
|
||||||
channel_url: 'https://discord.com/api/webhooks/123/abc',
|
channel_url: 'https://discord.com/api/webhooks/123/abc',
|
||||||
@@ -312,4 +313,48 @@ describe('NotificationService - routing logic', () => {
|
|||||||
|
|
||||||
expect(mockUpdateNotificationDispatchError).not.toHaveBeenCalled();
|
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' })
|
||||||
|
);
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import { Router, type Request, type Response } from 'express';
|
import { Router, type Request, type Response } from 'express';
|
||||||
import { DatabaseService } from '../services/DatabaseService';
|
import { DatabaseService } from '../services/DatabaseService';
|
||||||
import { NotificationService } from '../services/NotificationService';
|
import { NotificationService } from '../services/NotificationService';
|
||||||
|
import { NodeRegistry } from '../services/NodeRegistry';
|
||||||
import { authMiddleware } from '../middleware/auth';
|
import { authMiddleware } from '../middleware/auth';
|
||||||
import { requireAdmin, requireAdmiral } from '../middleware/tierGates';
|
import { requireAdmin, requireAdmiral } from '../middleware/tierGates';
|
||||||
import {
|
import {
|
||||||
@@ -20,6 +21,20 @@ function parseRouteId(req: Request, res: Response): number | null {
|
|||||||
return id;
|
return id;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function validateNodeId(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 localNodeId = NodeRegistry.getInstance().getDefaultNodeId();
|
||||||
|
if (nodeId !== localNodeId) {
|
||||||
|
res.status(400).json({ error: 'node_id must match the local node or be null' });
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
return nodeId;
|
||||||
|
}
|
||||||
|
|
||||||
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> => {
|
||||||
@@ -100,7 +115,7 @@ notificationRoutesRouter.post('/', authMiddleware, async (req: Request, res: Res
|
|||||||
if (!requireAdmin(req, res)) return;
|
if (!requireAdmin(req, res)) return;
|
||||||
if (!requireAdmiral(req, res)) return;
|
if (!requireAdmiral(req, res)) return;
|
||||||
try {
|
try {
|
||||||
const { name, stack_patterns, channel_type, channel_url, priority, enabled } = req.body;
|
const { name, node_id: rawNodeId, stack_patterns, channel_type, channel_url, priority, enabled } = req.body;
|
||||||
|
|
||||||
if (!name || typeof name !== 'string' || !name.trim()) {
|
if (!name || typeof name !== 'string' || !name.trim()) {
|
||||||
res.status(400).json({ error: 'Name is required' });
|
res.status(400).json({ error: 'Name is required' });
|
||||||
@@ -110,6 +125,8 @@ notificationRoutesRouter.post('/', authMiddleware, async (req: Request, res: Res
|
|||||||
res.status(400).json({ error: 'Name must be 100 characters or fewer' });
|
res.status(400).json({ error: 'Name must be 100 characters or fewer' });
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
const nodeIdResult = validateNodeId(rawNodeId, res);
|
||||||
|
if (nodeIdResult === false) return;
|
||||||
if (!Array.isArray(stack_patterns) || stack_patterns.length === 0 || stack_patterns.some((p: unknown) => typeof p !== 'string')) {
|
if (!Array.isArray(stack_patterns) || stack_patterns.length === 0 || stack_patterns.some((p: unknown) => typeof p !== 'string')) {
|
||||||
res.status(400).json({ error: 'stack_patterns must be a non-empty array of stack names' });
|
res.status(400).json({ error: 'stack_patterns must be a non-empty array of stack names' });
|
||||||
return;
|
return;
|
||||||
@@ -133,6 +150,7 @@ notificationRoutesRouter.post('/', authMiddleware, async (req: Request, res: Res
|
|||||||
const now = Date.now();
|
const now = Date.now();
|
||||||
const route = DatabaseService.getInstance().createNotificationRoute({
|
const route = DatabaseService.getInstance().createNotificationRoute({
|
||||||
name: name.trim(),
|
name: name.trim(),
|
||||||
|
node_id: nodeIdResult,
|
||||||
stack_patterns: cleanedPatterns,
|
stack_patterns: cleanedPatterns,
|
||||||
channel_type,
|
channel_type,
|
||||||
channel_url: channel_url.trim(),
|
channel_url: channel_url.trim(),
|
||||||
@@ -160,7 +178,7 @@ notificationRoutesRouter.put('/:id', authMiddleware, async (req: Request, res: R
|
|||||||
const existing = DatabaseService.getInstance().getNotificationRoute(id);
|
const existing = DatabaseService.getInstance().getNotificationRoute(id);
|
||||||
if (!existing) { res.status(404).json({ error: 'Route not found' }); return; }
|
if (!existing) { res.status(404).json({ error: 'Route not found' }); return; }
|
||||||
|
|
||||||
const { name, stack_patterns, channel_type, channel_url, priority, enabled } = req.body;
|
const { name, node_id: rawNodeId, stack_patterns, channel_type, channel_url, priority, enabled } = req.body;
|
||||||
|
|
||||||
if (name !== undefined && (typeof name !== 'string' || !name.trim())) {
|
if (name !== undefined && (typeof name !== 'string' || !name.trim())) {
|
||||||
res.status(400).json({ error: 'Name must be a non-empty string' });
|
res.status(400).json({ error: 'Name must be a non-empty string' });
|
||||||
@@ -170,6 +188,12 @@ notificationRoutesRouter.put('/:id', authMiddleware, async (req: Request, res: R
|
|||||||
res.status(400).json({ error: 'Name must be 100 characters or fewer' });
|
res.status(400).json({ error: 'Name must be 100 characters or fewer' });
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
let validatedNodeId: number | null | undefined;
|
||||||
|
if ('node_id' in req.body) {
|
||||||
|
const result = validateNodeId(rawNodeId, res);
|
||||||
|
if (result === false) return;
|
||||||
|
validatedNodeId = result;
|
||||||
|
}
|
||||||
let cleanedPatterns: string[] | undefined;
|
let cleanedPatterns: string[] | undefined;
|
||||||
if (stack_patterns !== undefined) {
|
if (stack_patterns !== undefined) {
|
||||||
if (!Array.isArray(stack_patterns) || stack_patterns.length === 0 || stack_patterns.some((p: unknown) => typeof p !== 'string')) {
|
if (!Array.isArray(stack_patterns) || stack_patterns.length === 0 || stack_patterns.some((p: unknown) => typeof p !== 'string')) {
|
||||||
@@ -201,6 +225,7 @@ notificationRoutesRouter.put('/:id', authMiddleware, async (req: Request, res: R
|
|||||||
|
|
||||||
const updates: Record<string, unknown> = { updated_at: Date.now() };
|
const updates: Record<string, unknown> = { updated_at: Date.now() };
|
||||||
if (name !== undefined) updates.name = name.trim();
|
if (name !== undefined) updates.name = name.trim();
|
||||||
|
if (validatedNodeId !== undefined) updates.node_id = validatedNodeId;
|
||||||
if (cleanedPatterns !== undefined) updates.stack_patterns = cleanedPatterns;
|
if (cleanedPatterns !== undefined) updates.stack_patterns = cleanedPatterns;
|
||||||
if (channel_type !== undefined) updates.channel_type = channel_type;
|
if (channel_type !== undefined) updates.channel_type = channel_type;
|
||||||
if (channel_url !== undefined) updates.channel_url = channel_url.trim();
|
if (channel_url !== undefined) updates.channel_url = channel_url.trim();
|
||||||
|
|||||||
@@ -307,6 +307,7 @@ export interface Registry {
|
|||||||
export interface NotificationRoute {
|
export interface NotificationRoute {
|
||||||
id: number;
|
id: number;
|
||||||
name: string;
|
name: string;
|
||||||
|
node_id: number | null;
|
||||||
stack_patterns: string[];
|
stack_patterns: string[];
|
||||||
channel_type: 'discord' | 'slack' | 'webhook';
|
channel_type: 'discord' | 'slack' | 'webhook';
|
||||||
channel_url: string;
|
channel_url: string;
|
||||||
@@ -485,6 +486,7 @@ export class DatabaseService {
|
|||||||
this.migrateRegistries();
|
this.migrateRegistries();
|
||||||
this.migrateRoleAssignments();
|
this.migrateRoleAssignments();
|
||||||
this.migrateNotificationRoutes();
|
this.migrateNotificationRoutes();
|
||||||
|
this.migrateNotificationRoutesNodeId();
|
||||||
this.migrateNotificationHistoryContext();
|
this.migrateNotificationHistoryContext();
|
||||||
this.migrateScanPolicyFleetColumns();
|
this.migrateScanPolicyFleetColumns();
|
||||||
this.migrateSecretMisconfigColumns();
|
this.migrateSecretMisconfigColumns();
|
||||||
@@ -1130,6 +1132,15 @@ export class DatabaseService {
|
|||||||
try { this.db.prepare('ALTER TABLE notification_history ADD COLUMN dispatch_error TEXT').run(); } catch { /* already exists */ }
|
try { this.db.prepare('ALTER TABLE notification_history ADD COLUMN dispatch_error TEXT').run(); } catch { /* already exists */ }
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private migrateNotificationRoutesNodeId(): void {
|
||||||
|
try {
|
||||||
|
this.db.prepare('ALTER TABLE notification_routes ADD COLUMN node_id INTEGER NULL').run();
|
||||||
|
} catch {
|
||||||
|
// column already present
|
||||||
|
}
|
||||||
|
this.db.prepare('CREATE INDEX IF NOT EXISTS idx_notification_routes_node_priority ON notification_routes(node_id, enabled, priority)').run();
|
||||||
|
}
|
||||||
|
|
||||||
private migrateNotificationHistoryContext(): void {
|
private migrateNotificationHistoryContext(): void {
|
||||||
const tryAddColumn = (col: string, def: string) => {
|
const tryAddColumn = (col: string, def: string) => {
|
||||||
try {
|
try {
|
||||||
@@ -1247,6 +1258,7 @@ export class DatabaseService {
|
|||||||
return {
|
return {
|
||||||
id: row.id as number,
|
id: row.id as number,
|
||||||
name: row.name as string,
|
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[],
|
stack_patterns: JSON.parse(row.stack_patterns as string) as string[],
|
||||||
channel_type: row.channel_type as 'discord' | 'slack' | 'webhook',
|
channel_type: row.channel_type as 'discord' | 'slack' | 'webhook',
|
||||||
channel_url: row.channel_url as string,
|
channel_url: row.channel_url as string,
|
||||||
@@ -1276,9 +1288,10 @@ export class DatabaseService {
|
|||||||
|
|
||||||
public createNotificationRoute(route: Omit<NotificationRoute, 'id'>): NotificationRoute {
|
public createNotificationRoute(route: Omit<NotificationRoute, 'id'>): NotificationRoute {
|
||||||
const result = this.db.prepare(
|
const result = this.db.prepare(
|
||||||
'INSERT INTO notification_routes (name, stack_patterns, channel_type, channel_url, priority, enabled, created_at, updated_at) VALUES (?, ?, ?, ?, ?, ?, ?, ?)'
|
'INSERT INTO notification_routes (name, node_id, stack_patterns, channel_type, channel_url, priority, enabled, created_at, updated_at) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)'
|
||||||
).run(
|
).run(
|
||||||
route.name,
|
route.name,
|
||||||
|
route.node_id ?? null,
|
||||||
JSON.stringify(route.stack_patterns),
|
JSON.stringify(route.stack_patterns),
|
||||||
route.channel_type,
|
route.channel_type,
|
||||||
route.channel_url,
|
route.channel_url,
|
||||||
@@ -1295,6 +1308,7 @@ export class DatabaseService {
|
|||||||
const values: unknown[] = [];
|
const values: unknown[] = [];
|
||||||
|
|
||||||
if (updates.name !== undefined) { fields.push('name = ?'); values.push(updates.name); }
|
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 (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_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.channel_url !== undefined) { fields.push('channel_url = ?'); values.push(updates.channel_url); }
|
||||||
|
|||||||
@@ -122,7 +122,10 @@ export class NotificationService {
|
|||||||
|
|
||||||
if (stackName !== undefined) {
|
if (stackName !== undefined) {
|
||||||
const routes = this.dbService.getEnabledNotificationRoutes();
|
const routes = this.dbService.getEnabledNotificationRoutes();
|
||||||
const matched = routes.filter(r => r.stack_patterns.includes(stackName));
|
const matched = routes.filter(r =>
|
||||||
|
(r.node_id == null || r.node_id === localNodeId) &&
|
||||||
|
r.stack_patterns.includes(stackName)
|
||||||
|
);
|
||||||
if (matched.length > 0) {
|
if (matched.length > 0) {
|
||||||
if (isDebugEnabled()) console.log(`[Notify:diag] Matched ${matched.length} route(s) for stack "${stackName}"`);
|
if (isDebugEnabled()) console.log(`[Notify:diag] Matched ${matched.length} route(s) for stack "${stackName}"`);
|
||||||
await Promise.allSettled(
|
await Promise.allSettled(
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { useState, useEffect, useCallback } from 'react';
|
import { useState, useEffect, useCallback, useMemo } from '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 { Label } from '@/components/ui/label';
|
import { Label } from '@/components/ui/label';
|
||||||
@@ -8,6 +8,7 @@ import { Skeleton } from '@/components/ui/skeleton';
|
|||||||
import { Combobox } from '@/components/ui/combobox';
|
import { Combobox } from '@/components/ui/combobox';
|
||||||
import type { ComboboxOption } from '@/components/ui/combobox';
|
import type { ComboboxOption } from '@/components/ui/combobox';
|
||||||
import { Tabs, TabsList, TabsTrigger, TabsHighlight, TabsHighlightItem } from '@/components/ui/tabs';
|
import { Tabs, TabsList, TabsTrigger, TabsHighlight, TabsHighlightItem } from '@/components/ui/tabs';
|
||||||
|
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select';
|
||||||
import { springs } from '@/lib/motion';
|
import { springs } from '@/lib/motion';
|
||||||
import {
|
import {
|
||||||
Dialog,
|
Dialog,
|
||||||
@@ -28,6 +29,7 @@ import {
|
|||||||
} from '@/components/ui/alert-dialog';
|
} from '@/components/ui/alert-dialog';
|
||||||
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 { useNodes } from '@/context/NodeContext';
|
||||||
import { AdmiralGate } from '@/components/AdmiralGate';
|
import { AdmiralGate } from '@/components/AdmiralGate';
|
||||||
import { CapabilityGate } from '@/components/CapabilityGate';
|
import { CapabilityGate } from '@/components/CapabilityGate';
|
||||||
import { Plus, Trash2, Pencil, RefreshCw, Zap, X, Route } from 'lucide-react';
|
import { Plus, Trash2, Pencil, RefreshCw, Zap, X, Route } from 'lucide-react';
|
||||||
@@ -35,6 +37,7 @@ import { Plus, Trash2, Pencil, RefreshCw, Zap, X, Route } from 'lucide-react';
|
|||||||
interface NotificationRoute {
|
interface NotificationRoute {
|
||||||
id: number;
|
id: number;
|
||||||
name: string;
|
name: string;
|
||||||
|
node_id: number | null;
|
||||||
stack_patterns: string[];
|
stack_patterns: string[];
|
||||||
channel_type: 'discord' | 'slack' | 'webhook';
|
channel_type: 'discord' | 'slack' | 'webhook';
|
||||||
channel_url: string;
|
channel_url: string;
|
||||||
@@ -57,6 +60,8 @@ const CHANNEL_PLACEHOLDERS: Record<string, string> = {
|
|||||||
};
|
};
|
||||||
|
|
||||||
export function NotificationRoutingSection() {
|
export function NotificationRoutingSection() {
|
||||||
|
const { nodes } = useNodes();
|
||||||
|
const localNode = useMemo(() => nodes.find(n => n.type === 'local') ?? null, [nodes]);
|
||||||
const [routes, setRoutes] = useState<NotificationRoute[]>([]);
|
const [routes, setRoutes] = useState<NotificationRoute[]>([]);
|
||||||
const [loading, setLoading] = useState(true);
|
const [loading, setLoading] = useState(true);
|
||||||
const [saving, setSaving] = useState(false);
|
const [saving, setSaving] = useState(false);
|
||||||
@@ -67,6 +72,7 @@ export function NotificationRoutingSection() {
|
|||||||
|
|
||||||
// Form state
|
// Form state
|
||||||
const [formName, setFormName] = useState('');
|
const [formName, setFormName] = useState('');
|
||||||
|
const [formNodeId, setFormNodeId] = useState<number | null>(null);
|
||||||
const [formStacks, setFormStacks] = useState<string[]>([]);
|
const [formStacks, setFormStacks] = useState<string[]>([]);
|
||||||
const [formChannelType, setFormChannelType] = useState<'discord' | 'slack' | 'webhook'>('discord');
|
const [formChannelType, setFormChannelType] = useState<'discord' | 'slack' | 'webhook'>('discord');
|
||||||
const [formChannelUrl, setFormChannelUrl] = useState('');
|
const [formChannelUrl, setFormChannelUrl] = useState('');
|
||||||
@@ -105,6 +111,7 @@ export function NotificationRoutingSection() {
|
|||||||
|
|
||||||
const resetForm = () => {
|
const resetForm = () => {
|
||||||
setFormName('');
|
setFormName('');
|
||||||
|
setFormNodeId(null);
|
||||||
setFormStacks([]);
|
setFormStacks([]);
|
||||||
setFormChannelType('discord');
|
setFormChannelType('discord');
|
||||||
setFormChannelUrl('');
|
setFormChannelUrl('');
|
||||||
@@ -117,6 +124,7 @@ export function NotificationRoutingSection() {
|
|||||||
const startEdit = (route: NotificationRoute) => {
|
const startEdit = (route: NotificationRoute) => {
|
||||||
setEditingId(route.id);
|
setEditingId(route.id);
|
||||||
setFormName(route.name);
|
setFormName(route.name);
|
||||||
|
setFormNodeId(route.node_id);
|
||||||
setFormStacks([...route.stack_patterns]);
|
setFormStacks([...route.stack_patterns]);
|
||||||
setFormChannelType(route.channel_type);
|
setFormChannelType(route.channel_type);
|
||||||
setFormChannelUrl(route.channel_url);
|
setFormChannelUrl(route.channel_url);
|
||||||
@@ -137,6 +145,7 @@ export function NotificationRoutingSection() {
|
|||||||
try {
|
try {
|
||||||
const body = {
|
const body = {
|
||||||
name: formName.trim(),
|
name: formName.trim(),
|
||||||
|
node_id: formNodeId,
|
||||||
stack_patterns: formStacks,
|
stack_patterns: formStacks,
|
||||||
channel_type: formChannelType,
|
channel_type: formChannelType,
|
||||||
channel_url: formChannelUrl.trim(),
|
channel_url: formChannelUrl.trim(),
|
||||||
@@ -256,6 +265,24 @@ export function NotificationRoutingSection() {
|
|||||||
/>
|
/>
|
||||||
</div>
|
</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>
|
||||||
|
{localNode !== null && (
|
||||||
|
<SelectItem value={String(localNode.id)}>{localNode.name}</SelectItem>
|
||||||
|
)}
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
</div>
|
||||||
|
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
<Label>Stacks</Label>
|
<Label>Stacks</Label>
|
||||||
<Combobox
|
<Combobox
|
||||||
@@ -366,6 +393,11 @@ export function NotificationRoutingSection() {
|
|||||||
<Badge variant="outline" className="text-[10px] shrink-0">
|
<Badge variant="outline" className="text-[10px] shrink-0">
|
||||||
{CHANNEL_LABELS[route.channel_type]}
|
{CHANNEL_LABELS[route.channel_type]}
|
||||||
</Badge>
|
</Badge>
|
||||||
|
{route.node_id !== null && (
|
||||||
|
<Badge variant="secondary" className="text-[10px] shrink-0 font-mono">
|
||||||
|
{route.node_id === localNode?.id ? localNode?.name : `node:${route.node_id}`}
|
||||||
|
</Badge>
|
||||||
|
)}
|
||||||
{!route.enabled && (
|
{!route.enabled && (
|
||||||
<Badge variant="secondary" className="text-[10px] shrink-0 text-muted-foreground">
|
<Badge variant="secondary" className="text-[10px] shrink-0 text-muted-foreground">
|
||||||
Disabled
|
Disabled
|
||||||
|
|||||||
Reference in New Issue
Block a user