import { useState, useEffect, useCallback } from 'react'; import { Camera, ArrowLeft, Server, Layers, FileText, AlertTriangle, Trash2, Eye, ChevronDown, ChevronRight, Plus, Loader2, RotateCcw, } from 'lucide-react'; import { Button } from '@/components/ui/button'; import { Input } from '@/components/ui/input'; import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow, } from '@/components/ui/table'; import { Badge } from '@/components/ui/badge'; import { Skeleton } from '@/components/ui/skeleton'; import { Checkbox } from '@/components/ui/checkbox'; import { Label } from '@/components/ui/label'; import { AlertDialog, AlertDialogAction, AlertDialogCancel, AlertDialogContent, AlertDialogDescription, AlertDialogFooter, AlertDialogHeader, AlertDialogTitle, AlertDialogTrigger, } from '@/components/ui/alert-dialog'; import { apiFetch } from '@/lib/api'; import { useAuth } from '@/context/AuthContext'; import { toast } from 'sonner'; // --- Types --- interface FleetSnapshot { id: number; description: string; created_by: string; node_count: number; stack_count: number; skipped_nodes: string; // JSON string created_at: number; } interface SnapshotStackFile { filename: string; content: string; } interface SnapshotStack { stackName: string; files: SnapshotStackFile[]; } interface SnapshotNode { nodeId: number; nodeName: string; stacks: SnapshotStack[]; } interface FleetSnapshotDetail extends FleetSnapshot { nodes: SnapshotNode[]; } interface SkippedNode { nodeId: number; nodeName: string; reason: string; } // --- Main Component --- export default function FleetSnapshots() { const { isAdmin } = useAuth(); const [snapshots, setSnapshots] = useState([]); const [loading, setLoading] = useState(true); const [creating, setCreating] = useState(false); const [showCreateForm, setShowCreateForm] = useState(false); const [description, setDescription] = useState(''); const [selectedSnapshot, setSelectedSnapshot] = useState(null); const [viewMode, setViewMode] = useState<'list' | 'detail'>('list'); const [loadingDetail, setLoadingDetail] = useState(false); const [expandedNodes, setExpandedNodes] = useState>(new Set()); const [expandedStacks, setExpandedStacks] = useState>(new Set()); const [previewFiles, setPreviewFiles] = useState>(new Set()); const [restoringStack, setRestoringStack] = useState(null); const [deletingId, setDeletingId] = useState(null); // --- Data Fetching --- const fetchSnapshots = useCallback(async () => { try { const res = await apiFetch('/fleet/snapshots', { localOnly: true }); if (res.ok) { const data: { snapshots: FleetSnapshot[]; total: number } = await res.json(); setSnapshots(data.snapshots); } else { const err = await res.json().catch(() => null); toast.error(err?.message || err?.error || err?.data?.error || 'Failed to load snapshots.'); } } catch (error: unknown) { const err = error as Record | null; toast.error(err?.message as string || err?.error as string || 'Something went wrong.'); } finally { setLoading(false); } }, []); useEffect(() => { fetchSnapshots(); }, [fetchSnapshots]); const handleCreate = async () => { setCreating(true); try { const res = await apiFetch('/fleet/snapshots', { method: 'POST', localOnly: true, body: JSON.stringify({ description: description.trim() || undefined }), }); if (res.ok) { toast.success('Snapshot created successfully.'); setShowCreateForm(false); setDescription(''); await fetchSnapshots(); } else { const err = await res.json().catch(() => null); toast.error(err?.message || err?.error || err?.data?.error || 'Failed to create snapshot.'); } } catch (error: unknown) { const err = error as Record | null; toast.error(err?.message as string || err?.error as string || 'Something went wrong.'); } finally { setCreating(false); } }; const handleViewDetail = async (snapshot: FleetSnapshot) => { setLoadingDetail(true); setViewMode('detail'); setExpandedNodes(new Set()); setExpandedStacks(new Set()); setPreviewFiles(new Set()); try { const res = await apiFetch(`/fleet/snapshots/${snapshot.id}`, { localOnly: true }); if (res.ok) { const data: FleetSnapshotDetail = await res.json(); setSelectedSnapshot(data); } else { const err = await res.json().catch(() => null); toast.error(err?.message || err?.error || err?.data?.error || 'Failed to load snapshot details.'); setViewMode('list'); } } catch (error: unknown) { const err = error as Record | null; toast.error(err?.message as string || err?.error as string || 'Something went wrong.'); setViewMode('list'); } finally { setLoadingDetail(false); } }; const handleDelete = async (id: number) => { setDeletingId(id); try { const res = await apiFetch(`/fleet/snapshots/${id}`, { method: 'DELETE', localOnly: true, }); if (res.ok) { toast.success('Snapshot deleted.'); setSnapshots(prev => prev.filter(s => s.id !== id)); } else { const err = await res.json().catch(() => null); toast.error(err?.message || err?.error || err?.data?.error || 'Failed to delete snapshot.'); } } catch (error: unknown) { const err = error as Record | null; toast.error(err?.message as string || err?.error as string || 'Something went wrong.'); } finally { setDeletingId(null); } }; const handleRestore = async (nodeId: number, stackName: string, redeploy: boolean) => { if (!selectedSnapshot) return; const key = `${nodeId}:${stackName}`; setRestoringStack(key); try { const res = await apiFetch(`/fleet/snapshots/${selectedSnapshot.id}/restore`, { method: 'POST', localOnly: true, body: JSON.stringify({ nodeId, stackName, redeploy }), }); if (res.ok) { const data: { message: string; redeployed: boolean } = await res.json(); toast.success(data.redeployed ? 'Stack restored and redeployed.' : 'Stack restored successfully.'); } else { const err = await res.json().catch(() => null); toast.error(err?.message || err?.error || err?.data?.error || 'Failed to restore stack.'); } } catch (error: unknown) { const err = error as Record | null; toast.error(err?.message as string || err?.error as string || 'Something went wrong.'); } finally { setRestoringStack(null); } }; // --- Toggle helpers --- const toggleNode = (nodeId: number) => { setExpandedNodes(prev => { const next = new Set(prev); if (next.has(nodeId)) next.delete(nodeId); else next.add(nodeId); return next; }); }; const toggleStack = (key: string) => { setExpandedStacks(prev => { const next = new Set(prev); if (next.has(key)) next.delete(key); else next.add(key); return next; }); }; const togglePreview = (key: string) => { setPreviewFiles(prev => { const next = new Set(prev); if (next.has(key)) next.delete(key); else next.add(key); return next; }); }; // --- Parse skipped nodes safely --- function parseSkippedNodes(raw: string): SkippedNode[] { try { const parsed: unknown = JSON.parse(raw); if (Array.isArray(parsed)) return parsed as SkippedNode[]; } catch { /* invalid JSON */ } return []; } // --- Detail View --- if (viewMode === 'detail') { return (
{/* Back button */} {loadingDetail ? (
) : selectedSnapshot ? ( <> {/* Header card */}

{selectedSnapshot.description || 'Untitled Snapshot'}

Created by {selectedSnapshot.created_by} on{' '} {new Date(selectedSnapshot.created_at).toLocaleString()}

{selectedSnapshot.node_count} node{selectedSnapshot.node_count !== 1 ? 's' : ''} {selectedSnapshot.stack_count} stack{selectedSnapshot.stack_count !== 1 ? 's' : ''}
{/* Skipped nodes warning */} {(() => { const skipped = parseSkippedNodes(selectedSnapshot.skipped_nodes); if (skipped.length === 0) return null; return (
Some nodes were unreachable during snapshot creation:
    {skipped.map(node => (
  • {node.nodeName} {' - '} {node.reason}
  • ))}
); })()} {/* Node / Stack / File tree */}
{selectedSnapshot.nodes.map(node => { const nodeExpanded = expandedNodes.has(node.nodeId); return (
{/* Node header */} {/* Stacks */} {nodeExpanded && (
{node.stacks.map(stack => { const stackKey = `${node.nodeId}:${stack.stackName}`; const stackExpanded = expandedStacks.has(stackKey); return (
{/* Files */} {stackExpanded && (
{stack.files.map(file => { const fileKey = `${stackKey}:${file.filename}`; const showPreview = previewFiles.has(fileKey); return (
{file.filename}
{showPreview && (
                                                                                        {file.content}
                                                                                    
)}
); })} {/* Restore button (admin only) */} {isAdmin && ( )}
)}
); })}
)}
); })}
) : null}
); } // --- List View --- return (
{/* Header */}

Fleet Snapshots

{isAdmin && !showCreateForm && ( )}
{/* Create form */} {showCreateForm && (
setDescription(e.target.value)} disabled={creating} onKeyDown={(e) => { if (e.key === 'Enter') handleCreate(); }} />
)} {/* Loading state */} {loading ? (
{Array.from({ length: 3 }).map((_, i) => (
))}
) : snapshots.length === 0 ? ( /* Empty state */

No snapshots yet

Create your first fleet snapshot to back up compose files across all nodes.

) : ( /* Snapshots table */
Date Description Scope Warnings Actions {snapshots.map(snapshot => { const skipped = parseSkippedNodes(snapshot.skipped_nodes); const skippedNames = skipped.map(s => s.nodeName).join(', '); return ( {new Date(snapshot.created_at).toLocaleString()} {snapshot.description ? ( snapshot.description ) : ( No description )} {snapshot.node_count} node{snapshot.node_count !== 1 ? 's' : ''} {' ยท '} {snapshot.stack_count} stack{snapshot.stack_count !== 1 ? 's' : ''} {skipped.length > 0 ? ( {skipped.length} ) : ( None )}
{isAdmin && ( Delete snapshot? This will permanently delete this fleet snapshot. This action cannot be undone. Cancel handleDelete(snapshot.id)} > Delete )}
); })}
)}
); } // --- Restore Button Sub-Component --- function RestoreButton({ nodeId, nodeName, stackName, restoring, onRestore }: { nodeId: number; nodeName: string; stackName: string; restoring: boolean; onRestore: (nodeId: number, stackName: string, redeploy: boolean) => Promise; }) { const [redeploy, setRedeploy] = useState(false); return ( Restore {stackName} on {nodeName}? This will overwrite the current compose files with the snapshot version.
setRedeploy(checked === true)} />
Cancel onRestore(nodeId, stackName, redeploy)} > {restoring && } Restore
); }