mirror of
https://github.com/andwati/dokploy.git
synced 2026-11-03 02:32:21 +00:00
- Added a check to prevent empty values from being processed in the onValueChange handler for Bitbucket, Gitea, GitHub, and GitLab providers. - Removed unnecessary defaultValue prop from Select components to streamline the code. - Updated button styles to remove background color for better consistency across the UI. - Enhanced volume backup selection to display a message when no volumes are found. This update enhances user experience by ensuring that empty selections are handled gracefully and improves the overall visual consistency of the UI components.
400 lines
11 KiB
TypeScript
400 lines
11 KiB
TypeScript
import { standardSchemaResolver as zodResolver } from "@hookform/resolvers/standard-schema";
|
|
import copy from "copy-to-clipboard";
|
|
import debounce from "lodash/debounce";
|
|
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 { AlertBlock } from "@/components/shared/alert-block";
|
|
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 { formatBytes } from "../../database/backups/restore-backup";
|
|
import { type LogLine, parseLogs } from "../../docker/logs/utils";
|
|
|
|
interface Props {
|
|
id: string;
|
|
type: "application" | "compose";
|
|
serverId?: string;
|
|
}
|
|
|
|
const RestoreBackupSchema = z.object({
|
|
destinationId: z.string().min(1, {
|
|
message: "Destination is required",
|
|
}),
|
|
backupFile: z.string().min(1, {
|
|
message: "Backup file is required",
|
|
}),
|
|
volumeName: z.string().min(1, {
|
|
message: "Volume name is required",
|
|
}),
|
|
});
|
|
|
|
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 destinationId = form.watch("destinationId");
|
|
const volumeName = form.watch("volumeName");
|
|
const backupFile = form.watch("backupFile");
|
|
|
|
const debouncedSetSearch = debounce((value: string) => {
|
|
setDebouncedSearchTerm(value);
|
|
}, 350);
|
|
|
|
const handleSearchChange = (value: string) => {
|
|
setSearch(value);
|
|
debouncedSetSearch(value);
|
|
};
|
|
|
|
const { data: files = [], isPending } = api.backup.listBackupFiles.useQuery(
|
|
{
|
|
destinationId: destinationId,
|
|
search: debouncedSearchTerm,
|
|
serverId: serverId ?? "",
|
|
},
|
|
{
|
|
enabled: isOpen && !!destinationId,
|
|
},
|
|
);
|
|
|
|
const [isDrawerOpen, setIsDrawerOpen] = useState(false);
|
|
const [filteredLogs, setFilteredLogs] = useState<LogLine[]>([]);
|
|
const [isDeploying, setIsDeploying] = useState(false);
|
|
|
|
api.volumeBackups.restoreVolumeBackupWithLogs.useSubscription(
|
|
{
|
|
id,
|
|
serviceType: type,
|
|
serverId,
|
|
destinationId,
|
|
volumeName,
|
|
backupFileName: backupFile,
|
|
},
|
|
{
|
|
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 () => {
|
|
setIsDeploying(true);
|
|
};
|
|
|
|
return (
|
|
<Dialog open={isOpen} onOpenChange={setIsOpen}>
|
|
<DialogTrigger asChild>
|
|
<Button variant="outline">
|
|
<RotateCcw className="mr-2 size-4" />
|
|
Restore Volume Backup
|
|
</Button>
|
|
</DialogTrigger>
|
|
<DialogContent className="sm:max-w-lg">
|
|
<DialogHeader>
|
|
<DialogTitle className="flex items-center">
|
|
<RotateCcw className="mr-2 size-4" />
|
|
Restore Volume Backup
|
|
</DialogTitle>
|
|
<DialogDescription>
|
|
Select a destination and search for volume backup files
|
|
</DialogDescription>
|
|
<AlertBlock>
|
|
Make sure the volume name is not being used by another container.
|
|
</AlertBlock>
|
|
</DialogHeader>
|
|
|
|
<Form {...form}>
|
|
<form
|
|
id="hook-form-restore-backup"
|
|
onSubmit={form.handleSubmit(onSubmit)}
|
|
className="grid w-full gap-4"
|
|
>
|
|
<FormField
|
|
control={form.control}
|
|
name="destinationId"
|
|
render={({ field }) => (
|
|
<FormItem className="">
|
|
<FormLabel>Destination</FormLabel>
|
|
<Popover>
|
|
<PopoverTrigger asChild>
|
|
<FormControl>
|
|
<Button
|
|
variant="outline"
|
|
className={cn(
|
|
"w-full justify-between",
|
|
!field.value && "text-muted-foreground",
|
|
)}
|
|
>
|
|
{field.value
|
|
? destinations.find(
|
|
(d) => d.destinationId === field.value,
|
|
)?.name
|
|
: "Select Destination"}
|
|
<ChevronsUpDown className="ml-2 h-4 w-4 shrink-0 opacity-50" />
|
|
</Button>
|
|
</FormControl>
|
|
</PopoverTrigger>
|
|
<PopoverContent className="p-0" align="start">
|
|
<Command>
|
|
<CommandInput
|
|
placeholder="Search destinations..."
|
|
className="h-9"
|
|
/>
|
|
<CommandEmpty>No destinations found.</CommandEmpty>
|
|
<ScrollArea className="h-64">
|
|
<CommandGroup>
|
|
{destinations.map((destination) => (
|
|
<CommandItem
|
|
value={destination.destinationId}
|
|
key={destination.destinationId}
|
|
onSelect={() => {
|
|
form.setValue(
|
|
"destinationId",
|
|
destination.destinationId,
|
|
);
|
|
}}
|
|
>
|
|
{destination.name}
|
|
<CheckIcon
|
|
className={cn(
|
|
"ml-auto h-4 w-4",
|
|
destination.destinationId === field.value
|
|
? "opacity-100"
|
|
: "opacity-0",
|
|
)}
|
|
/>
|
|
</CommandItem>
|
|
))}
|
|
</CommandGroup>
|
|
</ScrollArea>
|
|
</Command>
|
|
</PopoverContent>
|
|
</Popover>
|
|
<FormMessage />
|
|
</FormItem>
|
|
)}
|
|
/>
|
|
|
|
<FormField
|
|
control={form.control}
|
|
name="backupFile"
|
|
render={({ field }) => (
|
|
<FormItem className="">
|
|
<FormLabel className="flex items-center">
|
|
Search Backup Files
|
|
{field.value && (
|
|
<Badge variant="outline" className="truncate w-52">
|
|
{field.value}
|
|
<Copy
|
|
className="ml-2 size-4 cursor-pointer"
|
|
onClick={(e) => {
|
|
e.stopPropagation();
|
|
e.preventDefault();
|
|
copy(field.value);
|
|
toast.success("Backup file copied to clipboard");
|
|
}}
|
|
/>
|
|
</Badge>
|
|
)}
|
|
</FormLabel>
|
|
<Popover modal>
|
|
<PopoverTrigger asChild>
|
|
<FormControl>
|
|
<Button
|
|
variant="outline"
|
|
className={cn(
|
|
"w-full justify-between",
|
|
!field.value && "text-muted-foreground",
|
|
)}
|
|
>
|
|
<span className="truncate text-left flex-1 w-52">
|
|
{field.value || "Search and select a backup file"}
|
|
</span>
|
|
<ChevronsUpDown className="ml-2 h-4 w-4 shrink-0 opacity-50" />
|
|
</Button>
|
|
</FormControl>
|
|
</PopoverTrigger>
|
|
<PopoverContent className="p-0" align="start">
|
|
<Command>
|
|
<CommandInput
|
|
placeholder="Search backup files..."
|
|
value={search}
|
|
onValueChange={handleSearchChange}
|
|
className="h-9"
|
|
/>
|
|
{isPending ? (
|
|
<div className="py-6 text-center text-sm">
|
|
Loading backup files...
|
|
</div>
|
|
) : files.length === 0 && search ? (
|
|
<div className="py-6 text-center text-sm text-muted-foreground">
|
|
No backup files found for "{search}"
|
|
</div>
|
|
) : files.length === 0 ? (
|
|
<div className="py-6 text-center text-sm text-muted-foreground">
|
|
No backup files available
|
|
</div>
|
|
) : (
|
|
<ScrollArea className="h-64">
|
|
<CommandGroup className="w-96">
|
|
{files?.map((file) => (
|
|
<CommandItem
|
|
value={file.Path}
|
|
key={file.Path}
|
|
onSelect={() => {
|
|
form.setValue("backupFile", file.Path);
|
|
if (file.IsDir) {
|
|
setSearch(`${file.Path}/`);
|
|
setDebouncedSearchTerm(`${file.Path}/`);
|
|
} else {
|
|
setSearch(file.Path);
|
|
setDebouncedSearchTerm(file.Path);
|
|
}
|
|
}}
|
|
>
|
|
<div className="flex w-full flex-col gap-1">
|
|
<div className="flex w-full justify-between">
|
|
<span className="font-medium">
|
|
{file.Path}
|
|
</span>
|
|
|
|
<CheckIcon
|
|
className={cn(
|
|
"ml-auto h-4 w-4",
|
|
file.Path === field.value
|
|
? "opacity-100"
|
|
: "opacity-0",
|
|
)}
|
|
/>
|
|
</div>
|
|
<div className="flex items-center gap-4 text-xs text-muted-foreground">
|
|
<span>
|
|
Size: {formatBytes(file.Size)}
|
|
</span>
|
|
{file.IsDir && (
|
|
<span className="text-blue-500">
|
|
Directory
|
|
</span>
|
|
)}
|
|
{file.Hashes?.MD5 && (
|
|
<span>MD5: {file.Hashes.MD5}</span>
|
|
)}
|
|
</div>
|
|
</div>
|
|
</CommandItem>
|
|
))}
|
|
</CommandGroup>
|
|
</ScrollArea>
|
|
)}
|
|
</Command>
|
|
</PopoverContent>
|
|
</Popover>
|
|
<FormMessage />
|
|
</FormItem>
|
|
)}
|
|
/>
|
|
<FormField
|
|
control={form.control}
|
|
name="volumeName"
|
|
render={({ field }) => (
|
|
<FormItem>
|
|
<FormLabel>Volume Name</FormLabel>
|
|
<FormControl>
|
|
<Input placeholder="Enter volume name" {...field} />
|
|
</FormControl>
|
|
<FormMessage />
|
|
</FormItem>
|
|
)}
|
|
/>
|
|
|
|
<DialogFooter>
|
|
<Button
|
|
isLoading={isDeploying}
|
|
form="hook-form-restore-backup"
|
|
type="submit"
|
|
// disabled={
|
|
// !form.watch("backupFile") ||
|
|
// (backupType === "compose" && !form.watch("databaseType"))
|
|
// }
|
|
>
|
|
Restore
|
|
</Button>
|
|
</DialogFooter>
|
|
</form>
|
|
</Form>
|
|
|
|
<DrawerLogs
|
|
isOpen={isDrawerOpen}
|
|
onClose={() => {
|
|
setIsDrawerOpen(false);
|
|
setFilteredLogs([]);
|
|
setIsDeploying(false);
|
|
// refetch();
|
|
}}
|
|
filteredLogs={filteredLogs}
|
|
/>
|
|
</DialogContent>
|
|
</Dialog>
|
|
);
|
|
};
|