import { useState, useEffect, useCallback, useMemo } from 'react'; import { Button } from '@/components/ui/button'; import { Input } from '@/components/ui/input'; import { Label } from '@/components/ui/label'; import { TogglePill } from '@/components/ui/toggle-pill'; 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 { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select'; import { springs } from '@/lib/motion'; import { Modal, ModalHeader, ModalBody, ModalFooter, ConfirmModal } from '@/components/ui/modal'; import { toast } from '@/components/ui/toast-store'; import { apiFetch } from '@/lib/api'; import { useNodes } from '@/context/NodeContext'; import { CapabilityGate } from '@/components/CapabilityGate'; import type { NotificationCategory } from '@/components/dashboard/types'; import type { Label as StackLabel } from '@/components/label-types'; import { CATEGORY_LABELS } from '@/lib/notificationCategories'; import { Plus, Trash2, Pencil, RefreshCw, Zap, X, Route } from 'lucide-react'; import { SettingsCallout } from './SettingsCallout'; import { SettingsPrimaryButton } from './SettingsActions'; import { useMastheadStats } from './MastheadStatsContext'; interface NotificationRoute { id: number; name: string; node_id: number | null; stack_patterns: string[]; label_ids: number[] | null; categories: NotificationCategory[] | null; 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 { nodes } = useNodes(); const localNode = useMemo(() => nodes.find(n => n.type === 'local') ?? null, [nodes]); 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 [deleteRouteId, setDeleteRouteId] = useState(null); const [stackOptions, setStackOptions] = useState([]); const [labelOptions, setLabelOptions] = useState([]); // Form state const [formName, setFormName] = useState(''); const [formNodeId, setFormNodeId] = useState(null); const [formStacks, setFormStacks] = useState([]); const [formLabelIds, setFormLabelIds] = useState([]); const [formCategories, setFormCategories] = 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 } }, []); const fetchLabels = useCallback(async () => { try { const res = await apiFetch('/labels'); if (res.ok) { setLabelOptions(await res.json()); } } catch { // Labels non-critical } }, []); useEffect(() => { void Promise.all([fetchRoutes(), fetchStacks(), fetchLabels()]); }, [fetchRoutes, fetchStacks, fetchLabels]); const resetForm = () => { setFormName(''); setFormNodeId(null); setFormStacks([]); setFormLabelIds([]); setFormCategories([]); setFormChannelType('discord'); setFormChannelUrl(''); setFormPriority(0); setFormEnabled(true); setEditingId(null); setShowForm(false); }; const startEdit = (route: NotificationRoute) => { setEditingId(route.id); setFormName(route.name); setFormNodeId(route.node_id); setFormStacks([...route.stack_patterns]); setFormLabelIds(route.label_ids ? [...route.label_ids] : []); setFormCategories(route.categories ? [...route.categories] : []); 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 (!formChannelUrl.trim() || !formChannelUrl.startsWith('https://')) { toast.error('Channel URL must be a valid HTTPS URL.'); return; } setSaving(true); try { const body = { name: formName.trim(), node_id: formNodeId, stack_patterns: formStacks, label_ids: formLabelIds.length > 0 ? formLabelIds : null, categories: formCategories.length > 0 ? formCategories : null, 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 () => { if (deleteRouteId == null) return; try { const res = await apiFetch(`/notification-routes/${deleteRouteId}`, { 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.'); } finally { setDeleteRouteId(null); } }; const deleteTargetRoute = deleteRouteId != null ? routes.find(r => r.id === deleteRouteId) : null; 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 addLabel = (idStr: string) => { const id = Number(idStr); if (!isNaN(id) && id > 0 && !formLabelIds.includes(id)) { setFormLabelIds(prev => [...prev, id]); } }; const removeLabel = (id: number) => { setFormLabelIds(prev => prev.filter(l => l !== id)); }; const addCategory = (cat: string) => { const c = cat as NotificationCategory; if (c && !formCategories.includes(c)) { setFormCategories(prev => [...prev, c]); } }; const removeCategory = (cat: NotificationCategory) => { setFormCategories(prev => prev.filter(c => c !== cat)); }; const enabledRoutesCount = routes.filter(r => r.enabled).length; useMastheadStats( loading ? null : [ { label: 'ROUTES', value: `${routes.length}` }, { label: 'ENABLED', value: `${enabledRoutesCount}`, tone: enabledRoutesCount > 0 ? 'value' : 'subtitle', }, ], ); const availableStackOptions = stackOptions.filter(o => !formStacks.includes(o.value)); const availableLabelOptions = useMemo( () => labelOptions.filter(l => !formLabelIds.includes(l.id)).map(l => ({ value: String(l.id), label: l.name })), [labelOptions, formLabelIds], ); const availableCategoryOptions = useMemo( () => (Object.keys(CATEGORY_LABELS) as NotificationCategory[]).filter(c => !formCategories.includes(c)).map(c => ({ value: c, label: CATEGORY_LABELS[c] })), [formCategories], ); return (
{ resetForm(); setShowForm(true); }}> Add route
{ if (!open) resetForm(); }} size="lg">
setFormName(e.target.value)} maxLength={100} />
{formStacks.length > 0 && (
{formStacks.map(s => ( {s} ))}
)}
{formLabelIds.length > 0 && (
{formLabelIds.map(id => { const lbl = labelOptions.find(l => l.id === id); return ( {lbl?.name ?? `Label ${id}`} ); })}
)}
{formCategories.length > 0 && (
{formCategories.map(c => ( {CATEGORY_LABELS[c]} ))}
)}

Leave blank to match all categories. All non-empty filters must match (AND).

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.

Cancel } primary={ {saving ? <>Saving : editingId ? 'Update' : 'Create'} } />
{loading && (
)} {!loading && routes.length === 0 && ( } title="No routing rules configured" subtitle="Alerts 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.node_id !== null && ( {route.node_id === localNode?.id ? localNode?.name : `node:${route.node_id}`} )} {!route.enabled && ( Disabled )}
handleToggleEnabled(route)} className="scale-75" />
{route.stack_patterns.length > 0 && route.stack_patterns.map(s => ( {s} ))} {route.label_ids && route.label_ids.length > 0 && route.label_ids.map(id => { const lbl = labelOptions.find(l => l.id === id); return ( {lbl?.name ?? `label:${id}`} ); })} {route.categories && route.categories.length > 0 && route.categories.map(c => ( {CATEGORY_LABELS[c] ?? c} ))} {route.stack_patterns.length === 0 && (!route.label_ids || route.label_ids.length === 0) && (!route.categories || route.categories.length === 0) && ( Matches all alerts )} | {route.channel_url} {route.priority !== 0 && ( <> | Priority: {route.priority} )}
))} { if (!open) setDeleteRouteId(null); }} variant="destructive" kicker="ROUTING · DELETE · IRREVERSIBLE" title="Delete routing rule" confirmLabel="Delete" onConfirm={handleDelete} >

Deletes {deleteTargetRoute?.name ?? 'this rule'}. Alerts for the associated stacks will fall back to your global notification channels.

); }