import { useCallback, useEffect, useRef, useState } from "react"; import { useMutation } from "@tanstack/react-query"; import { AlertTriangle, ChevronDown, Download, FolderOpen, RotateCcw } from "lucide-react"; import { Button } from "~/client/components/ui/button"; import { Tooltip, TooltipContent, TooltipTrigger } from "~/client/components/ui/tooltip"; import { Alert, AlertDescription, AlertTitle } from "~/client/components/ui/alert"; import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "~/client/components/ui/card"; import { Input } from "~/client/components/ui/input"; import { Label } from "~/client/components/ui/label"; import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "~/client/components/ui/select"; import { AlertDialog, AlertDialogAction, AlertDialogContent, AlertDialogDescription, AlertDialogFooter, AlertDialogHeader, AlertDialogTitle, } from "~/client/components/ui/alert-dialog"; import { PathSelector } from "~/client/components/path-selector"; import { SnapshotTreeBrowser } from "~/client/components/file-browsers/snapshot-tree-browser"; import { RestoreProgress } from "~/client/components/restore-progress"; import { restoreSnapshotMutation } from "~/client/api-client/@tanstack/react-query.gen"; import { type RestoreCompletedEvent, useServerEvents } from "~/client/hooks/use-server-events"; import { OVERWRITE_MODES, type OverwriteMode } from "@zerobyte/core/restic"; import { isPathWithin } from "@zerobyte/core/utils"; import type { Repository } from "~/client/lib/types"; import { handleRepositoryError } from "~/client/lib/errors"; import { useNavigate } from "@tanstack/react-router"; import { cn } from "~/client/lib/utils"; type RestoreLocation = "original" | "custom"; interface RestoreFormProps { repository: Repository; snapshotId: string; returnPath: string; queryBasePath?: string; displayBasePath?: string; hasNonPosixSnapshotPaths?: boolean; } export function RestoreForm({ repository, snapshotId, returnPath, queryBasePath, displayBasePath, hasNonPosixSnapshotPaths = false, }: RestoreFormProps) { const navigate = useNavigate(); const { addEventListener } = useServerEvents(); const snapshotBasePath = queryBasePath ?? "/"; const hasMismatchedDisplayBasePath = displayBasePath && !isPathWithin(displayBasePath, snapshotBasePath); const restoreRequiresCustomTarget = hasNonPosixSnapshotPaths || hasMismatchedDisplayBasePath; const [restoreLocation, setRestoreLocation] = useState( restoreRequiresCustomTarget ? "custom" : "original", ); const [customTargetPath, setCustomTargetPath] = useState(""); const [overwriteMode, setOverwriteMode] = useState("always"); const [showAdvanced, setShowAdvanced] = useState(false); const [excludeXattr, setExcludeXattr] = useState(""); const [isRestoreActive, setIsRestoreActive] = useState(false); const [restoreResult, setRestoreResult] = useState(null); const [showRestoreResultAlert, setShowRestoreResultAlert] = useState(false); const restoreCompletedRef = useRef(false); const [selectedPaths, setSelectedPaths] = useState>(new Set()); const [selectedPathKind, setSelectedPathKind] = useState<"file" | "dir" | null>(null); const trimmedCustomTargetPath = customTargetPath.trim(); const hasCustomTargetPath = trimmedCustomTargetPath !== ""; const selectedPathCount = selectedPaths.size; useEffect(() => { if (restoreRequiresCustomTarget) { setRestoreLocation("custom"); } }, [restoreRequiresCustomTarget]); useEffect(() => { const abortController = new AbortController(); const signal = abortController.signal; addEventListener( "restore:started", (startedData) => { if (startedData.repositoryId === repository.shortId && startedData.snapshotId === snapshotId) { restoreCompletedRef.current = false; setIsRestoreActive(true); setRestoreResult(null); setShowRestoreResultAlert(false); } }, { signal }, ); addEventListener( "restore:progress", (progressData) => { if (progressData.repositoryId === repository.shortId && progressData.snapshotId === snapshotId) { if (restoreCompletedRef.current) { return; } setIsRestoreActive(true); } }, { signal }, ); addEventListener( "restore:completed", (completedData) => { if (completedData.repositoryId === repository.shortId && completedData.snapshotId === snapshotId) { restoreCompletedRef.current = true; setIsRestoreActive(false); setRestoreResult(completedData); setShowRestoreResultAlert(true); } }, { signal }, ); return () => { abortController.abort(); }; }, [addEventListener, repository.shortId, snapshotId]); const { mutate: restoreSnapshot, isPending: isRestoring } = useMutation({ ...restoreSnapshotMutation(), onError: (error) => { restoreCompletedRef.current = true; setIsRestoreActive(false); handleRepositoryError("Restore failed", error, repository.shortId); }, }); const handleRestore = useCallback(() => { const excludeXattrValues = excludeXattr .split(",") .map((value) => value.trim()) .filter(Boolean); const isCustomLocation = restoreLocation === "custom"; const targetPath = isCustomLocation && hasCustomTargetPath ? trimmedCustomTargetPath : undefined; const includePaths = Array.from(selectedPaths); restoreCompletedRef.current = false; setIsRestoreActive(true); setRestoreResult(null); setShowRestoreResultAlert(false); restoreSnapshot({ path: { shortId: repository.shortId }, body: { snapshotId, include: includePaths.length > 0 ? includePaths : undefined, selectedItemKind: includePaths.length === 1 ? (selectedPathKind ?? undefined) : undefined, excludeXattr: excludeXattrValues.length > 0 ? excludeXattrValues : undefined, targetPath, overwrite: overwriteMode, }, }); }, [ repository.shortId, snapshotId, excludeXattr, hasCustomTargetPath, restoreLocation, trimmedCustomTargetPath, selectedPaths, selectedPathKind, overwriteMode, restoreSnapshot, ]); const handleDownload = useCallback(() => { if (selectedPaths.size > 1) return; const url = new URL( `/api/v1/repositories/${repository.shortId}/snapshots/${snapshotId}/dump`, window.location.origin, ); const [selectedPath] = selectedPaths; if (selectedPath) { url.searchParams.set("path", selectedPath); if (selectedPathKind) { url.searchParams.set("kind", selectedPathKind); } } window.location.assign(url.toString()); }, [repository.shortId, snapshotId, selectedPathKind, selectedPaths]); const acknowledgeRestoreResult = useCallback(() => { setShowRestoreResultAlert(false); setRestoreResult(null); }, []); const handleResultAlertOpenChange = useCallback((open: boolean) => { if (open) { setShowRestoreResultAlert(true); } }, []); const canRestore = restoreRequiresCustomTarget ? hasCustomTargetPath : restoreLocation === "original" || hasCustomTargetPath; const canDownload = selectedPathCount <= 1; const isRestoreRunning = isRestoring || isRestoreActive; function getDownloadButtonText(): string { if (selectedPathCount > 0) { return `Download ${selectedPathCount} ${selectedPathCount === 1 ? "item" : "items"}`; } return "Download All"; } function getRestoreButtonText(): string { if (isRestoreRunning) { return "Restoring..."; } if (selectedPathCount > 0) { return `Restore ${selectedPathCount} ${selectedPathCount === 1 ? "item" : "items"}`; } return "Restore All"; } return (

Restore Snapshot

{repository.name} / {snapshotId}

Download is available only for one selected item, or with no selection to download everything.

{isRestoreRunning && } {restoreRequiresCustomTarget && ( Source paths do not match This snapshot was created from source paths that do not match this Zerobyte server or the current linked volume. Restoring to the original location is unavailable. Restore it to a custom location, or download it instead. )} Restore Location Choose where to restore the files
{restoreLocation === "custom" && (

Files will be restored directly to this path

)}
Overwrite Mode How to handle existing files

{overwriteMode === OVERWRITE_MODES.always && "Existing files will always be replaced with the snapshot version."} {overwriteMode === OVERWRITE_MODES.ifChanged && "Files are only replaced if their content differs from the snapshot."} {overwriteMode === OVERWRITE_MODES.ifNewer && "Files are only replaced if the snapshot version has a newer modification time."} {overwriteMode === OVERWRITE_MODES.never && "Existing files will never be replaced, only missing files are restored."}

setShowAdvanced(!showAdvanced)}>
Advanced options
{showAdvanced && (
setExcludeXattr(e.target.value)} />

Exclude specific extended attributes during restore (comma-separated)

)}
Select Files to Restore {selectedPaths.size > 0 ? `${selectedPaths.size} ${selectedPaths.size === 1 ? "item" : "items"} selected` : "Select specific files or folders, or leave empty to restore everything"}
{restoreResult?.status === "success" ? "Restore completed" : "Restore failed"} {restoreResult?.status === "success" ? `Snapshot ${snapshotId} was restored successfully.` : restoreResult?.error || `Snapshot ${snapshotId} could not be restored.`} OK
); }