import { useState, useEffect } from 'react'; import { Sheet, SheetContent, SheetDescription, 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 { 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'; import { apiFetch } from '@/lib/api'; import { useNodes } from '@/context/NodeContext'; import { useAuth } from '@/context/AuthContext'; interface StackAlert { id?: number; stack_name: string; metric: string; operator: string; threshold: number; duration_mins: number; cooldown_mins: number; } interface StackAlertSheetProps { isOpen: boolean; onClose: () => void; stackName: string; } interface AgentStatus { loading: boolean; hasEnabled: boolean; 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 = Object.fromEntries(metricOptions.map(o => [o.value, o.label])); const agentTypeLabels: Record = { discord: 'Discord', slack: 'Slack', webhook: 'Webhook', }; const clampNonNegative = (setter: (v: string) => void) => (e: React.ChangeEvent) => { 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(); const isRemote = activeNode?.type === 'remote'; const [alerts, setAlerts] = useState([]); const [isLoading, setIsLoading] = useState(false); const [confirmDeleteId, setConfirmDeleteId] = useState(null); const [agentStatus, setAgentStatus] = useState({ loading: false, hasEnabled: false, enabledTypes: [], }); // New Alert Form State const [metric, setMetric] = useState('cpu_percent'); const [operator, setOperator] = useState('>'); const [threshold, setThreshold] = useState(''); const [duration, setDuration] = useState('5'); const [cooldown, setCooldown] = useState('60'); useEffect(() => { if (isOpen && stackName) { fetchAlerts(); fetchAgentStatus(); } }, [isOpen, stackName]); // eslint-disable-line react-hooks/exhaustive-deps const fetchAlerts = async () => { try { const res = await apiFetch(`/alerts?stackName=${encodeURIComponent(stackName)}`); if (res.ok) { const data = await res.json(); setAlerts(data); } } catch (e) { console.error('[StackAlertSheet] Failed to fetch alerts', e); } }; const fetchAgentStatus = async () => { setAgentStatus(prev => ({ ...prev, loading: true })); try { // Always fetch agents from the active node (proxied via x-node-id for remote) const res = await apiFetch('/agents'); if (res.ok) { const agents: Array<{ type: string; enabled: boolean }> = await res.json(); const enabled = agents.filter(a => a.enabled); setAgentStatus({ loading: false, hasEnabled: enabled.length > 0, enabledTypes: enabled.map(a => a.type), }); } else { setAgentStatus({ loading: false, hasEnabled: false, enabledTypes: [] }); } } catch (e) { console.error('[StackAlertSheet] Failed to fetch agent status', e); setAgentStatus({ loading: false, hasEnabled: false, enabledTypes: [] }); } }; const addAlert = async () => { if (!threshold) { toast.error('Please enter a threshold.'); return; } setIsLoading(true); const newAlert = { stack_name: stackName, metric, operator, threshold: parseFloat(threshold), duration_mins: parseInt(duration, 10), cooldown_mins: parseInt(cooldown, 10), }; try { const res = await apiFetch('/alerts', { method: 'POST', body: JSON.stringify(newAlert), }); if (res.ok) { toast.success('Alert rule added.'); setThreshold(''); fetchAlerts(); } else { const err = await res.json().catch(() => ({})); toast.error(err?.error || err?.message || 'Failed to add alert rule.'); console.error('[StackAlertSheet] addAlert failed:', err); } } catch (e) { console.error('[StackAlertSheet] addAlert threw:', e); toast.error('Network error. Could not reach the node.'); } finally { setIsLoading(false); } }; const deleteAlert = async (id: number) => { setIsLoading(true); try { const res = await apiFetch(`/alerts/${id}`, { method: 'DELETE' }); if (res.ok) { toast.success('Alert rule deleted.'); fetchAlerts(); } else { const err = await res.json().catch(() => ({})); toast.error(err?.error || 'Failed to delete alert rule.'); } } catch { toast.error('Network error. Could not reach the node.'); } finally { setIsLoading(false); } }; const renderAgentStatusBanner = () => { if (agentStatus.loading) { return (
Checking notification channels...
); } if (isRemote) { return (

Remote node: {activeNode?.name}

Alert rules are stored and evaluated on this remote instance. Notifications are dispatched using that node's configured channels.

{!agentStatus.hasEnabled && (

No notification channels are configured on this remote node. Open Settings → Notifications to configure them.

)} {agentStatus.hasEnabled && (

Active channels: {agentStatus.enabledTypes.map(t => agentTypeLabels[t] ?? t).join(', ')}

)}
); } if (!agentStatus.hasEnabled) { return (

No notification channels configured

Alert rules will be saved and evaluated, but no notifications will be dispatched. Configure Discord, Slack, or a webhook in{' '} Settings → Notifications.

); } return (

Notifications active via {agentStatus.enabledTypes.map(t => agentTypeLabels[t] ?? t).join(', ')}

); }; return ( <> !open && onClose()}> Stack Alerts: {stackName} Configure metric thresholds to trigger notifications for this stack.
{/* Notification agent status banner */} {renderAgentStatusBanner()} {/* List Existing Alerts */}

Existing Rules

{alerts.length === 0 ? (
No active alert rules for this stack.
) : ( alerts.map(alert => (
{metricLabels[alert.metric] || alert.metric} {alert.operator} {alert.threshold}
Trigger after {alert.duration_mins}m • Cooldown: {alert.cooldown_mins}m
{isAdmin && }
)) )}

{/* Add New Alert Form */} {isAdmin &&

Add New Rule

The system resource or metric to monitor. Select from CPU, Memory, Network I/O, or Restarts.

The comparison condition to trigger the alert against the threshold.

The numerical value the metric needs to breach to trigger the conditions.

How long the metric must stay in breach of the threshold before sending an alert.

How long to wait before sending another alert if the stack continues to breach.

}
!open && setConfirmDeleteId(null)}> Delete Alert Rule This will permanently remove this alert rule. Notifications for this condition will no longer be sent. Cancel { if (confirmDeleteId) deleteAlert(confirmDeleteId); setConfirmDeleteId(null); }} > Delete ); }