import { useState, useEffect, useCallback } from 'react'; import { Camera, ArrowLeft, Server, Layers, FileText, AlertTriangle, Trash2, Eye, ChevronDown, ChevronLeft, ChevronRight, Plus, Loader2, RotateCcw, Cloud, CloudUpload, Download, BookText, } 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 { apiFetch } from '@/lib/api'; import { useAuth } from '@/context/AuthContext'; import { useLicense } from '@/context/LicenseContext'; import { toast } from '@/components/ui/toast-store'; import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from '@/components/ui/tooltip'; import { FleetTabHeading, FleetEmptyState, FleetEmptyCard } from './fleet/FleetEmptyState'; // --- Types --- interface FleetSnapshot { id: number; description: string; created_by: string; node_count: number; stack_count: number; skipped_nodes: string; // JSON string skipped_stacks: string; // JSON string created_at: number; has_documentation?: number; } interface SnapshotStackFile { filename: string; content: string; } interface SnapshotStack { stackName: string; files: SnapshotStackFile[]; } interface SnapshotNode { nodeId: number; nodeName: string; stacks: SnapshotStack[]; } /** Operator-authored dossier fields preserved with the snapshot. */ type SnapshotDossierFields = Record; interface SnapshotDocumentationStack { nodeId: number; nodeName: string; stackName: string; dossier: SnapshotDossierFields; } interface SnapshotDocumentation { generated_at: string; stacks: SnapshotDocumentationStack[]; warnings: Array<{ nodeId: number; nodeName: string; stackName: string; reason: string }>; } interface FleetSnapshotDetail extends FleetSnapshot { nodes: SnapshotNode[]; documentation?: SnapshotDocumentation; } // Ordered labels for the read-only dossier block; only non-empty fields render. const DOSSIER_FIELD_LABELS: ReadonlyArray<[string, string]> = [ ['purpose', 'Purpose'], ['owner', 'Owner'], ['access_urls', 'Access URLs'], ['static_ip', 'Static IP'], ['vlan', 'VLAN'], ['firewall_notes', 'Firewall'], ['reverse_proxy_notes', 'Reverse proxy'], ['backup_notes', 'Backup'], ['upgrade_notes', 'Upgrade'], ['recovery_notes', 'Recovery'], ['custom_notes', 'Notes'], ]; interface SkippedNode { nodeId: number; nodeName: string; reason: string; } interface SkippedStack { nodeId: number; nodeName: string; stackName: string; reason: string; } const PAGE_SIZE = 10; // --- Main Component --- export default function FleetSnapshots() { const { isAdmin } = useAuth(); const { isPaid } = useLicense(); // Cloud-upload affordance is reachable when the saved provider is custom // (every tier) or sencho on a paid license. A downgraded admin whose // saved provider is still 'sencho' sees no upload button — they cannot // call POST /cloud-backup/upload/:id because gateForCurrentProvider would // 403 anyway, so the UI must not advertise an action that is gated away. const [cloudEnabled, setCloudEnabled] = useState(false); 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 [restoringAll, setRestoringAll] = useState(false); 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 fetchCloudConfig = useCallback(async () => { try { const res = await apiFetch('/cloud-backup/config', { localOnly: true }); if (!res.ok) return; const data = await res.json() as { provider: 'disabled' | 'sencho' | 'custom' }; setCloudEnabled(data.provider === 'custom' || (data.provider === 'sencho' && isPaid)); } catch { // best-effort; cloud affordances stay hidden on failure } }, [isPaid]); const fetchCloudSnapshots = useCallback(async () => { if (!cloudEnabled) 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 } }, [cloudEnabled]); useEffect(() => { fetchCloudConfig(); }, [fetchCloudConfig]); 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, restoreNotes: 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, restoreNotes }), }); if (res.ok) { const data: { message: string; redeployed: boolean; notesRestored: boolean; notesError?: string } = await res.json(); const base = data.redeployed ? 'Stack restored and redeployed.' : 'Stack restored successfully.'; if (data.notesError) { // Files restored; only the optional notes write failed. toast.warning(`${base} Documentation notes could not be restored.`); } else { toast.success(base + (data.notesRestored ? ' Documentation notes restored.' : '')); } } 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); } }; const handleDownloadFile = (stackName: string, file: SnapshotStackFile) => { try { const blob = new Blob([file.content], { type: 'text/plain;charset=utf-8' }); const url = URL.createObjectURL(blob); const a = document.createElement('a'); a.href = url; a.download = `${stackName}-${file.filename}`; document.body.appendChild(a); a.click(); document.body.removeChild(a); setTimeout(() => URL.revokeObjectURL(url), 100); } catch (error: unknown) { const err = error as Record | null; toast.error((err?.message as string) || 'Download failed.'); } }; const handleRestoreAll = async (redeploy: boolean, restoreNotes: boolean) => { if (!selectedSnapshot) return; setRestoringAll(true); try { const res = await apiFetch(`/fleet/snapshots/${selectedSnapshot.id}/restore-all`, { method: 'POST', localOnly: true, body: JSON.stringify({ redeploy, restoreNotes }), }); if (res.ok) { const data: { restored: number; failed: number; redeploy: boolean; results: Array<{ stackName: string; success: boolean; error?: string; notesError?: string }>; } = await res.json(); const noun = (n: number) => `${n} stack${n === 1 ? '' : 's'}`; const firstFailed = data.results?.find(r => !r.success); const failDetail = firstFailed ? ` First failure: ${firstFailed.stackName} · ${firstFailed.error || 'unknown error'}` : ''; // Files restored but the optional notes write failed on some stacks. const notesFailed = data.results?.filter(r => r.notesError).length ?? 0; const notesSuffix = notesFailed > 0 ? ` Documentation notes could not be restored for ${noun(notesFailed)}.` : ''; if (data.failed === 0 && notesFailed === 0) { toast.success(data.redeploy ? `Restored and redeployed ${noun(data.restored)}.` : `Restored ${noun(data.restored)}.`); } else if (data.restored === 0) { toast.error(`Restore failed for ${noun(data.failed)}.${failDetail}`); } else if (data.failed === 0) { toast.warning(`Restored ${noun(data.restored)}.${notesSuffix}`); } else { toast.warning(`${data.restored} restored, ${data.failed} failed.${failDetail}${notesSuffix}`); } } else { const err = await res.json().catch(() => null); toast.error(err?.message || err?.error || err?.data?.error || 'Failed to restore snapshot.'); } } catch (error: unknown) { const err = error as Record | null; toast.error(err?.message as string || err?.error as string || 'Something went wrong.'); } finally { setRestoringAll(false); } }; // --- 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 JSON-array warning columns safely --- function parseJsonArray(raw: string): T[] { try { const parsed: unknown = JSON.parse(raw); if (Array.isArray(parsed)) return parsed as T[]; } 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()}

{isAdmin && selectedSnapshot.nodes.length > 0 && ( 0} onRestoreAll={handleRestoreAll} /> )}
{selectedSnapshot.node_count} node{selectedSnapshot.node_count !== 1 ? 's' : ''} {selectedSnapshot.stack_count} stack{selectedSnapshot.stack_count !== 1 ? 's' : ''} {(selectedSnapshot.documentation?.stacks.length ?? 0) > 0 && ( Documentation captured )}
{/* Skipped nodes warning */} {(() => { const skipped = parseJsonArray(selectedSnapshot.skipped_nodes); if (skipped.length === 0) return null; return (
Some nodes were unreachable during snapshot creation:
    {skipped.map(node => (
  • {node.nodeName} {' - '} {node.reason}
  • ))}
); })()} {/* Partially captured stacks warning */} {(() => { const skipped = parseJsonArray(selectedSnapshot.skipped_stacks); if (skipped.length === 0) return null; return (
Some stacks were not fully captured:
    {skipped.map((stack, i) => (
  • {stack.nodeName} {' / '} {stack.stackName} {' - '} {stack.reason}
  • ))}
); })()} {/* Documentation capture warnings (notes that could not be fetched) */} {(() => { const warnings = selectedSnapshot.documentation?.warnings ?? []; if (warnings.length === 0) return null; return (
Some stack documentation could not be captured:
    {warnings.map((w, i) => (
  • {w.nodeName} {' / '} {w.stackName} {' - '} {w.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); const dossier = selectedSnapshot.documentation?.stacks .find(s => s.nodeId === node.nodeId && s.stackName === stack.stackName)?.dossier; return (
{isAdmin && ( )}
{/* Files */} {stackExpanded && (
{stack.files.map(file => { const fileKey = `${stackKey}:${file.filename}`; const showPreview = previewFiles.has(fileKey); return (
{file.filename}
{showPreview && (
                                                                                            {file.content}
                                                                                        
)}
); })} {/* Preserved dossier notes (read-only) */} {dossier && }
)}
); })}
)}
); })}
) : null}
); } // --- List View --- return (
{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 ? ( setShowCreateForm(true)}> Create Snapshot ) : undefined} /> ) : ( /* Snapshots table */
Date Description Scope Warnings Actions {pagedSnapshots.map(snapshot => { const skippedNodes = parseJsonArray(snapshot.skipped_nodes); const skippedStacks = parseJsonArray(snapshot.skipped_stacks); const warningCount = skippedNodes.length + skippedStacks.length; const warningTitle = [ skippedNodes.length > 0 ? `Nodes: ${skippedNodes.map(s => s.nodeName).join(', ')}` : '', skippedStacks.length > 0 ? `Stacks: ${skippedStacks.map(s => `${s.nodeName}/${s.stackName}`).join(', ')}` : '', ].filter(Boolean).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' : ''} {warningCount > 0 ? ( {warningCount} ) : ( None )}
{isAdmin && cloudEnabled && !cloudSnapshotIds.has(snapshot.id) && ( Upload to cloud )} {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, hasDossier, restoring, onRestore }: { nodeId: number; nodeName: string; stackName: string; hasDossier: boolean; restoring: boolean; onRestore: (nodeId: number, stackName: string, redeploy: boolean, restoreNotes: boolean) => Promise; }) { const [redeploy, setRedeploy] = useState(false); const [restoreNotes, setRestoreNotes] = useState(false); const [open, setOpen] = useState(false); return ( <> { try { await onRestore(nodeId, stackName, redeploy, restoreNotes); } finally { setOpen(false); } }} >

Overwrites the current compose files with the snapshot version.

setRedeploy(checked === true)} />
{hasDossier && (
setRestoreNotes(checked === true)} />
)}
); } // --- Preserved Dossier Sub-Component --- function DossierBlock({ dossier }: { dossier: SnapshotDossierFields }) { const entries = DOSSIER_FIELD_LABELS.filter(([key]) => (dossier[key] ?? '').trim() !== ''); if (entries.length === 0) return null; return (
Dossier notes
{entries.map(([key, label]) => (
{label}
{dossier[key]}
))}
); } // --- Restore All Button Sub-Component --- function RestoreAllButton({ restoring, hasDocumentation, onRestoreAll }: { restoring: boolean; hasDocumentation: boolean; onRestoreAll: (redeploy: boolean, restoreNotes: boolean) => Promise; }) { const [redeploy, setRedeploy] = useState(false); const [restoreNotes, setRestoreNotes] = useState(false); const [open, setOpen] = useState(false); return ( <> { try { await onRestoreAll(redeploy, restoreNotes); } finally { setOpen(false); } }} >

Overwrites the current compose and environment files for every stack on every node in this snapshot.

setRedeploy(checked === true)} />
{hasDocumentation && (
setRestoreNotes(checked === true)} />
)}
); }