mirror of
https://github.com/Dokploy/dokploy.git
synced 2026-07-15 19:05:28 +02:00
642 lines
17 KiB
TypeScript
642 lines
17 KiB
TypeScript
import { standardSchemaResolver as zodResolver } from "@hookform/resolvers/standard-schema";
|
||
import { DatabaseZap, PenBoxIcon, PlusCircle, RefreshCw } from "lucide-react";
|
||
import { useEffect, 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 { Button } from "@/components/ui/button";
|
||
import {
|
||
Dialog,
|
||
DialogContent,
|
||
DialogDescription,
|
||
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 {
|
||
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 type { CacheType } from "../domains/handle-domain";
|
||
import { ScheduleFormField } from "../schedules/handle-schedules";
|
||
|
||
const formSchema = z
|
||
.object({
|
||
name: z.string().min(1, "Name is required"),
|
||
cronExpression: z.string().min(1, "Cron expression is required"),
|
||
volumeName: z
|
||
.string()
|
||
.min(1, "Volume name is required")
|
||
.regex(
|
||
/^[a-zA-Z0-9][a-zA-Z0-9_.-]*$/,
|
||
"Invalid volume name. Use letters, numbers, '._-' and start with a letter/number.",
|
||
),
|
||
prefix: z.string(),
|
||
keepLatestCount: z.coerce
|
||
.number()
|
||
.int()
|
||
.gte(1, "Must be at least 1")
|
||
.optional()
|
||
.nullable(),
|
||
turnOff: z.boolean().default(false),
|
||
enabled: z.boolean().default(true),
|
||
serviceType: z.enum([
|
||
"application",
|
||
"compose",
|
||
"postgres",
|
||
"mariadb",
|
||
"mongo",
|
||
"mysql",
|
||
"redis",
|
||
]),
|
||
serviceName: z.string(),
|
||
destinationId: z.string().min(1, "Destination required"),
|
||
})
|
||
.superRefine((data, ctx) => {
|
||
if (data.serviceType === "compose" && !data.serviceName) {
|
||
ctx.addIssue({
|
||
code: z.ZodIssueCode.custom,
|
||
message: "Service name is required",
|
||
path: ["serviceName"],
|
||
});
|
||
}
|
||
|
||
if (data.serviceType === "compose" && !data.serviceName) {
|
||
ctx.addIssue({
|
||
code: z.ZodIssueCode.custom,
|
||
message: "Service name is required",
|
||
path: ["serviceName"],
|
||
});
|
||
}
|
||
});
|
||
|
||
interface Props {
|
||
id?: string;
|
||
volumeBackupId?: string;
|
||
volumeBackupType?:
|
||
| "application"
|
||
| "compose"
|
||
| "postgres"
|
||
| "mariadb"
|
||
| "mongo"
|
||
| "mysql"
|
||
| "redis";
|
||
}
|
||
|
||
export const HandleVolumeBackups = ({
|
||
id,
|
||
volumeBackupId,
|
||
volumeBackupType,
|
||
}: Props) => {
|
||
const [isOpen, setIsOpen] = useState(false);
|
||
const [cacheType, setCacheType] = useState<CacheType>("cache");
|
||
const [keepLatestCountInput, setKeepLatestCountInput] = useState("");
|
||
|
||
const utils = api.useUtils();
|
||
const form = useForm({
|
||
resolver: zodResolver(formSchema),
|
||
defaultValues: {
|
||
name: "",
|
||
cronExpression: "",
|
||
volumeName: "",
|
||
prefix: "",
|
||
keepLatestCount: undefined,
|
||
turnOff: false,
|
||
enabled: true,
|
||
serviceName: "",
|
||
serviceType: volumeBackupType,
|
||
},
|
||
});
|
||
|
||
const serviceTypeForm = volumeBackupType;
|
||
const { data: destinations } = api.destination.all.useQuery();
|
||
const { data: volumeBackup } = api.volumeBackups.one.useQuery(
|
||
{ volumeBackupId: volumeBackupId || "" },
|
||
{ enabled: !!volumeBackupId },
|
||
);
|
||
|
||
const { data: mounts } = api.mounts.allNamedByApplicationId.useQuery(
|
||
{ applicationId: id || "" },
|
||
{ enabled: !!id && volumeBackupType === "application" },
|
||
);
|
||
|
||
const {
|
||
data: services,
|
||
isFetching: isLoadingServices,
|
||
error: errorServices,
|
||
refetch: refetchServices,
|
||
} = api.compose.loadServices.useQuery(
|
||
{
|
||
composeId: id || "",
|
||
type: cacheType,
|
||
},
|
||
{
|
||
retry: false,
|
||
refetchOnWindowFocus: false,
|
||
enabled: !!id && volumeBackupType === "compose",
|
||
},
|
||
);
|
||
|
||
const serviceName = form.watch("serviceName");
|
||
|
||
const { data: mountsByService } = api.compose.loadMountsByService.useQuery(
|
||
{
|
||
composeId: id || "",
|
||
serviceName,
|
||
},
|
||
{
|
||
enabled: !!id && volumeBackupType === "compose" && !!serviceName,
|
||
},
|
||
);
|
||
|
||
useEffect(() => {
|
||
if (volumeBackupId && volumeBackup) {
|
||
form.reset({
|
||
name: volumeBackup.name,
|
||
cronExpression: volumeBackup.cronExpression,
|
||
volumeName: volumeBackup.volumeName || "",
|
||
prefix: volumeBackup.prefix,
|
||
keepLatestCount: volumeBackup.keepLatestCount || undefined,
|
||
turnOff: volumeBackup.turnOff,
|
||
enabled: volumeBackup.enabled || false,
|
||
serviceName: volumeBackup.serviceName || "",
|
||
destinationId: volumeBackup.destinationId,
|
||
serviceType: volumeBackup.serviceType,
|
||
});
|
||
setKeepLatestCountInput(
|
||
volumeBackup.keepLatestCount !== null &&
|
||
volumeBackup.keepLatestCount !== undefined
|
||
? String(volumeBackup.keepLatestCount)
|
||
: "",
|
||
);
|
||
}
|
||
}, [form, volumeBackup, volumeBackupId]);
|
||
|
||
const { mutateAsync, isPending } = volumeBackupId
|
||
? api.volumeBackups.update.useMutation()
|
||
: api.volumeBackups.create.useMutation();
|
||
|
||
const onSubmit = async (values: z.infer<typeof formSchema>) => {
|
||
if (!id && !volumeBackupId) return;
|
||
|
||
const preparedKeepLatestCount =
|
||
keepLatestCountInput === "" ? null : (values.keepLatestCount ?? null);
|
||
|
||
await mutateAsync({
|
||
...values,
|
||
keepLatestCount: preparedKeepLatestCount ?? undefined,
|
||
destinationId: values.destinationId,
|
||
volumeBackupId: volumeBackupId || "",
|
||
serviceType: volumeBackupType,
|
||
...(volumeBackupType === "application" && {
|
||
applicationId: id || "",
|
||
}),
|
||
...(volumeBackupType === "compose" && {
|
||
composeId: id || "",
|
||
}),
|
||
...(volumeBackupType === "postgres" && {
|
||
serverId: id || "",
|
||
}),
|
||
...(volumeBackupType === "postgres" && {
|
||
postgresId: id || "",
|
||
}),
|
||
...(volumeBackupType === "mariadb" && {
|
||
mariadbId: id || "",
|
||
}),
|
||
...(volumeBackupType === "mongo" && {
|
||
mongoId: id || "",
|
||
}),
|
||
...(volumeBackupType === "mysql" && {
|
||
mysqlId: id || "",
|
||
}),
|
||
...(volumeBackupType === "redis" && {
|
||
redisId: id || "",
|
||
}),
|
||
})
|
||
.then(() => {
|
||
toast.success(
|
||
`Volume backup ${volumeBackupId ? "updated" : "created"} successfully`,
|
||
);
|
||
utils.volumeBackups.list.invalidate({
|
||
id,
|
||
volumeBackupType,
|
||
});
|
||
setIsOpen(false);
|
||
})
|
||
.catch((error) => {
|
||
toast.error(
|
||
error instanceof Error ? error.message : "An unknown error occurred",
|
||
);
|
||
});
|
||
};
|
||
|
||
return (
|
||
<Dialog open={isOpen} onOpenChange={setIsOpen}>
|
||
<DialogTrigger asChild>
|
||
{volumeBackupId ? (
|
||
<Button
|
||
variant="ghost"
|
||
size="icon"
|
||
className="group hover:bg-blue-500/10"
|
||
>
|
||
<PenBoxIcon className="size-3.5 text-primary group-hover:text-blue-500" />
|
||
</Button>
|
||
) : (
|
||
<Button>
|
||
<PlusCircle className="w-4 h-4 mr-2" />
|
||
Add Volume Backup
|
||
</Button>
|
||
)}
|
||
</DialogTrigger>
|
||
<DialogContent
|
||
className={cn(
|
||
volumeBackupType === "compose" || volumeBackupType === "application"
|
||
? "sm:max-w-2xl"
|
||
: " sm:max-w-lg",
|
||
)}
|
||
>
|
||
<DialogHeader>
|
||
<DialogTitle>
|
||
{volumeBackupId ? "Edit" : "Create"} Volume Backup
|
||
</DialogTitle>
|
||
<DialogDescription>
|
||
Create a volume backup to backup your volume to a destination
|
||
</DialogDescription>
|
||
</DialogHeader>
|
||
<Form {...form}>
|
||
<form onSubmit={form.handleSubmit(onSubmit)} className="space-y-6">
|
||
<FormField
|
||
control={form.control}
|
||
name="name"
|
||
render={({ field }) => (
|
||
<FormItem>
|
||
<FormLabel className="flex items-center gap-2">
|
||
Task Name
|
||
</FormLabel>
|
||
<FormControl>
|
||
<Input placeholder="Daily Database Backup" {...field} />
|
||
</FormControl>
|
||
<FormDescription>
|
||
A descriptive name for your scheduled task
|
||
</FormDescription>
|
||
<FormMessage />
|
||
</FormItem>
|
||
)}
|
||
/>
|
||
<ScheduleFormField
|
||
name="cronExpression"
|
||
formControl={form.control}
|
||
/>
|
||
|
||
<FormField
|
||
control={form.control}
|
||
name="destinationId"
|
||
render={({ field }) => (
|
||
<FormItem>
|
||
<FormLabel>Destination</FormLabel>
|
||
<Select
|
||
onValueChange={field.onChange}
|
||
defaultValue={field.value}
|
||
>
|
||
<FormControl>
|
||
<SelectTrigger>
|
||
<SelectValue placeholder="Select a destination" />
|
||
</SelectTrigger>
|
||
</FormControl>
|
||
<SelectContent>
|
||
{destinations?.map((destination) => (
|
||
<SelectItem
|
||
key={destination.destinationId}
|
||
value={destination.destinationId}
|
||
>
|
||
{destination.name}
|
||
</SelectItem>
|
||
))}
|
||
</SelectContent>
|
||
</Select>
|
||
<FormDescription>
|
||
Choose the backup destination where files will be stored
|
||
</FormDescription>
|
||
<FormMessage />
|
||
</FormItem>
|
||
)}
|
||
/>
|
||
{serviceTypeForm === "compose" && (
|
||
<>
|
||
<div className="flex flex-col w-full gap-4">
|
||
{errorServices && (
|
||
<AlertBlock
|
||
type="warning"
|
||
className="[overflow-wrap:anywhere]"
|
||
>
|
||
{errorServices?.message}
|
||
</AlertBlock>
|
||
)}
|
||
<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}
|
||
defaultValue={field.value || ""}
|
||
>
|
||
<FormControl>
|
||
<SelectTrigger>
|
||
<SelectValue placeholder="Select a service name" />
|
||
</SelectTrigger>
|
||
</FormControl>
|
||
|
||
<SelectContent>
|
||
{services?.map((service, index) => (
|
||
<SelectItem
|
||
value={service}
|
||
key={`${service}-${index}`}
|
||
>
|
||
{service}
|
||
</SelectItem>
|
||
))}
|
||
<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>
|
||
{mountsByService && mountsByService.length > 0 && (
|
||
<FormField
|
||
control={form.control}
|
||
name="volumeName"
|
||
render={({ field }) => (
|
||
<FormItem>
|
||
<FormLabel>Volumes</FormLabel>
|
||
<Select
|
||
onValueChange={field.onChange}
|
||
defaultValue={field.value || ""}
|
||
>
|
||
<FormControl>
|
||
<SelectTrigger>
|
||
<SelectValue placeholder="Select a volume name" />
|
||
</SelectTrigger>
|
||
</FormControl>
|
||
<SelectContent>
|
||
{mountsByService?.map((volume) => (
|
||
<SelectItem
|
||
key={volume.Name}
|
||
value={volume.Name || ""}
|
||
>
|
||
{volume.Name}
|
||
</SelectItem>
|
||
))}
|
||
</SelectContent>
|
||
</Select>
|
||
<FormDescription>
|
||
Choose the volume to backup, if you dont see the
|
||
volume here, you can type the volume name manually
|
||
</FormDescription>
|
||
<FormMessage />
|
||
</FormItem>
|
||
)}
|
||
/>
|
||
)}
|
||
</>
|
||
)}
|
||
{serviceTypeForm === "application" && (
|
||
<FormField
|
||
control={form.control}
|
||
name="volumeName"
|
||
render={({ field }) => (
|
||
<FormItem>
|
||
<FormLabel>Volumes</FormLabel>
|
||
<Select
|
||
onValueChange={field.onChange}
|
||
defaultValue={field.value || ""}
|
||
>
|
||
<FormControl>
|
||
<SelectTrigger>
|
||
<SelectValue placeholder="Select a volume name" />
|
||
</SelectTrigger>
|
||
</FormControl>
|
||
<SelectContent>
|
||
{mounts?.map((mount) => (
|
||
<SelectItem key={mount.Name} value={mount.Name || ""}>
|
||
{mount.Name}
|
||
</SelectItem>
|
||
))}
|
||
</SelectContent>
|
||
</Select>
|
||
<FormDescription>
|
||
Choose the volume to backup, if you dont see the volume
|
||
here, you can type the volume name manually
|
||
</FormDescription>
|
||
<FormMessage />
|
||
</FormItem>
|
||
)}
|
||
/>
|
||
)}
|
||
|
||
<FormField
|
||
control={form.control}
|
||
name="volumeName"
|
||
render={({ field }) => (
|
||
<FormItem>
|
||
<FormLabel>Volume Name</FormLabel>
|
||
<FormControl>
|
||
<Input placeholder="my-volume-name" {...field} />
|
||
</FormControl>
|
||
<FormDescription>
|
||
The name of the Docker volume to backup
|
||
</FormDescription>
|
||
<FormMessage />
|
||
</FormItem>
|
||
)}
|
||
/>
|
||
|
||
<FormField
|
||
control={form.control}
|
||
name="prefix"
|
||
render={({ field }) => (
|
||
<FormItem>
|
||
<FormLabel>Backup Prefix</FormLabel>
|
||
<FormControl>
|
||
<Input placeholder="backup-" {...field} />
|
||
</FormControl>
|
||
<FormDescription>
|
||
Prefix for backup files (optional)
|
||
</FormDescription>
|
||
<FormMessage />
|
||
</FormItem>
|
||
)}
|
||
/>
|
||
|
||
<FormField
|
||
control={form.control}
|
||
name="keepLatestCount"
|
||
render={({ field }) => (
|
||
<FormItem>
|
||
<FormLabel>Keep Latest Backups</FormLabel>
|
||
<FormControl>
|
||
<Input
|
||
{...field}
|
||
type="number"
|
||
min={1}
|
||
autoComplete="off"
|
||
placeholder="Leave empty to keep all"
|
||
value={keepLatestCountInput}
|
||
onChange={(e) => {
|
||
const raw = e.target.value;
|
||
setKeepLatestCountInput(raw);
|
||
if (raw === "") {
|
||
field.onChange(undefined);
|
||
} else if (/^\d+$/.test(raw)) {
|
||
field.onChange(Number(raw));
|
||
}
|
||
}}
|
||
/>
|
||
</FormControl>
|
||
<FormDescription>
|
||
How many recent backups to keep. Empty means no cleanup.
|
||
</FormDescription>
|
||
<FormMessage />
|
||
</FormItem>
|
||
)}
|
||
/>
|
||
|
||
<FormField
|
||
control={form.control}
|
||
name="turnOff"
|
||
render={({ field }) => (
|
||
<FormItem>
|
||
<FormLabel className="flex items-center gap-2">
|
||
<Switch
|
||
checked={field.value}
|
||
onCheckedChange={field.onChange}
|
||
/>
|
||
Turn Off Container During Backup
|
||
</FormLabel>
|
||
<FormDescription className="text-amber-600 dark:text-amber-400">
|
||
⚠️ The container will be temporarily stopped during backup to
|
||
prevent file corruption. This ensures data integrity but may
|
||
cause temporary service interruption.
|
||
</FormDescription>
|
||
</FormItem>
|
||
)}
|
||
/>
|
||
|
||
<FormField
|
||
control={form.control}
|
||
name="enabled"
|
||
render={({ field }) => (
|
||
<FormItem>
|
||
<FormLabel className="flex items-center gap-2">
|
||
<Switch
|
||
checked={field.value}
|
||
onCheckedChange={field.onChange}
|
||
/>
|
||
Enabled
|
||
</FormLabel>
|
||
</FormItem>
|
||
)}
|
||
/>
|
||
|
||
<Button type="submit" isLoading={isPending} className="w-full">
|
||
{volumeBackupId ? "Update" : "Create"} Volume Backup
|
||
</Button>
|
||
</form>
|
||
</Form>
|
||
</DialogContent>
|
||
</Dialog>
|
||
);
|
||
};
|