mirror of
https://github.com/Studio-Saelix/sencho.git
synced 2026-08-24 08:57:25 +00:00
fix(alerts): harden with security fixes, design compliance, and test coverage (#570)
* fix(alerts): harden with security fixes, design compliance, and test coverage Add authMiddleware to all alert endpoints, validate notification test dispatch inputs, fix restart_count metric via Docker inspect, correct network metric units, replace any types with DockerContainerStats interface, add webhook timeouts and dispatch error tracking. Frontend: migrate Select to Combobox, add ScrollArea and delete confirmation AlertDialog, fix icon strokeWidth to 1.5. Add update availability notifications for both Sencho version updates (6-hour check in MonitorService) and stack image updates (state transition detection in ImageUpdateService). Extract shared version fetch logic into utils/version-check.ts. Add diagnostic logging gated behind developer_mode for MonitorService breach state machine and NotificationService dispatch routing. Tests: 24 new alert API integration tests, restart_count and version check unit tests (688 total passing). Docs updated with HTTPS requirement, update notifications section, and troubleshooting guide. * fix(alerts): remove unused TEST_USERNAME import in alerts-api tests
This commit is contained in:
@@ -6,10 +6,21 @@ import {
|
||||
SheetHeader,
|
||||
SheetTitle,
|
||||
} from '@/components/ui/sheet';
|
||||
import {
|
||||
AlertDialog,
|
||||
AlertDialogAction,
|
||||
AlertDialogCancel,
|
||||
AlertDialogContent,
|
||||
AlertDialogDescription,
|
||||
AlertDialogFooter,
|
||||
AlertDialogHeader,
|
||||
AlertDialogTitle,
|
||||
} from '@/components/ui/alert-dialog';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Input } from '@/components/ui/input';
|
||||
import { Label } from '@/components/ui/label';
|
||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select';
|
||||
import { Combobox } from '@/components/ui/combobox';
|
||||
import { ScrollArea } from '@/components/ui/scroll-area';
|
||||
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from '@/components/ui/tooltip';
|
||||
import { Trash2, HelpCircle, AlertTriangle, Info, CheckCircle2, Loader2 } from 'lucide-react';
|
||||
import { toast } from '@/components/ui/toast-store';
|
||||
@@ -39,6 +50,37 @@ interface AgentStatus {
|
||||
enabledTypes: string[];
|
||||
}
|
||||
|
||||
const metricOptions = [
|
||||
{ value: 'cpu_percent', label: 'CPU Usage (%)' },
|
||||
{ value: 'memory_percent', label: 'Memory Usage (%)' },
|
||||
{ value: 'memory_mb', label: 'Memory Usage (MB)' },
|
||||
{ value: 'net_rx', label: 'Network In (MB)' },
|
||||
{ value: 'net_tx', label: 'Network Out (MB)' },
|
||||
{ value: 'restart_count', label: 'Restart Count' },
|
||||
];
|
||||
|
||||
const operatorOptions = [
|
||||
{ value: '>', label: 'Greater than' },
|
||||
{ value: '>=', label: 'Greater or eq' },
|
||||
{ value: '<', label: 'Less than' },
|
||||
{ value: '<=', label: 'Less or eq' },
|
||||
{ value: '==', label: 'Equals' },
|
||||
];
|
||||
|
||||
const metricLabels: Record<string, string> = Object.fromEntries(metricOptions.map(o => [o.value, o.label]));
|
||||
|
||||
const agentTypeLabels: Record<string, string> = {
|
||||
discord: 'Discord',
|
||||
slack: 'Slack',
|
||||
webhook: 'Webhook',
|
||||
};
|
||||
|
||||
const clampNonNegative = (setter: (v: string) => void) => (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
let val = e.target.value;
|
||||
if (val !== '' && Number(val) < 0) val = '0';
|
||||
setter(val);
|
||||
};
|
||||
|
||||
export function StackAlertSheet({ isOpen, onClose, stackName }: StackAlertSheetProps) {
|
||||
const { isAdmin } = useAuth();
|
||||
const { activeNode } = useNodes();
|
||||
@@ -46,6 +88,7 @@ export function StackAlertSheet({ isOpen, onClose, stackName }: StackAlertSheetP
|
||||
|
||||
const [alerts, setAlerts] = useState<StackAlert[]>([]);
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
const [confirmDeleteId, setConfirmDeleteId] = useState<number | null>(null);
|
||||
const [agentStatus, setAgentStatus] = useState<AgentStatus>({
|
||||
loading: false,
|
||||
hasEnabled: false,
|
||||
@@ -156,27 +199,12 @@ export function StackAlertSheet({ isOpen, onClose, stackName }: StackAlertSheetP
|
||||
}
|
||||
};
|
||||
|
||||
const metricLabels: Record<string, string> = {
|
||||
cpu_percent: 'CPU Usage (%)',
|
||||
memory_percent: 'Memory Usage (%)',
|
||||
memory_mb: 'Memory Usage (MB)',
|
||||
net_rx: 'Network In (MB)',
|
||||
net_tx: 'Network Out (MB)',
|
||||
restart_count: 'Restart Count',
|
||||
};
|
||||
|
||||
const agentTypeLabels: Record<string, string> = {
|
||||
discord: 'Discord',
|
||||
slack: 'Slack',
|
||||
webhook: 'Webhook',
|
||||
};
|
||||
|
||||
const renderAgentStatusBanner = () => {
|
||||
if (agentStatus.loading) {
|
||||
return (
|
||||
<div className="flex items-center gap-2 p-3 rounded-lg bg-muted/50 border text-sm text-muted-foreground">
|
||||
<Loader2 className="h-4 w-4 animate-spin shrink-0" />
|
||||
<span>Checking notification channels…</span>
|
||||
<span>Checking notification channels...</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -184,7 +212,7 @@ export function StackAlertSheet({ isOpen, onClose, stackName }: StackAlertSheetP
|
||||
if (isRemote) {
|
||||
return (
|
||||
<div className="flex items-start gap-2 p-3 rounded-lg bg-info-muted border border-info/20 text-sm">
|
||||
<Info className="h-4 w-4 text-info shrink-0 mt-0.5" />
|
||||
<Info className="h-4 w-4 text-info shrink-0 mt-0.5" strokeWidth={1.5} />
|
||||
<div className="space-y-0.5">
|
||||
<p className="font-medium text-info">
|
||||
Remote node: <span className="font-medium">{activeNode?.name}</span>
|
||||
@@ -194,7 +222,7 @@ export function StackAlertSheet({ isOpen, onClose, stackName }: StackAlertSheetP
|
||||
</p>
|
||||
{!agentStatus.hasEnabled && (
|
||||
<p className="text-warning font-medium mt-1">
|
||||
No notification channels are configured on this remote node. Open Settings → Notifications to configure them.
|
||||
No notification channels are configured on this remote node. Open Settings → Notifications to configure them.
|
||||
</p>
|
||||
)}
|
||||
{agentStatus.hasEnabled && (
|
||||
@@ -210,12 +238,12 @@ export function StackAlertSheet({ isOpen, onClose, stackName }: StackAlertSheetP
|
||||
if (!agentStatus.hasEnabled) {
|
||||
return (
|
||||
<div className="flex items-start gap-2 p-3 rounded-lg bg-warning-muted border border-warning/20 text-sm">
|
||||
<AlertTriangle className="h-4 w-4 text-warning shrink-0 mt-0.5" />
|
||||
<AlertTriangle className="h-4 w-4 text-warning shrink-0 mt-0.5" strokeWidth={1.5} />
|
||||
<div>
|
||||
<p className="font-medium text-warning">No notification channels configured</p>
|
||||
<p className="text-muted-foreground mt-0.5">
|
||||
Alert rules will be saved and evaluated, but no notifications will be dispatched. Configure Discord, Slack, or a webhook in{' '}
|
||||
<span className="font-medium">Settings → Notifications</span>.
|
||||
<span className="font-medium">Settings → Notifications</span>.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
@@ -224,7 +252,7 @@ export function StackAlertSheet({ isOpen, onClose, stackName }: StackAlertSheetP
|
||||
|
||||
return (
|
||||
<div className="flex items-center gap-2 p-3 rounded-lg bg-success-muted border border-success/20 text-sm">
|
||||
<CheckCircle2 className="h-4 w-4 text-success shrink-0" />
|
||||
<CheckCircle2 className="h-4 w-4 text-success shrink-0" strokeWidth={1.5} />
|
||||
<div>
|
||||
<p className="font-medium text-success">
|
||||
Notifications active via {agentStatus.enabledTypes.map(t => agentTypeLabels[t] ?? t).join(', ')}
|
||||
@@ -235,196 +263,199 @@ export function StackAlertSheet({ isOpen, onClose, stackName }: StackAlertSheetP
|
||||
};
|
||||
|
||||
return (
|
||||
<Sheet open={isOpen} onOpenChange={(open) => !open && onClose()}>
|
||||
<SheetContent className="overflow-y-auto sm:max-w-[420px]">
|
||||
<SheetHeader>
|
||||
<SheetTitle>Stack Alerts: {stackName}</SheetTitle>
|
||||
<SheetDescription>
|
||||
Configure metric thresholds to trigger notifications for this stack.
|
||||
</SheetDescription>
|
||||
</SheetHeader>
|
||||
<>
|
||||
<Sheet open={isOpen} onOpenChange={(open) => !open && onClose()}>
|
||||
<SheetContent className="sm:max-w-[420px] flex flex-col">
|
||||
<SheetHeader>
|
||||
<SheetTitle>Stack Alerts: {stackName}</SheetTitle>
|
||||
<SheetDescription>
|
||||
Configure metric thresholds to trigger notifications for this stack.
|
||||
</SheetDescription>
|
||||
</SheetHeader>
|
||||
|
||||
<TooltipProvider>
|
||||
<div className="mt-4 space-y-5">
|
||||
{/* Notification agent status banner */}
|
||||
{renderAgentStatusBanner()}
|
||||
<ScrollArea className="flex-1">
|
||||
<TooltipProvider>
|
||||
<div className="mt-4 space-y-5 pr-2">
|
||||
{/* Notification agent status banner */}
|
||||
{renderAgentStatusBanner()}
|
||||
|
||||
{/* List Existing Alerts */}
|
||||
<div className="space-y-3">
|
||||
<h4 className="text-sm font-medium">Existing Rules</h4>
|
||||
{alerts.length === 0 ? (
|
||||
<div className="text-sm text-muted-foreground p-4 bg-muted/50 rounded-lg text-center">
|
||||
No active alert rules for this stack.
|
||||
</div>
|
||||
) : (
|
||||
alerts.map(alert => (
|
||||
<div key={alert.id} className="flex flex-col gap-2 p-3 bg-muted/50 rounded-lg border text-sm">
|
||||
<div className="flex justify-between items-start">
|
||||
<div>
|
||||
<span className="font-medium text-foreground">
|
||||
{metricLabels[alert.metric] || alert.metric} {alert.operator} {alert.threshold}
|
||||
</span>
|
||||
<div className="text-muted-foreground mt-1">
|
||||
Trigger after {alert.duration_mins}m • Cooldown: {alert.cooldown_mins}m
|
||||
{/* List Existing Alerts */}
|
||||
<div className="space-y-3">
|
||||
<h4 className="text-sm font-medium">Existing Rules</h4>
|
||||
{alerts.length === 0 ? (
|
||||
<div className="text-sm text-muted-foreground p-4 bg-muted/50 rounded-lg text-center">
|
||||
No active alert rules for this stack.
|
||||
</div>
|
||||
) : (
|
||||
alerts.map(alert => (
|
||||
<div key={alert.id} className="flex flex-col gap-2 p-3 bg-muted/50 rounded-lg border text-sm">
|
||||
<div className="flex justify-between items-start">
|
||||
<div>
|
||||
<span className="font-medium text-foreground">
|
||||
{metricLabels[alert.metric] || alert.metric} {alert.operator} {alert.threshold}
|
||||
</span>
|
||||
<div className="text-muted-foreground mt-1">
|
||||
Trigger after {alert.duration_mins}m • Cooldown: {alert.cooldown_mins}m
|
||||
</div>
|
||||
</div>
|
||||
{isAdmin && <Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="h-8 w-8 text-muted-foreground hover:text-destructive shrink-0"
|
||||
onClick={() => alert.id && setConfirmDeleteId(alert.id)}
|
||||
disabled={isLoading}
|
||||
>
|
||||
<Trash2 className="h-4 w-4" strokeWidth={1.5} />
|
||||
</Button>}
|
||||
</div>
|
||||
</div>
|
||||
{isAdmin && <Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="h-8 w-8 text-muted-foreground hover:text-destructive shrink-0"
|
||||
onClick={() => alert.id && deleteAlert(alert.id)}
|
||||
disabled={isLoading}
|
||||
>
|
||||
<Trash2 className="h-4 w-4" />
|
||||
</Button>}
|
||||
))
|
||||
)}
|
||||
</div>
|
||||
|
||||
<hr />
|
||||
|
||||
{/* Add New Alert Form */}
|
||||
{isAdmin && <div className="space-y-4">
|
||||
<h4 className="text-sm font-medium">Add New Rule</h4>
|
||||
|
||||
<div className="space-y-2">
|
||||
<div className="flex items-center gap-2">
|
||||
<Label>Metric</Label>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<HelpCircle className="h-4 w-4 text-muted-foreground cursor-help" strokeWidth={1.5} />
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>
|
||||
<p className="max-w-[200px] text-sm">The system resource or metric to monitor. Select from CPU, Memory, Network I/O, or Restarts.</p>
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
</div>
|
||||
<Combobox
|
||||
options={metricOptions}
|
||||
value={metric}
|
||||
onValueChange={setMetric}
|
||||
placeholder="Select metric..."
|
||||
searchPlaceholder="Search metrics..."
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<div className="space-y-2">
|
||||
<div className="flex items-center gap-2">
|
||||
<Label>Operator</Label>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<HelpCircle className="h-4 w-4 text-muted-foreground cursor-help" strokeWidth={1.5} />
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>
|
||||
<p className="max-w-[200px] text-sm">The comparison condition to trigger the alert against the threshold.</p>
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
</div>
|
||||
<Combobox
|
||||
options={operatorOptions}
|
||||
value={operator}
|
||||
onValueChange={setOperator}
|
||||
placeholder="Select operator..."
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<div className="flex items-center gap-2">
|
||||
<Label>Threshold</Label>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<HelpCircle className="h-4 w-4 text-muted-foreground cursor-help" strokeWidth={1.5} />
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>
|
||||
<p className="max-w-[200px] text-sm">The numerical value the metric needs to breach to trigger the conditions.</p>
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
</div>
|
||||
<Input
|
||||
type="number"
|
||||
min={0}
|
||||
value={threshold}
|
||||
onChange={clampNonNegative(setThreshold)}
|
||||
placeholder="e.g. 90"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
))
|
||||
)}
|
||||
</div>
|
||||
|
||||
<hr />
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<div className="space-y-2">
|
||||
<div className="flex items-center gap-2">
|
||||
<Label>Duration (mins)</Label>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<HelpCircle className="h-4 w-4 text-muted-foreground cursor-help" strokeWidth={1.5} />
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>
|
||||
<p className="max-w-[200px] text-sm">How long the metric must stay in breach of the threshold before sending an alert.</p>
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
</div>
|
||||
<Input
|
||||
type="number"
|
||||
min={0}
|
||||
value={duration}
|
||||
onChange={clampNonNegative(setDuration)}
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<div className="flex items-center gap-2">
|
||||
<Label>Cooldown (mins)</Label>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<HelpCircle className="h-4 w-4 text-muted-foreground cursor-help" strokeWidth={1.5} />
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>
|
||||
<p className="max-w-[200px] text-sm">How long to wait before sending another alert if the stack continues to breach.</p>
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
</div>
|
||||
<Input
|
||||
type="number"
|
||||
min={0}
|
||||
value={cooldown}
|
||||
onChange={clampNonNegative(setCooldown)}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Add New Alert Form */}
|
||||
{isAdmin && <div className="space-y-4">
|
||||
<h4 className="text-sm font-medium">Add New Rule</h4>
|
||||
|
||||
<div className="space-y-2">
|
||||
<div className="flex items-center gap-2">
|
||||
<Label>Metric</Label>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<HelpCircle className="h-4 w-4 text-muted-foreground cursor-help" />
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>
|
||||
<p className="max-w-[200px] text-sm">The system resource or metric to monitor. Select from CPU, Memory, Network I/O, or Restarts.</p>
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
</div>
|
||||
<Select value={metric} onValueChange={setMetric}>
|
||||
<SelectTrigger>
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{Object.entries(metricLabels).map(([val, label]) => (
|
||||
<SelectItem key={val} value={val}>{label}</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<Button className="w-full mt-2" onClick={addAlert} disabled={isLoading}>
|
||||
{isLoading ? (
|
||||
<><Loader2 className="h-4 w-4 mr-2 animate-spin" />Saving...</>
|
||||
) : (
|
||||
'Add Rule'
|
||||
)}
|
||||
</Button>
|
||||
</div>}
|
||||
</div>
|
||||
</TooltipProvider>
|
||||
</ScrollArea>
|
||||
</SheetContent>
|
||||
</Sheet>
|
||||
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<div className="space-y-2">
|
||||
<div className="flex items-center gap-2">
|
||||
<Label>Operator</Label>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<HelpCircle className="h-4 w-4 text-muted-foreground cursor-help" />
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>
|
||||
<p className="max-w-[200px] text-sm">The comparison condition to trigger the alert against the threshold.</p>
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
</div>
|
||||
<Select value={operator} onValueChange={setOperator}>
|
||||
<SelectTrigger>
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value=">">Greater than</SelectItem>
|
||||
<SelectItem value=">=">Greater or eq</SelectItem>
|
||||
<SelectItem value="<">Less than</SelectItem>
|
||||
<SelectItem value="<=">Less or eq</SelectItem>
|
||||
<SelectItem value="==">Equals</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<div className="flex items-center gap-2">
|
||||
<Label>Threshold</Label>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<HelpCircle className="h-4 w-4 text-muted-foreground cursor-help" />
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>
|
||||
<p className="max-w-[200px] text-sm">The numerical value the metric needs to breach to trigger the conditions.</p>
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
</div>
|
||||
<Input
|
||||
type="number"
|
||||
min={0}
|
||||
value={threshold}
|
||||
onChange={e => {
|
||||
let val = e.target.value;
|
||||
if (val !== '' && Number(val) < 0) val = '0';
|
||||
setThreshold(val);
|
||||
}}
|
||||
placeholder="e.g. 90"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<div className="space-y-2">
|
||||
<div className="flex items-center gap-2">
|
||||
<Label>Duration (mins)</Label>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<HelpCircle className="h-4 w-4 text-muted-foreground cursor-help" />
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>
|
||||
<p className="max-w-[200px] text-sm">How long the metric must stay in breach of the threshold before sending an alert.</p>
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
</div>
|
||||
<Input
|
||||
type="number"
|
||||
min={0}
|
||||
value={duration}
|
||||
onChange={e => {
|
||||
let val = e.target.value;
|
||||
if (val !== '' && Number(val) < 0) val = '0';
|
||||
setDuration(val);
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<div className="flex items-center gap-2">
|
||||
<Label>Cooldown (mins)</Label>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<HelpCircle className="h-4 w-4 text-muted-foreground cursor-help" />
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>
|
||||
<p className="max-w-[200px] text-sm">How long to wait before sending another alert if the stack continues to breach.</p>
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
</div>
|
||||
<Input
|
||||
type="number"
|
||||
min={0}
|
||||
value={cooldown}
|
||||
onChange={e => {
|
||||
let val = e.target.value;
|
||||
if (val !== '' && Number(val) < 0) val = '0';
|
||||
setCooldown(val);
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Button className="w-full mt-2" onClick={addAlert} disabled={isLoading}>
|
||||
{isLoading ? (
|
||||
<><Loader2 className="h-4 w-4 mr-2 animate-spin" />Saving…</>
|
||||
) : (
|
||||
'Add Rule'
|
||||
)}
|
||||
</Button>
|
||||
</div>}
|
||||
</div>
|
||||
</TooltipProvider>
|
||||
</SheetContent>
|
||||
</Sheet>
|
||||
<AlertDialog open={!!confirmDeleteId} onOpenChange={(open) => !open && setConfirmDeleteId(null)}>
|
||||
<AlertDialogContent>
|
||||
<AlertDialogHeader>
|
||||
<AlertDialogTitle>Delete Alert Rule</AlertDialogTitle>
|
||||
<AlertDialogDescription>
|
||||
This will permanently remove this alert rule. Notifications for this condition will no longer be sent.
|
||||
</AlertDialogDescription>
|
||||
</AlertDialogHeader>
|
||||
<AlertDialogFooter>
|
||||
<AlertDialogCancel>Cancel</AlertDialogCancel>
|
||||
<AlertDialogAction
|
||||
className="bg-destructive text-destructive-foreground hover:bg-destructive/90"
|
||||
onClick={() => { if (confirmDeleteId) deleteAlert(confirmDeleteId); setConfirmDeleteId(null); }}
|
||||
>
|
||||
Delete
|
||||
</AlertDialogAction>
|
||||
</AlertDialogFooter>
|
||||
</AlertDialogContent>
|
||||
</AlertDialog>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user