import { useState, useEffect } from 'react'; import { Sheet, SheetContent, SheetDescription, SheetHeader, SheetTitle, } from '@/components/ui/sheet'; 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 { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from '@/components/ui/tooltip'; import { Trash2, HelpCircle, AlertTriangle, Info, CheckCircle2, Loader2 } from 'lucide-react'; import { toast } from 'sonner'; 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[]; } 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 [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 metricLabels: Record = { 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 = { discord: 'Discord', slack: 'Slack', webhook: 'Webhook', }; 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.

{ let val = e.target.value; if (val !== '' && Number(val) < 0) val = '0'; setThreshold(val); }} placeholder="e.g. 90" />

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

{ let val = e.target.value; if (val !== '' && Number(val) < 0) val = '0'; setDuration(val); }} />

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

{ let val = e.target.value; if (val !== '' && Number(val) < 0) val = '0'; setCooldown(val); }} />
}
); }