import { useState, useEffect, useCallback, useMemo, useRef } 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 { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select'; import { SegmentedControl } from '@/components/ui/segmented-control'; 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 { emitMuteRulesChanged, type MuteRuleDraft } from '@/lib/muteRules'; import { useMuteRulesRefresh } from '@/hooks/useMuteRulesRefresh'; import { Plus, Trash2, Pencil, RefreshCw, X, BellOff } from 'lucide-react'; import { SettingsCallout } from './SettingsCallout'; import { SettingsPrimaryButton } from './SettingsActions'; import { useMastheadStats } from './MastheadStatsContext'; import { PatternChips, type PatternChipsHandle } from './PatternChips'; type NotificationLevel = 'info' | 'warning' | 'error'; type AppliesTo = 'bell' | 'external' | 'both'; type ExpirationPreset = 'forever' | '1h' | '24h' | 'custom'; interface NotificationSuppressionRule { id: number; name: string; node_id: number | null; stack_patterns: string[]; label_ids: number[] | null; categories: NotificationCategory[] | null; levels: NotificationLevel[] | null; applies_to: AppliesTo; enabled: boolean; expires_at: number | null; schedule: MuteRuleSchedule | null; created_at: number; updated_at: number; } type MuteRuleSchedule = { days: number[]; start_minute: number; end_minute: number; tz: 'UTC'; }; const DAY_LABELS = ['Sun', 'Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat'] as const; function minuteToTimeInput(minute: number): string { const h = Math.floor(minute / 60); const m = minute % 60; return `${String(h).padStart(2, '0')}:${String(m).padStart(2, '0')}`; } function timeInputToMinute(value: string): number | null { const match = /^(\d{2}):(\d{2})(?::\d{2})?$/.exec(value); if (!match) return null; const h = Number(match[1]); const m = Number(match[2]); if (h > 23 || m > 59) return null; return h * 60 + m; } function formatScheduleSummary(schedule: MuteRuleSchedule | null): string | null { if (!schedule) return null; const days = schedule.days.map((d) => DAY_LABELS[d] ?? String(d)).join(', '); return `UTC ${days} ${minuteToTimeInput(schedule.start_minute)}-${minuteToTimeInput(schedule.end_minute)}`; } const LEVEL_LABELS: Record = { info: 'Info', warning: 'Warning', error: 'Error', }; const APPLIES_TO_LABELS: Record = { bell: 'Bell only', external: 'External only', both: 'Bell and external', }; function expirationFromPreset(preset: ExpirationPreset, customMs: number | null): number | null { if (preset === 'forever') return null; if (preset === '1h') return Date.now() + 3_600_000; if (preset === '24h') return Date.now() + 86_400_000; return customMs; } function presetFromExpiresAt(expires_at: number | null): { preset: ExpirationPreset; customMs: number | null } { if (expires_at == null) return { preset: 'forever', customMs: null }; return { preset: 'custom', customMs: expires_at }; } function formatExpiry(expires_at: number | null): string { if (expires_at == null) return 'Never'; if (expires_at <= Date.now()) return 'Expired'; return new Date(expires_at).toLocaleString(undefined, { month: 'short', day: 'numeric', hour: '2-digit', minute: '2-digit' }); } function applyDraftToForm( draft: MuteRuleDraft, setters: { setFormName: (v: string) => void; setFormNodeId: (v: number | null) => void; setFormStacks: (v: string[]) => void; setFormLabelIds: (v: number[]) => void; setFormCategories: (v: NotificationCategory[]) => void; setFormLevels: (v: NotificationLevel[]) => void; setFormAppliesTo: (v: AppliesTo) => void; setFormEnabled: (v: boolean) => void; }, ) { setters.setFormName(draft.name); setters.setFormNodeId(draft.node_id ?? null); setters.setFormStacks(draft.stack_patterns ?? []); setters.setFormLabelIds(draft.label_ids ?? []); setters.setFormCategories(draft.categories ?? []); setters.setFormLevels(draft.levels ?? []); setters.setFormAppliesTo(draft.applies_to ?? 'both'); setters.setFormEnabled(draft.enabled ?? true); } interface NotificationSuppressionSectionProps { prefill?: MuteRuleDraft | null; onPrefillConsumed?: () => void; } export function NotificationSuppressionSection({ prefill = null, onPrefillConsumed, }: NotificationSuppressionSectionProps) { const { nodes } = useNodes(); const [rules, setRules] = useState([]); const [loading, setLoading] = useState(true); const [saving, setSaving] = useState(false); const [showForm, setShowForm] = useState(false); const [editingId, setEditingId] = useState(null); const [deleteRuleId, setDeleteRuleId] = useState(null); const [stackOptions, setStackOptions] = useState([]); const [labelOptions, setLabelOptions] = useState([]); const [formName, setFormName] = useState(''); const [formNodeId, setFormNodeId] = useState(null); const [formStacks, setFormStacks] = useState([]); const patternChipsRef = useRef(null); const [formLabelIds, setFormLabelIds] = useState([]); const [formCategories, setFormCategories] = useState([]); const [formLevels, setFormLevels] = useState([]); const [formAppliesTo, setFormAppliesTo] = useState('both'); const [formEnabled, setFormEnabled] = useState(true); const [formExpirationPreset, setFormExpirationPreset] = useState('forever'); const [formCustomExpiry, setFormCustomExpiry] = useState(''); const [formScheduleEnabled, setFormScheduleEnabled] = useState(false); const [formScheduleDays, setFormScheduleDays] = useState([]); const [formScheduleStart, setFormScheduleStart] = useState('02:00'); const [formScheduleEnd, setFormScheduleEnd] = useState('06:00'); const fetchRules = useCallback(async () => { try { const res = await apiFetch('/notification-suppression-rules'); if (res.ok) setRules(await res.json()); } catch { toast.error('Failed to load mute rules.'); } 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 { /* non-critical */ } }, []); const fetchLabels = useCallback(async () => { try { const res = await apiFetch('/labels'); if (res.ok) setLabelOptions(await res.json()); } catch { /* non-critical */ } }, []); useEffect(() => { void Promise.all([fetchRules(), fetchStacks(), fetchLabels()]); }, [fetchRules, fetchStacks, fetchLabels]); useMuteRulesRefresh(fetchRules); useEffect(() => { if (!prefill) return; applyDraftToForm(prefill, { setFormName, setFormNodeId, setFormStacks, setFormLabelIds, setFormCategories, setFormLevels, setFormAppliesTo, setFormEnabled, }); setFormExpirationPreset('forever'); setFormCustomExpiry(''); setFormScheduleEnabled(false); setFormScheduleDays([]); setFormScheduleStart('02:00'); setFormScheduleEnd('06:00'); setEditingId(null); setShowForm(true); onPrefillConsumed?.(); }, [prefill, onPrefillConsumed]); const resetForm = () => { setFormName(''); setFormNodeId(null); setFormStacks([]); setFormLabelIds([]); setFormCategories([]); setFormLevels([]); setFormAppliesTo('both'); setFormEnabled(true); setFormExpirationPreset('forever'); setFormCustomExpiry(''); setFormScheduleEnabled(false); setFormScheduleDays([]); setFormScheduleStart('02:00'); setFormScheduleEnd('06:00'); setEditingId(null); setShowForm(false); }; const startEdit = (rule: NotificationSuppressionRule) => { const { preset, customMs } = presetFromExpiresAt(rule.expires_at); setEditingId(rule.id); setFormName(rule.name); setFormNodeId(rule.node_id); setFormStacks([...rule.stack_patterns]); setFormLabelIds(rule.label_ids ? [...rule.label_ids] : []); setFormCategories(rule.categories ? [...rule.categories] : []); setFormLevels(rule.levels ? [...rule.levels] : []); setFormAppliesTo(rule.applies_to); setFormEnabled(rule.enabled); setFormExpirationPreset(preset); setFormCustomExpiry(customMs != null ? new Date(customMs).toISOString().slice(0, 16) : ''); if (rule.schedule) { setFormScheduleEnabled(true); setFormScheduleDays([...rule.schedule.days]); setFormScheduleStart(minuteToTimeInput(rule.schedule.start_minute)); setFormScheduleEnd(minuteToTimeInput(rule.schedule.end_minute)); } else { setFormScheduleEnabled(false); setFormScheduleDays([]); setFormScheduleStart('02:00'); setFormScheduleEnd('06:00'); } setShowForm(true); }; const handleSave = async () => { if (!formName.trim()) { toast.error('Name is required.'); return; } const preparedPatterns = patternChipsRef.current?.prepareSave(); if (!preparedPatterns?.ok) { toast.error('Fix invalid stack patterns before saving.'); return; } const customMs = formCustomExpiry ? new Date(formCustomExpiry).getTime() : null; if (formExpirationPreset === 'custom' && (customMs == null || Number.isNaN(customMs))) { toast.error('Choose a valid custom expiration date.'); return; } let schedule: MuteRuleSchedule | null = null; if (formScheduleEnabled) { if (formScheduleDays.length === 0) { toast.error('Select at least one day for the weekly window.'); return; } const startMinute = timeInputToMinute(formScheduleStart); const endMinute = timeInputToMinute(formScheduleEnd); if (startMinute == null || endMinute == null) { toast.error('Enter valid UTC start and end times.'); return; } if (startMinute === endMinute) { toast.error('Weekly window start and end must differ.'); return; } schedule = { days: [...new Set(formScheduleDays)].sort((a, b) => a - b), start_minute: startMinute, end_minute: endMinute, tz: 'UTC', }; } setSaving(true); try { const body = { name: formName.trim(), node_id: formNodeId, stack_patterns: preparedPatterns.patterns, label_ids: formLabelIds.length > 0 ? formLabelIds : null, categories: formCategories.length > 0 ? formCategories : null, levels: formLevels.length > 0 ? formLevels : null, applies_to: formAppliesTo, enabled: formEnabled, expires_at: expirationFromPreset(formExpirationPreset, customMs), schedule, }; const url = editingId ? `/notification-suppression-rules/${editingId}` : '/notification-suppression-rules'; const res = await apiFetch(url, { method: editingId ? 'PUT' : 'POST', body: JSON.stringify(body), }); if (res.ok) { toast.success(editingId ? 'Mute rule updated.' : 'Mute rule created.'); emitMuteRulesChanged(); resetForm(); fetchRules(); } else { const err = await res.json().catch(() => ({})); toast.error(err?.error || err?.message || 'Something went wrong.'); } } catch (e: unknown) { toast.error((e as Error)?.message || 'Network error.'); } finally { setSaving(false); } }; const handleDelete = async () => { if (deleteRuleId == null) return; try { const res = await apiFetch(`/notification-suppression-rules/${deleteRuleId}`, { method: 'DELETE' }); if (res.ok) { toast.success('Mute rule deleted.'); emitMuteRulesChanged(); fetchRules(); } else { const err = await res.json().catch(() => ({})); toast.error(err?.error || 'Something went wrong.'); } } catch { toast.error('Network error.'); } finally { setDeleteRuleId(null); } }; const handleToggleEnabled = async (rule: NotificationSuppressionRule) => { try { const res = await apiFetch(`/notification-suppression-rules/${rule.id}`, { method: 'PUT', body: JSON.stringify({ enabled: !rule.enabled }), }); if (res.ok) { emitMuteRulesChanged(); fetchRules(); } else { const err = await res.json().catch(() => ({})); toast.error(err?.error || 'Something went wrong.'); } } catch { toast.error('Network error.'); } }; const addStack = (stackName: string) => { if (stackName && !formStacks.includes(stackName)) setFormStacks((prev) => [...prev, stackName]); }; const addLabel = (idStr: string) => { const id = Number(idStr); if (!Number.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 addLevel = (level: string) => { const l = level as NotificationLevel; if (l && !formLevels.includes(l)) setFormLevels((prev) => [...prev, l]); }; const removeLevel = (level: NotificationLevel) => setFormLevels((prev) => prev.filter((x) => x !== level)); const enabledCount = rules.filter((r) => r.enabled && (r.expires_at == null || r.expires_at > Date.now())).length; useMastheadStats( loading ? null : [ { label: 'RULES', value: `${rules.length}` }, { label: 'ACTIVE', value: `${enabledCount}`, tone: enabledCount > 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], ); const availableLevelOptions = useMemo( () => (['info', 'warning', 'error'] as NotificationLevel[]) .filter((l) => !formLevels.includes(l)) .map((l) => ({ value: l, label: LEVEL_LABELS[l] })), [formLevels], ); const deleteTarget = deleteRuleId != null ? rules.find((r) => r.id === deleteRuleId) : null; return (
} title="Mute rules vs routing" subtitle="Routing sends matching alerts to another channel. Mute rules hide or drop delivery to the bell, external channels, or both. Events still appear in stack activity history." />
{ resetForm(); setShowForm(true); }}> Add mute rule
{ if (!open) resetForm(); }} size="lg">
setFormName(e.target.value)} maxLength={100} />
{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]} ))}
)}
{formLevels.length > 0 && (
{formLevels.map((l) => ( {LEVEL_LABELS[l]} ))}
)}

Leave matchers blank to match any value. All non-empty filters must match (AND).

{formExpirationPreset === 'custom' && ( setFormCustomExpiry(e.target.value)} /> )}
{formScheduleEnabled && (
{DAY_LABELS.map((label, day) => { const selected = formScheduleDays.includes(day); return ( ); })}
setFormScheduleStart(e.target.value)} />
setFormScheduleEnd(e.target.value)} />

Outside this window the rule does not mute. Cross-midnight windows use the start day only.

)}
{formEnabled ? 'Enabled' : 'Disabled'}
Cancel} primary={ {saving ? <>Saving : editingId ? 'Update' : 'Create'} } />
{loading && (
)} {!loading && rules.length === 0 && ( } title="No mute rules configured" subtitle="Alerts follow your routing and global channels unless a mute rule matches." /> )} {!loading && rules.map((rule) => (
{rule.name} {APPLIES_TO_LABELS[rule.applies_to]} {rule.node_id !== null && ( {nodes.find((n) => n.id === rule.node_id)?.name ?? `node:${rule.node_id}`} )} {!rule.enabled && Disabled} {rule.expires_at != null && rule.expires_at <= Date.now() && ( Expired )}
handleToggleEnabled(rule)} className="scale-75" />
{rule.stack_patterns.map((s) => {s})} {rule.label_ids?.map((id) => { const lbl = labelOptions.find((l) => l.id === id); return {lbl?.name ?? `label:${id}`}; })} {rule.categories?.map((c) => {CATEGORY_LABELS[c as NotificationCategory] ?? c})} {rule.levels?.map((l) => {LEVEL_LABELS[l]})} {rule.stack_patterns.length === 0 && !rule.label_ids?.length && !rule.categories?.length && !rule.levels?.length && ( Matches all alerts )} | Expires: {formatExpiry(rule.expires_at)} {formatScheduleSummary(rule.schedule) && ( <> | {formatScheduleSummary(rule.schedule)} )}
))} { if (!open) setDeleteRuleId(null); }} variant="destructive" kicker="MUTE RULES · DELETE · IRREVERSIBLE" title="Delete mute rule" confirmLabel="Delete" onConfirm={handleDelete} >

Deletes {deleteTarget?.name ?? 'this rule'}. Matching alerts will deliver normally again.

); }