import { useState, useEffect } from 'react'; import { SystemSheet, SheetSection } from '@/components/ui/system-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 { TogglePill } from '@/components/ui/toggle-pill'; import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from '@/components/ui/tooltip'; import { Trash2, HelpCircle, AlertTriangle, Info, CheckCircle2, Loader2, ChevronDown, ChevronUp } 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'; import { useLicense } from '@/context/LicenseContext'; interface StackAlert { id?: number; stack_name: string; metric: string; operator: string; threshold: number; duration_mins: number; cooldown_mins: number; } interface AutoHealPolicy { id?: number; node_id: number; proxy_entitled_until: number; stack_name: string; service_name: string | null; unhealthy_duration_mins: number; cooldown_mins: number; max_restarts_per_hour: number; auto_disable_after_failures: number; enabled: number; consecutive_failures: number; last_fired_at: number; created_at: number; updated_at: number; } interface AutoHealHistoryEntry { id?: number; policy_id: number; stack_name: string; service_name: string | null; container_name: string; container_id: string; action: 'restarted' | 'skipped_user_action' | 'skipped_cooldown' | 'skipped_rate_limit' | 'failed' | 'policy_auto_disabled' | 'docker_unavailable'; reason: string; success: number; error: string | null; timestamp: number; } type MonitorTab = 'alerts' | 'auto-heal'; interface StackAlertSheetProps { open: boolean; onOpenChange: (open: boolean) => void; stackName: string; initialTab?: MonitorTab; } 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/s)' }, { value: 'net_tx', label: 'Network Out (MB/s)' }, { 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); }; function actionColorClass(action: AutoHealHistoryEntry['action']): string { if (action === 'restarted') return 'text-success'; if (action === 'failed' || action === 'policy_auto_disabled') return 'text-destructive'; return 'text-muted-foreground'; } function actionLabel(action: AutoHealHistoryEntry['action']): string { switch (action) { case 'restarted': return 'Restarted'; case 'skipped_user_action': return 'Skipped (user action)'; case 'skipped_cooldown': return 'Skipped (cooldown)'; case 'skipped_rate_limit': return 'Skipped (rate limit)'; case 'failed': return 'Failed'; case 'policy_auto_disabled': return 'Auto-disabled'; case 'docker_unavailable': return 'Docker unavailable'; } } export function StackAlertSheet({ open, onOpenChange, stackName, initialTab = 'alerts' }: StackAlertSheetProps) { const { isPaid } = useLicense(); // Per Sencho convention: paid features hide their trigger entirely. Community users // never see the Auto-heal tab, and a stray initialTab='auto-heal' falls back to alerts. const effectiveInitialTab: MonitorTab = !isPaid && initialTab === 'auto-heal' ? 'alerts' : initialTab; const [activeTab, setActiveTab] = useState(effectiveInitialTab); useEffect(() => { if (open) setActiveTab(effectiveInitialTab); }, [open, effectiveInitialTab, stackName]); const tabs = isPaid ? [ { id: 'alerts', label: 'Alerts' }, { id: 'auto-heal', label: 'Auto-heal' }, ] : [{ id: 'alerts', label: 'Alerts' }]; return ( setActiveTab(id as MonitorTab)} size="md" > {activeTab === 'alerts' && } {activeTab === 'auto-heal' && isPaid && } ); } function AlertsTab({ stackName }: { stackName: string }) { 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: [], }); 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 (!stackName) return; fetchAlerts(); fetchAgentStatus(); }, [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 { 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 ( {renderAgentStatusBanner()} {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 && ( )}
))}
)}
{isAdmin && (

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

)} !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
); } function AutoHealTab({ stackName, open }: { stackName: string; open: boolean }) { const [policies, setPolicies] = useState([]); const [loading, setLoading] = useState(false); const [saving, setSaving] = useState(false); const [deleting, setDeleting] = useState(false); const [serviceOptions, setServiceOptions] = useState<{ value: string; label: string }[]>([]); const [service, setService] = useState(''); const [unhealthyFor, setUnhealthyFor] = useState('5'); const [cooldown, setCooldown] = useState('5'); const [maxRestarts, setMaxRestarts] = useState('3'); const [autoDisableAfter, setAutoDisableAfter] = useState('5'); useEffect(() => { if (!open || !stackName) return; setLoading(true); apiFetch(`/auto-heal/policies?stackName=${encodeURIComponent(stackName)}`) .then(res => res.json() as Promise) .then(data => setPolicies(data)) .catch(() => toast.error('Failed to load auto-heal policies.')) .finally(() => setLoading(false)); apiFetch(`/stacks/${encodeURIComponent(stackName)}/services`) .then(res => res.json() as Promise) .then(names => setServiceOptions(names.map(n => ({ value: n, label: n })))) .catch(() => { /* services list is optional, silently skip */ }); }, [open, stackName]); const handleToggle = async (id: number, enabled: boolean) => { setSaving(true); try { const res = await apiFetch(`/auto-heal/policies/${id}`, { method: 'PATCH', body: JSON.stringify({ enabled: enabled ? 1 : 0 }), }); if (res.ok) { setPolicies(prev => prev.map(p => p.id === id ? { ...p, enabled: enabled ? 1 : 0 } : p) ); } else { const err = await res.json().catch(() => ({})) as Record; toast.error((err?.message as string) || (err?.error as string) || 'Failed to update policy.'); } } catch (e) { console.error('[StackAlertSheet] Failed to toggle policy:', e); toast.error('Network error. Could not reach the node.'); } finally { setSaving(false); } }; const handleDelete = async (id: number) => { setDeleting(true); try { const res = await apiFetch(`/auto-heal/policies/${id}`, { method: 'DELETE' }); if (res.ok) { toast.success('Policy deleted.'); setPolicies(prev => prev.filter(p => p.id !== id)); } else { const err = await res.json().catch(() => ({})) as Record; toast.error((err?.message as string) || (err?.error as string) || 'Failed to delete policy.'); } } catch (e) { console.error('[StackAlertSheet] Failed to delete policy:', e); toast.error('Network error. Could not reach the node.'); } finally { setDeleting(false); } }; const handleAddPolicy = async () => { setSaving(true); const body = { stack_name: stackName, service_name: service === '' ? null : service, unhealthy_duration_mins: parseInt(unhealthyFor, 10) || 5, cooldown_mins: parseInt(cooldown, 10) || 5, max_restarts_per_hour: parseInt(maxRestarts, 10) || 3, auto_disable_after_failures: parseInt(autoDisableAfter, 10) || 5, }; try { const res = await apiFetch('/auto-heal/policies', { method: 'POST', body: JSON.stringify(body), }); if (res.ok) { toast.success('Policy added.'); setService(''); setUnhealthyFor('5'); setCooldown('5'); setMaxRestarts('3'); setAutoDisableAfter('5'); apiFetch(`/auto-heal/policies?stackName=${encodeURIComponent(stackName)}`) .then(res => res.json() as Promise) .then(data => setPolicies(data)) .catch(() => toast.error('Failed to reload policies.')); } else { const err = await res.json().catch(() => ({})) as Record; toast.error((err?.message as string) || (err?.error as string) || 'Failed to add policy.'); console.error('[StackAlertSheet] addPolicy failed:', err); } } catch (e) { console.error('[StackAlertSheet] addPolicy threw:', e); toast.error('Network error. Could not reach the node.'); } finally { setSaving(false); } }; const serviceComboOptions = [ { value: '', label: 'All services' }, ...serviceOptions, ]; return ( <> {loading ? (
Loading policies...
) : policies.length === 0 ? (
No auto-heal policies configured for this stack.
) : (
{policies.map(policy => ( ))}
)}
); } interface PolicyRowProps { policy: AutoHealPolicy; onDelete: (id: number) => void; onToggle: (id: number, enabled: boolean) => void; deleting: boolean; saving: boolean; } function PolicyRow({ policy, onDelete, onToggle, deleting, saving }: PolicyRowProps) { const [historyOpen, setHistoryOpen] = useState(false); const [history, setHistory] = useState([]); const [loadingHistory, setLoadingHistory] = useState(false); const toggleHistory = async () => { if (!historyOpen && history.length === 0 && policy.id != null) { setLoadingHistory(true); try { const res = await apiFetch(`/auto-heal/policies/${policy.id}/history`); if (res.ok) { const data: AutoHealHistoryEntry[] = await res.json(); setHistory(data); } else { const err = await res.json().catch(() => ({})) as Record; toast.error((err?.message as string) || (err?.error as string) || 'Failed to load history.'); } } catch (e) { console.error('[StackAlertSheet] Failed to fetch history:', e); toast.error('Network error. Could not reach the node.'); } finally { setLoadingHistory(false); } } setHistoryOpen(prev => !prev); }; return (
{policy.service_name ?? All services} Unhealthy for {policy.unhealthy_duration_mins} min • Cooldown: {policy.cooldown_mins} min • Max {policy.max_restarts_per_hour}/hr {policy.consecutive_failures > 0 && ( {policy.consecutive_failures} failure{policy.consecutive_failures !== 1 ? 's' : ''} )}
policy.id != null && onToggle(policy.id, checked)} disabled={saving} aria-label={`Toggle policy for ${policy.service_name ?? 'all services'}`} />
{historyOpen && (

Recent activity

{history.length === 0 ? (

No history yet.

) : ( history.map((entry) => (
{new Date(entry.timestamp).toLocaleString()} {entry.container_name} {actionLabel(entry.action)} {entry.reason}
)) )}
)}
); }