Files
sencho/frontend/src/components/settings/NotificationSuppressionSection.tsx
T
SaelixCode 1d1fba0e33 fix: fail closed on corrupt mute schedules and clean invalid replicas
Empty or whitespace stored schedules no longer act as all-day mutes. Invalid schedules trigger remote DELETE cleanup, and the weekly-window form gains accessibility attributes plus component coverage.
2026-07-21 11:47:56 -04:00

725 lines
36 KiB
TypeScript

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<NotificationLevel, string> = {
info: 'Info',
warning: 'Warning',
error: 'Error',
};
const APPLIES_TO_LABELS: Record<AppliesTo, string> = {
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<NotificationSuppressionRule[]>([]);
const [loading, setLoading] = useState(true);
const [saving, setSaving] = useState(false);
const [showForm, setShowForm] = useState(false);
const [editingId, setEditingId] = useState<number | null>(null);
const [deleteRuleId, setDeleteRuleId] = useState<number | null>(null);
const [stackOptions, setStackOptions] = useState<ComboboxOption[]>([]);
const [labelOptions, setLabelOptions] = useState<StackLabel[]>([]);
const [formName, setFormName] = useState('');
const [formNodeId, setFormNodeId] = useState<number | null>(null);
const [formStacks, setFormStacks] = useState<string[]>([]);
const patternChipsRef = useRef<PatternChipsHandle>(null);
const [formLabelIds, setFormLabelIds] = useState<number[]>([]);
const [formCategories, setFormCategories] = useState<NotificationCategory[]>([]);
const [formLevels, setFormLevels] = useState<NotificationLevel[]>([]);
const [formAppliesTo, setFormAppliesTo] = useState<AppliesTo>('both');
const [formEnabled, setFormEnabled] = useState(true);
const [formExpirationPreset, setFormExpirationPreset] = useState<ExpirationPreset>('forever');
const [formCustomExpiry, setFormCustomExpiry] = useState('');
const [formScheduleEnabled, setFormScheduleEnabled] = useState(false);
const [formScheduleDays, setFormScheduleDays] = useState<number[]>([]);
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<ComboboxOption[]>(
() => labelOptions.filter((l) => !formLabelIds.includes(l.id)).map((l) => ({ value: String(l.id), label: l.name })),
[labelOptions, formLabelIds],
);
const availableCategoryOptions = useMemo<ComboboxOption[]>(
() => (Object.keys(CATEGORY_LABELS) as NotificationCategory[])
.filter((c) => !formCategories.includes(c))
.map((c) => ({ value: c, label: CATEGORY_LABELS[c] })),
[formCategories],
);
const availableLevelOptions = useMemo<ComboboxOption[]>(
() => (['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 (
<CapabilityGate capability="notification-suppression" featureName="Mute Rules">
<div className="space-y-6">
<SettingsCallout
icon={<BellOff className="h-4 w-4" strokeWidth={1.5} />}
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."
/>
<div className="flex justify-end">
<SettingsPrimaryButton size="sm" onClick={() => { resetForm(); setShowForm(true); }}>
<Plus className="w-4 h-4" /> Add mute rule
</SettingsPrimaryButton>
</div>
<Modal open={showForm} onOpenChange={(open) => { if (!open) resetForm(); }} size="lg">
<ModalHeader
kicker={editingId ? 'MUTE RULES · EDIT RULE' : 'MUTE RULES · NEW RULE'}
title={editingId ? 'Edit mute rule' : 'New mute rule'}
description="Match alerts by node, stack, label, category, or severity, then choose where to mute delivery."
/>
<ModalBody>
<div className="space-y-2">
<Label>Name</Label>
<Input placeholder="e.g. Mute staging deploy noise" value={formName} onChange={(e) => setFormName(e.target.value)} maxLength={100} />
</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>
{nodes.map((n) => (
<SelectItem key={n.id} value={String(n.id)}>{n.name}</SelectItem>
))}
</SelectContent>
</Select>
</div>
<div className="space-y-2">
<Label>Stacks <span className="text-muted-foreground font-normal text-xs">(optional)</span></Label>
<PatternChips
ref={patternChipsRef}
patterns={formStacks}
onChange={setFormStacks}
placeholder="Type a pattern (for example prod-*)"
data-testid="mute-pattern-chips"
/>
<Combobox options={availableStackOptions} value="" onValueChange={addStack} placeholder="Insert known stack name..." searchPlaceholder="Search stacks..." emptyText="No stacks found." />
</div>
<div className="space-y-2">
<Label>Labels <span className="text-muted-foreground font-normal text-xs">(optional)</span></Label>
<Combobox options={availableLabelOptions} value="" onValueChange={addLabel} placeholder="Add a label..." searchPlaceholder="Search labels..." emptyText="No labels found." />
{formLabelIds.length > 0 && (
<div className="flex flex-wrap gap-1.5 pt-1">
{formLabelIds.map((id) => {
const lbl = labelOptions.find((l) => l.id === id);
return (
<Badge key={id} variant="secondary" className="text-xs gap-1 pr-1">
{lbl?.name ?? `Label ${id}`}
<button type="button" onClick={() => removeLabel(id)} className="ml-0.5 rounded-full hover:bg-foreground/10 p-0.5"><X className="w-3 h-3" /></button>
</Badge>
);
})}
</div>
)}
</div>
<div className="space-y-2">
<Label>Categories <span className="text-muted-foreground font-normal text-xs">(optional)</span></Label>
<Combobox options={availableCategoryOptions} value="" onValueChange={addCategory} placeholder="Add a category..." searchPlaceholder="Search categories..." emptyText="No categories found." />
{formCategories.length > 0 && (
<div className="flex flex-wrap gap-1.5 pt-1">
{formCategories.map((c) => (
<Badge key={c} variant="secondary" className="text-xs gap-1 pr-1">
{CATEGORY_LABELS[c]}
<button type="button" onClick={() => removeCategory(c)} className="ml-0.5 rounded-full hover:bg-foreground/10 p-0.5"><X className="w-3 h-3" /></button>
</Badge>
))}
</div>
)}
</div>
<div className="space-y-2">
<Label>Severity <span className="text-muted-foreground font-normal text-xs">(optional)</span></Label>
<Combobox options={availableLevelOptions} value="" onValueChange={addLevel} placeholder="Add a severity..." searchPlaceholder="Search..." emptyText="No levels left." />
{formLevels.length > 0 && (
<div className="flex flex-wrap gap-1.5 pt-1">
{formLevels.map((l) => (
<Badge key={l} variant="outline" className="text-xs gap-1 pr-1">
{LEVEL_LABELS[l]}
<button type="button" onClick={() => removeLevel(l)} className="ml-0.5 rounded-full hover:bg-foreground/10 p-0.5"><X className="w-3 h-3" /></button>
</Badge>
))}
</div>
)}
<p className="text-xs text-muted-foreground">Leave matchers blank to match any value. All non-empty filters must match (AND).</p>
</div>
<div className="space-y-2">
<Label>Apply to</Label>
<SegmentedControl
value={formAppliesTo}
options={[
{ value: 'bell', label: 'Bell' },
{ value: 'external', label: 'External' },
{ value: 'both', label: 'Both' },
]}
onChange={setFormAppliesTo}
ariaLabel="Suppression target"
fullWidth
/>
</div>
<div className="space-y-2">
<Label>Expiration</Label>
<Select value={formExpirationPreset} onValueChange={(v) => setFormExpirationPreset(v as ExpirationPreset)}>
<SelectTrigger><SelectValue /></SelectTrigger>
<SelectContent>
<SelectItem value="forever">Forever</SelectItem>
<SelectItem value="1h">1 hour</SelectItem>
<SelectItem value="24h">24 hours</SelectItem>
<SelectItem value="custom">Custom date</SelectItem>
</SelectContent>
</Select>
{formExpirationPreset === 'custom' && (
<Input type="datetime-local" value={formCustomExpiry} onChange={(e) => setFormCustomExpiry(e.target.value)} />
)}
</div>
<div className="space-y-2">
<div className="flex items-center gap-2">
<TogglePill
checked={formScheduleEnabled}
onChange={setFormScheduleEnabled}
id="mute-rule-schedule"
/>
<Label htmlFor="mute-rule-schedule" className="mb-0">Weekly window (UTC)</Label>
</div>
{formScheduleEnabled && (
<div className="space-y-2 rounded-md border border-border/60 p-3">
<div className="flex flex-wrap gap-1.5">
{DAY_LABELS.map((label, day) => {
const selected = formScheduleDays.includes(day);
return (
<Button
key={label}
type="button"
size="sm"
variant={selected ? 'default' : 'outline'}
className="h-7 px-2 text-xs"
aria-pressed={selected}
aria-label={label}
onClick={() => {
setFormScheduleDays((prev) =>
selected
? prev.filter((d) => d !== day)
: [...prev, day].sort((a, b) => a - b),
);
}}
>
{label}
</Button>
);
})}
</div>
<div className="grid grid-cols-2 gap-2">
<div className="space-y-1">
<Label htmlFor="mute-schedule-start" className="text-xs text-muted-foreground">Start (UTC)</Label>
<Input
id="mute-schedule-start"
type="time"
value={formScheduleStart}
onChange={(e) => setFormScheduleStart(e.target.value)}
/>
</div>
<div className="space-y-1">
<Label htmlFor="mute-schedule-end" className="text-xs text-muted-foreground">End (UTC)</Label>
<Input
id="mute-schedule-end"
type="time"
value={formScheduleEnd}
onChange={(e) => setFormScheduleEnd(e.target.value)}
/>
</div>
</div>
<p className="text-xs text-muted-foreground">
Outside this window the rule does not mute. Cross-midnight windows use the start day only.
</p>
</div>
)}
</div>
<div className="flex items-center gap-2">
<TogglePill checked={formEnabled} onChange={setFormEnabled} id="mute-rule-enabled" />
<span className="text-sm text-stat-value select-none">
{formEnabled ? 'Enabled' : 'Disabled'}
</span>
</div>
</ModalBody>
<ModalFooter
secondary={<Button variant="outline" size="sm" onClick={resetForm}>Cancel</Button>}
primary={
<SettingsPrimaryButton size="sm" onClick={handleSave} disabled={saving}>
{saving ? <><RefreshCw className="w-4 h-4 animate-spin" />Saving</> : editingId ? 'Update' : 'Create'}
</SettingsPrimaryButton>
}
/>
</Modal>
{loading && (
<div className="space-y-3">
<Skeleton className="h-20 w-full rounded-xl" />
<Skeleton className="h-20 w-full rounded-xl" />
</div>
)}
{!loading && rules.length === 0 && (
<SettingsCallout
icon={<BellOff className="h-4 w-4" strokeWidth={1.5} />}
title="No mute rules configured"
subtitle="Alerts follow your routing and global channels unless a mute rule matches."
/>
)}
{!loading && rules.map((rule) => (
<div key={rule.id} className="rounded-lg border border-card-border border-t-card-border-top bg-card text-card-foreground shadow-card-bevel transition-colors hover:border-t-card-border-hover p-4 space-y-3">
<div className="flex items-center justify-between gap-2">
<div className="flex items-center gap-2 min-w-0 flex-wrap">
<BellOff className="w-4 h-4 text-muted-foreground shrink-0" strokeWidth={1.5} />
<span className="font-medium text-sm truncate">{rule.name}</span>
<Badge variant="outline" className="text-[10px] shrink-0">{APPLIES_TO_LABELS[rule.applies_to]}</Badge>
{rule.node_id !== null && (
<Badge variant="secondary" className="text-[10px] shrink-0 font-mono">
{nodes.find((n) => n.id === rule.node_id)?.name ?? `node:${rule.node_id}`}
</Badge>
)}
{!rule.enabled && <Badge variant="secondary" className="text-[10px] shrink-0 text-muted-foreground">Disabled</Badge>}
{rule.expires_at != null && rule.expires_at <= Date.now() && (
<Badge variant="secondary" className="text-[10px] shrink-0 text-muted-foreground">Expired</Badge>
)}
</div>
<div className="flex items-center gap-1 shrink-0">
<TogglePill checked={rule.enabled} onChange={() => handleToggleEnabled(rule)} className="scale-75" />
<Button variant="ghost" size="sm" onClick={() => startEdit(rule)} title="Edit"><Pencil className="w-4 h-4" strokeWidth={1.5} /></Button>
<Button variant="ghost" size="sm" className="text-destructive/60 hover:bg-destructive hover:text-destructive-foreground" title="Delete" onClick={() => setDeleteRuleId(rule.id)}>
<Trash2 className="w-4 h-4" strokeWidth={1.5} />
</Button>
</div>
</div>
<div className="flex flex-wrap items-center gap-1.5 text-xs text-muted-foreground">
{rule.stack_patterns.map((s) => <Badge key={s} variant="secondary" className="font-mono text-[10px]">{s}</Badge>)}
{rule.label_ids?.map((id) => {
const lbl = labelOptions.find((l) => l.id === id);
return <Badge key={id} variant="outline" className="text-[10px]">{lbl?.name ?? `label:${id}`}</Badge>;
})}
{rule.categories?.map((c) => <Badge key={c} variant="outline" className="text-[10px] font-mono">{CATEGORY_LABELS[c as NotificationCategory] ?? c}</Badge>)}
{rule.levels?.map((l) => <Badge key={l} variant="outline" className="text-[10px]">{LEVEL_LABELS[l]}</Badge>)}
{rule.stack_patterns.length === 0 && !rule.label_ids?.length && !rule.categories?.length && !rule.levels?.length && (
<span className="text-muted-foreground/50 text-[10px]">Matches all alerts</span>
)}
<span className="text-muted-foreground/50">|</span>
<span className="tabular-nums">Expires: {formatExpiry(rule.expires_at)}</span>
{formatScheduleSummary(rule.schedule) && (
<>
<span className="text-muted-foreground/50">|</span>
<span className="tabular-nums">{formatScheduleSummary(rule.schedule)}</span>
</>
)}
</div>
</div>
))}
<ConfirmModal
open={deleteRuleId != null}
onOpenChange={(open) => { if (!open) setDeleteRuleId(null); }}
variant="destructive"
kicker="MUTE RULES · DELETE · IRREVERSIBLE"
title="Delete mute rule"
confirmLabel="Delete"
onConfirm={handleDelete}
>
<p className="text-sm text-stat-subtitle">
Deletes <span className="font-medium text-stat-value">{deleteTarget?.name ?? 'this rule'}</span>. Matching alerts will deliver normally again.
</p>
</ConfirmModal>
</div>
</CapabilityGate>
);
}