diff --git a/apps/dokploy/components/dashboard/application/volume-backups/restore-volume-backups.tsx b/apps/dokploy/components/dashboard/application/volume-backups/restore-volume-backups.tsx new file mode 100644 index 000000000..4c1bc1658 --- /dev/null +++ b/apps/dokploy/components/dashboard/application/volume-backups/restore-volume-backups.tsx @@ -0,0 +1,403 @@ +import { DrawerLogs } from "@/components/shared/drawer-logs"; +import { Badge } from "@/components/ui/badge"; +import { Button } from "@/components/ui/button"; +import { + Command, + CommandEmpty, + CommandGroup, + CommandInput, + CommandItem, +} from "@/components/ui/command"; +import { + Dialog, + DialogContent, + DialogDescription, + DialogFooter, + DialogHeader, + DialogTitle, + DialogTrigger, +} from "@/components/ui/dialog"; +import { + Form, + FormControl, + FormField, + FormItem, + FormLabel, + FormMessage, +} from "@/components/ui/form"; +import { Input } from "@/components/ui/input"; +import { + Popover, + PopoverContent, + PopoverTrigger, +} from "@/components/ui/popover"; +import { ScrollArea } from "@/components/ui/scroll-area"; +import { cn } from "@/lib/utils"; +import { api } from "@/utils/api"; +import { zodResolver } from "@hookform/resolvers/zod"; +import copy from "copy-to-clipboard"; +import { debounce } from "lodash"; +import { CheckIcon, ChevronsUpDown, Copy, RotateCcw } from "lucide-react"; +import { useState } from "react"; +import { useForm } from "react-hook-form"; +import { toast } from "sonner"; +import { z } from "zod"; +import { type LogLine, parseLogs } from "../../docker/logs/utils"; +import { formatBytes } from "../../database/backups/restore-backup"; + +interface Props { + id: string; + type?: "application" | "compose" | "postgres" | "mariadb" | "mongo" | "mysql"; + serverId?: string; +} + +const RestoreBackupSchema = z + .object({ + destinationId: z + .string({ + required_error: "Please select a destination", + }) + .min(1, { + message: "Destination is required", + }), + backupFile: z + .string({ + required_error: "Please select a backup file", + }) + .min(1, { + message: "Backup file is required", + }), + volumeName: z + .string({ + required_error: "Please enter a volume name", + }) + .min(1, { + message: "Volume name is required", + }), + }) + .superRefine((data, ctx) => {}); + +export const RestoreVolumeBackups = ({ id, type, serverId }: Props) => { + const [isOpen, setIsOpen] = useState(false); + const [search, setSearch] = useState(""); + const [debouncedSearchTerm, setDebouncedSearchTerm] = useState(""); + + const { data: destinations = [] } = api.destination.all.useQuery(); + + const form = useForm>({ + defaultValues: { + destinationId: "", + backupFile: "", + volumeName: "", + }, + resolver: zodResolver(RestoreBackupSchema), + }); + + const destionationId = form.watch("destinationId"); + const volumeName = form.watch("volumeName"); + + const debouncedSetSearch = debounce((value: string) => { + setDebouncedSearchTerm(value); + }, 350); + + const handleSearchChange = (value: string) => { + setSearch(value); + debouncedSetSearch(value); + }; + + const { data: files = [], isLoading } = api.backup.listBackupFiles.useQuery( + { + destinationId: destionationId, + search: debouncedSearchTerm, + serverId: serverId ?? "", + }, + { + enabled: isOpen && !!destionationId, + }, + ); + + const [isDrawerOpen, setIsDrawerOpen] = useState(false); + const [filteredLogs, setFilteredLogs] = useState([]); + const [isDeploying, setIsDeploying] = useState(false); + + api.volumeBackups.restoreVolumeBackupWithLogs.useSubscription( + { + volumeBackupId: id, + destinationId: form.watch("destinationId"), + volumeName: volumeName, + }, + { + enabled: isDeploying, + onData(log) { + if (!isDrawerOpen) { + setIsDrawerOpen(true); + } + + if (log === "Restore completed successfully!") { + setIsDeploying(false); + } + const parsedLogs = parseLogs(log); + setFilteredLogs((prev) => [...prev, ...parsedLogs]); + }, + onError(error) { + console.error("Restore logs error:", error); + setIsDeploying(false); + }, + }, + ); + + const onSubmit = async (data: z.infer) => { + setIsDeploying(true); + }; + + return ( + + + + + + + + + Restore Volume Backup + + + Select a destination and search for volume backup files + + + +
+ + ( + + Destination + + + + + + + + + + No destinations found. + + + {destinations.map((destination) => ( + { + form.setValue( + "destinationId", + destination.destinationId, + ); + }} + > + {destination.name} + + + ))} + + + + + + + + )} + /> + + ( + + + Search Backup Files + {field.value && ( + + {field.value} + { + e.stopPropagation(); + e.preventDefault(); + copy(field.value); + toast.success("Backup file copied to clipboard"); + }} + /> + + )} + + + + + + + + + + + {isLoading ? ( +
+ Loading backup files... +
+ ) : files.length === 0 && search ? ( +
+ No backup files found for "{search}" +
+ ) : files.length === 0 ? ( +
+ No backup files available +
+ ) : ( + + + {files?.map((file) => ( + { + form.setValue("backupFile", file.Path); + if (file.IsDir) { + setSearch(`${file.Path}/`); + setDebouncedSearchTerm(`${file.Path}/`); + } else { + setSearch(file.Path); + setDebouncedSearchTerm(file.Path); + } + }} + > +
+
+ + {file.Path} + + + +
+
+ + Size: {formatBytes(file.Size)} + + {file.IsDir && ( + + Directory + + )} + {file.Hashes?.MD5 && ( + MD5: {file.Hashes.MD5} + )} +
+
+
+ ))} +
+
+ )} +
+
+
+ +
+ )} + /> + ( + + Volume Name + + + + + + )} + /> + + + + + + + + { + setIsDrawerOpen(false); + setFilteredLogs([]); + setIsDeploying(false); + // refetch(); + }} + filteredLogs={filteredLogs} + /> +
+
+ ); +}; diff --git a/apps/dokploy/components/dashboard/application/volume-backups/show-volume-backups.tsx b/apps/dokploy/components/dashboard/application/volume-backups/show-volume-backups.tsx index bf823c382..8318b0170 100644 --- a/apps/dokploy/components/dashboard/application/volume-backups/show-volume-backups.tsx +++ b/apps/dokploy/components/dashboard/application/volume-backups/show-volume-backups.tsx @@ -25,6 +25,7 @@ import { import { toast } from "sonner"; import { HandleVolumeBackups } from "./handle-volume-backups"; import { ShowDeploymentsModal } from "../deployments/show-deployments-modal"; +import { RestoreVolumeBackups } from "./restore-volume-backups"; interface Props { id: string; @@ -68,9 +69,14 @@ export const ShowVolumeBackups = ({ id, type = "application" }: Props) => { - {volumeBackups && volumeBackups.length > 0 && ( - - )} +
+ {volumeBackups && volumeBackups.length > 0 && ( + + )} +
+ +
+
@@ -220,7 +226,10 @@ export const ShowVolumeBackups = ({ id, type = "application" }: Props) => {

Create your first volume backup to automate your workflows

- +
+ + +
)}
diff --git a/apps/dokploy/components/dashboard/database/backups/restore-backup.tsx b/apps/dokploy/components/dashboard/database/backups/restore-backup.tsx index 7617b510e..b404204ab 100644 --- a/apps/dokploy/components/dashboard/database/backups/restore-backup.tsx +++ b/apps/dokploy/components/dashboard/database/backups/restore-backup.tsx @@ -199,7 +199,7 @@ const RestoreBackupSchema = z } }); -const formatBytes = (bytes: number): string => { +export const formatBytes = (bytes: number): string => { if (bytes === 0) return "0 Bytes"; const k = 1024; const sizes = ["Bytes", "KB", "MB", "GB", "TB"]; diff --git a/apps/dokploy/pages/dashboard/project/[projectId]/services/compose/[composeId].tsx b/apps/dokploy/pages/dashboard/project/[projectId]/services/compose/[composeId].tsx index 7ab6f20a5..de9ab94d5 100644 --- a/apps/dokploy/pages/dashboard/project/[projectId]/services/compose/[composeId].tsx +++ b/apps/dokploy/pages/dashboard/project/[projectId]/services/compose/[composeId].tsx @@ -216,10 +216,10 @@ const Service = ( className={cn( "xl:grid xl:w-fit max-md:overflow-y-scroll justify-start", isCloud && data?.serverId - ? "xl:grid-cols-9" + ? "xl:grid-cols-10" : data?.serverId - ? "xl:grid-cols-8" - : "xl:grid-cols-9", + ? "xl:grid-cols-9" + : "xl:grid-cols-10", )} > General diff --git a/apps/dokploy/server/api/routers/volume-backups.ts b/apps/dokploy/server/api/routers/volume-backups.ts index 77c98bb11..62187a9cb 100644 --- a/apps/dokploy/server/api/routers/volume-backups.ts +++ b/apps/dokploy/server/api/routers/volume-backups.ts @@ -5,6 +5,7 @@ import { removeVolumeBackup, createVolumeBackup, runVolumeBackup, + findDestinationById, } from "@dokploy/server"; import { createVolumeBackupSchema, @@ -15,6 +16,8 @@ import { z } from "zod"; import { createTRPCRouter, protectedProcedure } from "../trpc"; import { db } from "@dokploy/server/db"; import { eq } from "drizzle-orm"; +import { restorePostgresBackup } from "@dokploy/server/utils/restore"; +import { observable } from "@trpc/server/observable"; export const volumeBackupsRouter = createTRPCRouter({ list: protectedProcedure @@ -88,4 +91,29 @@ export const volumeBackupsRouter = createTRPCRouter({ return false; } }), + restoreVolumeBackupWithLogs: protectedProcedure + .meta({ + openapi: { + enabled: false, + path: "/restore-volume-backup-with-logs", + method: "POST", + override: true, + }, + }) + .input( + z.object({ + volumeBackupId: z.string().min(1), + destinationId: z.string().min(1), + volumeName: z.string().min(1), + }), + ) + .subscription(async ({ input }) => { + const destination = await findDestinationById(input.destinationId); + + return observable((emit) => { + // restorePostgresBackup(postgres, destination, input, (log) => { + // emit.next(log); + // }); + }); + }), }); diff --git a/packages/server/src/utils/volume-backups/utils.ts b/packages/server/src/utils/volume-backups/utils.ts index dbad12ec0..99b2f7c82 100644 --- a/packages/server/src/utils/volume-backups/utils.ts +++ b/packages/server/src/utils/volume-backups/utils.ts @@ -97,6 +97,7 @@ const backupVolume = async ( if (compose.composeType === "stack") { stopCommand = ` echo "Stopping compose to 0 replicas" + echo "Service name: ${compose.appName}_${volumeBackup.serviceName}" ACTUAL_REPLICAS=$(docker service inspect ${compose.appName}_${volumeBackup.serviceName} --format "{{.Spec.Mode.Replicated.Replicas}}") echo "Actual replicas: $ACTUAL_REPLICAS" docker service scale ${compose.appName}_${volumeBackup.serviceName}=0`;