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 { CapabilityGate } from '@/components/CapabilityGate'; import { Plus, Trash2, Pencil, RefreshCw, Zap, X, Route } 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 = { discord: 'Discord', slack: 'Slack', webhook: 'Webhook', }; const CHANNEL_PLACEHOLDERS: Record = { discord: 'https://discord.com/api/webhooks/...', slack: 'https://hooks.slack.com/services/...', webhook: 'https://example.com/webhook', }; export function NotificationRoutingSection() { const [routes, setRoutes] = useState([]); const [loading, setLoading] = useState(true); const [saving, setSaving] = useState(false); const [showForm, setShowForm] = useState(false); const [editingId, setEditingId] = useState(null); const [testingId, setTestingId] = useState(null); const [stackOptions, setStackOptions] = useState([]); // Form state const [formName, setFormName] = useState(''); const [formStacks, setFormStacks] = useState([]); 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 (
{ if (!open) resetForm(); }}> {editingId ? 'Edit Route' : 'New Routing Rule'} {editingId ? 'Edit a notification routing rule' : 'Create a notification routing rule'}
setFormName(e.target.value)} maxLength={100} />
{formStacks.length > 0 && (
{formStacks.map(s => ( {s} ))}
)}
setFormChannelType(v as 'discord' | 'slack' | 'webhook')}> Discord Slack Webhook setFormChannelUrl(e.target.value)} />
setFormPriority(parseInt(e.target.value, 10) || 0)} />

Lower values are evaluated first.

{loading && (
)} {!loading && routes.length === 0 && (

No routing rules configured.

Alerts will use your global notification channels. Add a route to direct specific stack alerts to dedicated channels.

)} {!loading && routes.map(route => (
{route.name} {CHANNEL_LABELS[route.channel_type]} {!route.enabled && ( Disabled )}
handleToggleEnabled(route)} className="scale-75" /> Delete routing rule? Deleting {route.name} will remove this routing rule. Alerts for the associated stacks will fall back to your global notification channels. Cancel handleDelete(route.id)} className="bg-destructive text-destructive-foreground hover:bg-destructive/90" > Delete
{route.stack_patterns.map(s => ( {s} ))}
| {route.channel_url} {route.priority !== 0 && ( <> | Priority: {route.priority} )}
))}
); }