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 { Switch } from '@/components/ui/switch'; import { Combobox } from '@/components/ui/combobox'; import { ScrollArea } from '@/components/ui/scroll-area'; import { Trash2, ChevronDown, ChevronUp, Loader2 } from 'lucide-react'; import { toast } from '@/components/ui/toast-store'; import { apiFetch } from '@/lib/api'; import { PaidGate } from '@/components/PaidGate'; interface AutoHealPolicy { id?: 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'; reason: string; success: number; error: string | null; timestamp: number; } interface StackAutoHealSheetProps { stackName: string; open: boolean; onOpenChange: (open: boolean) => void; } 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'; } } 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('[StackAutoHealSheet] 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}
)) )}
)}
); } export function StackAutoHealSheet({ stackName, open, onOpenChange }: StackAutoHealSheetProps) { 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 }[]>([]); // Form state 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('[StackAutoHealSheet] 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('[StackAutoHealSheet] 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('[StackAutoHealSheet] addPolicy failed:', err); } } catch (e) { console.error('[StackAutoHealSheet] addPolicy threw:', e); toast.error('Network error. Could not reach the node.'); } finally { setSaving(false); } }; const serviceComboOptions = [ { value: '', label: 'All services' }, ...serviceOptions, ]; return ( Auto-Heal Policies: {stackName} Configure auto-heal policies to automatically restart unhealthy containers in this stack.
{/* Existing policies */}

Active Policies

{loading ? (
Loading policies...
) : policies.length === 0 ? (
No auto-heal policies configured for this stack.
) : ( policies.map(policy => ( )) )}

{/* Add new policy form */}

Add New Policy

); }