mirror of
https://github.com/Studio-Saelix/sencho.git
synced 2026-07-26 11:49:16 +00:00
1d1fba0e33
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.
725 lines
36 KiB
TypeScript
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>
|
|
);
|
|
}
|