Files
dokploy/apps/dokploy/components/dashboard/database/backups/add-backup.tsx
T

508 lines
14 KiB
TypeScript

import { AlertBlock } from "@/components/shared/alert-block";
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,
FormDescription,
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 {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
import { Switch } from "@/components/ui/switch";
import {
Tooltip,
TooltipContent,
TooltipProvider,
TooltipTrigger,
} from "@/components/ui/tooltip";
import { cn } from "@/lib/utils";
import { api } from "@/utils/api";
import { zodResolver } from "@hookform/resolvers/zod";
import { DatabaseZap, PlusIcon, RefreshCw } from "lucide-react";
import { CheckIcon, ChevronsUpDown } from "lucide-react";
import { useEffect, useState } from "react";
import { useForm } from "react-hook-form";
import { toast } from "sonner";
import { z } from "zod";
type CacheType = "cache" | "fetch";
const AddPostgresBackup1Schema = z.object({
destinationId: z.string().min(1, "Destination required"),
schedule: z.string().min(1, "Schedule (Cron) required"),
prefix: z.string().min(1, "Prefix required"),
enabled: z.boolean(),
database: z.string().min(1, "Database required"),
keepLatestCount: z.coerce.number().optional(),
serviceName: z.string().nullable(),
});
type AddPostgresBackup = z.infer<typeof AddPostgresBackup1Schema>;
interface Props {
id: string;
databaseType: "postgres" | "mariadb" | "mysql" | "mongo" | "web-server";
refetch: () => void;
backupType: "database" | "compose";
}
export const AddBackup = ({
id,
databaseType,
refetch,
backupType = "database",
}: Props) => {
const { data, isLoading } = api.destination.all.useQuery();
const [cacheType, setCacheType] = useState<CacheType>("cache");
const { mutateAsync: createBackup, isLoading: isCreatingPostgresBackup } =
api.backup.create.useMutation();
const form = useForm<AddPostgresBackup>({
defaultValues: {
database: "",
destinationId: "",
enabled: true,
prefix: "/",
schedule: "",
keepLatestCount: undefined,
serviceName: null,
},
resolver: zodResolver(AddPostgresBackup1Schema),
});
const {
data: services,
isFetching: isLoadingServices,
error: errorServices,
refetch: refetchServices,
} = api.compose.loadServices.useQuery(
{
composeId: id,
type: cacheType,
},
{
retry: false,
refetchOnWindowFocus: false,
enabled: backupType === "compose",
},
);
useEffect(() => {
form.reset({
database: databaseType === "web-server" ? "dokploy" : "",
destinationId: "",
enabled: true,
prefix: "/",
schedule: "",
keepLatestCount: undefined,
serviceName: null,
});
}, [form, form.reset, form.formState.isSubmitSuccessful, databaseType]);
const onSubmit = async (data: AddPostgresBackup) => {
if (backupType === "compose" && !data.serviceName) {
form.setError("serviceName", {
type: "manual",
message: "Service name is required for compose backups",
});
return;
}
const getDatabaseId =
backupType === "compose"
? {
composeId: id,
}
: databaseType === "postgres"
? {
postgresId: id,
}
: databaseType === "mariadb"
? {
mariadbId: id,
}
: databaseType === "mysql"
? {
mysqlId: id,
}
: databaseType === "mongo"
? {
mongoId: id,
}
: databaseType === "web-server"
? {
userId: id,
}
: undefined;
await createBackup({
destinationId: data.destinationId,
prefix: data.prefix,
schedule: data.schedule,
enabled: data.enabled,
database: data.database,
keepLatestCount: data.keepLatestCount,
databaseType: databaseType,
serviceName: data.serviceName,
...getDatabaseId,
backupType,
})
.then(async () => {
toast.success("Backup Created");
refetch();
})
.catch(() => {
toast.error("Error creating a backup");
});
};
return (
<Dialog>
<DialogTrigger asChild>
<Button>
<PlusIcon className="h-4 w-4" />
Create Backup
</Button>
</DialogTrigger>
<DialogContent className="sm:max-w-lg max-h-screen overflow-y-auto">
<DialogHeader>
<DialogTitle>Create a backup</DialogTitle>
<DialogDescription>Add a new backup</DialogDescription>
</DialogHeader>
<Form {...form}>
<form
id="hook-form-add-backup"
onSubmit={form.handleSubmit(onSubmit)}
className="grid w-full gap-4"
>
<div className="grid grid-cols-1 gap-4">
{errorServices && (
<AlertBlock type="warning" className="[overflow-wrap:anywhere]">
{errorServices?.message}
</AlertBlock>
)}
<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 !bg-input",
!field.value && "text-muted-foreground",
)}
>
{isLoading
? "Loading...."
: field.value
? data?.find(
(destination) =>
destination.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 Destination..."
className="h-9"
/>
{isLoading && (
<span className="py-6 text-center text-sm">
Loading Destinations....
</span>
)}
<CommandEmpty>No destinations found.</CommandEmpty>
<ScrollArea className="h-64">
<CommandGroup>
{data?.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>
)}
/>
{backupType === "compose" && (
<div className="flex flex-row items-end w-full gap-4">
<FormField
control={form.control}
name="serviceName"
render={({ field }) => (
<FormItem className="w-full">
<FormLabel>Service Name</FormLabel>
<div className="flex gap-2">
<Select
onValueChange={field.onChange}
value={field.value || undefined}
>
<FormControl>
<SelectTrigger>
<SelectValue placeholder="Select a service name" />
</SelectTrigger>
</FormControl>
<SelectContent>
{services?.map((service, index) => (
<SelectItem
value={service}
key={`${service}-${index}`}
>
{service}
</SelectItem>
))}
{(!services || services.length === 0) && (
<SelectItem value="none" disabled>
Empty
</SelectItem>
)}
</SelectContent>
</Select>
<TooltipProvider delayDuration={0}>
<Tooltip>
<TooltipTrigger asChild>
<Button
variant="secondary"
type="button"
isLoading={isLoadingServices}
onClick={() => {
if (cacheType === "fetch") {
refetchServices();
} else {
setCacheType("fetch");
}
}}
>
<RefreshCw className="size-4 text-muted-foreground" />
</Button>
</TooltipTrigger>
<TooltipContent
side="left"
sideOffset={5}
className="max-w-[10rem]"
>
<p>
Fetch: Will clone the repository and load the
services
</p>
</TooltipContent>
</Tooltip>
</TooltipProvider>
<TooltipProvider delayDuration={0}>
<Tooltip>
<TooltipTrigger asChild>
<Button
variant="secondary"
type="button"
isLoading={isLoadingServices}
onClick={() => {
if (cacheType === "cache") {
refetchServices();
} else {
setCacheType("cache");
}
}}
>
<DatabaseZap className="size-4 text-muted-foreground" />
</Button>
</TooltipTrigger>
<TooltipContent
side="left"
sideOffset={5}
className="max-w-[10rem]"
>
<p>
Cache: If you previously deployed this
compose, it will read the services from the
last deployment/fetch from the repository
</p>
</TooltipContent>
</Tooltip>
</TooltipProvider>
</div>
<FormMessage />
</FormItem>
)}
/>
</div>
)}
<FormField
control={form.control}
name="database"
render={({ field }) => {
return (
<FormItem>
<FormLabel>Database</FormLabel>
<FormControl>
<Input
disabled={databaseType === "web-server"}
placeholder={"dokploy"}
{...field}
/>
</FormControl>
<FormMessage />
</FormItem>
);
}}
/>
<FormField
control={form.control}
name="schedule"
render={({ field }) => {
return (
<FormItem>
<FormLabel>Schedule (Cron)</FormLabel>
<FormControl>
<Input placeholder={"0 0 * * *"} {...field} />
</FormControl>
<FormMessage />
</FormItem>
);
}}
/>
<FormField
control={form.control}
name="prefix"
render={({ field }) => {
return (
<FormItem>
<FormLabel>Prefix Destination</FormLabel>
<FormControl>
<Input placeholder={"dokploy/"} {...field} />
</FormControl>
<FormDescription>
Use if you want to back up in a specific path of your
destination/bucket
</FormDescription>
<FormMessage />
</FormItem>
);
}}
/>
<FormField
control={form.control}
name="keepLatestCount"
render={({ field }) => {
return (
<FormItem>
<FormLabel>Keep the latest</FormLabel>
<FormControl>
<Input
type="number"
placeholder={"keeps all the backups if left empty"}
{...field}
/>
</FormControl>
<FormDescription>
Optional. If provided, only keeps the latest N backups
in the cloud.
</FormDescription>
<FormMessage />
</FormItem>
);
}}
/>
<FormField
control={form.control}
name="enabled"
render={({ field }) => (
<FormItem className="flex flex-row items-center justify-between rounded-lg border p-3 ">
<div className="space-y-0.5">
<FormLabel>Enabled</FormLabel>
<FormDescription>
Enable or disable the backup
</FormDescription>
</div>
<FormControl>
<Switch
checked={field.value}
onCheckedChange={field.onChange}
/>
</FormControl>
</FormItem>
)}
/>
</div>
<DialogFooter>
<Button
isLoading={isCreatingPostgresBackup}
form="hook-form-add-backup"
type="submit"
>
Create
</Button>
</DialogFooter>
</form>
</Form>
</DialogContent>
</Dialog>
);
};