import { useState, useEffect, useCallback } from 'react'; import { Camera, ArrowLeft, Server, Layers, FileText, AlertTriangle, Trash2, Eye, ChevronDown, ChevronLeft, ChevronRight, Plus, Loader2, RotateCcw, Cloud, CloudUpload, } 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 { ConfirmModal } from '@/components/ui/modal'; import { ScrollArea } from '@/components/ui/scroll-area'; import { apiFetch } from '@/lib/api'; import { useAuth } from '@/context/AuthContext'; import { useLicense } from '@/context/LicenseContext'; import { toast } from '@/components/ui/toast-store'; // --- 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; } const PAGE_SIZE = 10; // --- Main Component --- export default function FleetSnapshots() { const { isAdmin } = useAuth(); const { license, isPaid } = useLicense(); const isAdmiral = isPaid && license?.variant === 'admiral'; 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); const [confirmDeleteId, setConfirmDeleteId] = useState(null); const [page, setPage] = useState(0); const [cloudSnapshotIds, setCloudSnapshotIds] = useState>(new Set()); const [uploadingId, setUploadingId] = useState(null); const totalPages = Math.max(1, Math.ceil(snapshots.length / PAGE_SIZE)); const safePage = Math.min(page, totalPages - 1); const pagedSnapshots = snapshots.slice(safePage * PAGE_SIZE, (safePage + 1) * PAGE_SIZE); const needsPagination = snapshots.length > PAGE_SIZE; // --- 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 fetchCloudSnapshots = useCallback(async () => { if (!isAdmiral) return; try { const res = await apiFetch('/cloud-backup/snapshots', { localOnly: true }); if (!res.ok) return; const data = await res.json() as Array<{ snapshotId: number | null }>; setCloudSnapshotIds(new Set(data.map(d => d.snapshotId).filter((id): id is number => id != null))); } catch { // best-effort; cloud indicators stay hidden on failure } }, [isAdmiral]); useEffect(() => { fetchCloudSnapshots(); }, [fetchCloudSnapshots]); const handleCloudUpload = async (id: number) => { setUploadingId(id); try { const res = await apiFetch(`/cloud-backup/upload/${id}`, { method: 'POST', localOnly: true }); const data = await res.json().catch(() => ({})); if (!res.ok) throw new Error((data as { error?: string }).error || `Upload failed (${res.status})`); toast.success('Snapshot uploaded to cloud.'); await fetchCloudSnapshots(); } catch (err) { toast.error((err as Error)?.message || 'Cloud upload failed.'); } finally { setUploadingId(null); } }; const handleCreate = async () => { setCreating(true); const loadingId = toast.loading('Creating fleet snapshot...'); 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 { toast.dismiss(loadingId); 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

{needsPagination && (
{safePage + 1} / {totalPages}
)} {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 {pagedSnapshots.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 )} {cloudSnapshotIds.has(snapshot.id) && ( )}
{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 && isAdmiral && !cloudSnapshotIds.has(snapshot.id) && ( )} {isAdmin && ( )}
); })}
)} { if (!open) setConfirmDeleteId(null); }} variant="destructive" kicker="SNAPSHOTS · DELETE · IRREVERSIBLE" title="Delete snapshot" confirmLabel="Delete" onConfirm={async () => { if (confirmDeleteId !== null) { const id = confirmDeleteId; setConfirmDeleteId(null); await handleDelete(id); } }} >

Permanently removes this fleet snapshot.

); } // --- 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); const [open, setOpen] = useState(false); return ( <> { try { await onRestore(nodeId, stackName, redeploy); } finally { setOpen(false); } }} >

Overwrites the current compose files with the snapshot version.

setRedeploy(checked === true)} />
); }