mirror of
https://github.com/andwati/dokploy.git
synced 2026-11-03 18:52:17 +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.
694 lines
18 KiB
TypeScript
694 lines
18 KiB
TypeScript
import { standardSchemaResolver } from "@hookform/resolvers/standard-schema";
|
|
import {
|
|
CheckIcon,
|
|
ChevronsUpDown,
|
|
DatabaseZap,
|
|
Info,
|
|
PenBoxIcon,
|
|
PlusCircle,
|
|
RefreshCw,
|
|
} from "lucide-react";
|
|
import { useEffect, useState } from "react";
|
|
import { type Control, useForm } from "react-hook-form";
|
|
import { toast } from "sonner";
|
|
import { z } from "zod";
|
|
import { AlertBlock } from "@/components/shared/alert-block";
|
|
import { CodeEditor } from "@/components/shared/code-editor";
|
|
import { Button } from "@/components/ui/button";
|
|
import {
|
|
Command,
|
|
CommandEmpty,
|
|
CommandGroup,
|
|
CommandInput,
|
|
CommandItem,
|
|
CommandList,
|
|
} from "@/components/ui/command";
|
|
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 {
|
|
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 type { CacheType } from "../domains/handle-domain";
|
|
import { getTimezoneLabel, TIMEZONES } from "./timezones";
|
|
|
|
export const commonCronExpressions = [
|
|
{ label: "Every minute", value: "* * * * *" },
|
|
{ label: "Every hour", value: "0 * * * *" },
|
|
{ label: "Every day at midnight", value: "0 0 * * *" },
|
|
{ label: "Every Sunday at midnight", value: "0 0 * * 0" },
|
|
{ label: "Every month on the 1st at midnight", value: "0 0 1 * *" },
|
|
{ label: "Every 15 minutes", value: "*/15 * * * *" },
|
|
{ label: "Every weekday at midnight", value: "0 0 * * 1-5" },
|
|
{ label: "Custom", value: "custom" },
|
|
];
|
|
|
|
const formSchema = z
|
|
.object({
|
|
name: z.string().min(1, "Name is required"),
|
|
description: z.string().optional(),
|
|
cronExpression: z.string().min(1, "Cron expression is required"),
|
|
shellType: z.enum(["bash", "sh"]).default("bash"),
|
|
command: z.string(),
|
|
enabled: z.boolean().default(true),
|
|
serviceName: z.string(),
|
|
scheduleType: z.enum([
|
|
"application",
|
|
"compose",
|
|
"server",
|
|
"dokploy-server",
|
|
]),
|
|
script: z.string(),
|
|
timezone: z.string().optional(),
|
|
})
|
|
.superRefine((data, ctx) => {
|
|
if (data.scheduleType === "compose" && !data.serviceName) {
|
|
ctx.addIssue({
|
|
code: z.ZodIssueCode.custom,
|
|
message: "Service name is required",
|
|
path: ["serviceName"],
|
|
});
|
|
}
|
|
|
|
if (
|
|
(data.scheduleType === "dokploy-server" ||
|
|
data.scheduleType === "server") &&
|
|
!data.script
|
|
) {
|
|
ctx.addIssue({
|
|
code: z.ZodIssueCode.custom,
|
|
message: "Script is required",
|
|
path: ["script"],
|
|
});
|
|
}
|
|
|
|
if (
|
|
(data.scheduleType === "application" ||
|
|
data.scheduleType === "compose") &&
|
|
!data.command
|
|
) {
|
|
ctx.addIssue({
|
|
code: z.ZodIssueCode.custom,
|
|
message: "Command is required",
|
|
path: ["command"],
|
|
});
|
|
}
|
|
});
|
|
|
|
interface Props {
|
|
id?: string;
|
|
scheduleId?: string;
|
|
scheduleType?: "application" | "compose" | "server" | "dokploy-server";
|
|
}
|
|
|
|
export const ScheduleFormField = ({
|
|
name,
|
|
formControl,
|
|
}: {
|
|
name: string;
|
|
formControl: Control<any>;
|
|
}) => {
|
|
const [selectedOption, setSelectedOption] = useState("");
|
|
|
|
return (
|
|
<FormField
|
|
control={formControl}
|
|
name={name}
|
|
render={({ field }) => (
|
|
<FormItem>
|
|
<FormLabel className="flex items-center gap-2">
|
|
Schedule
|
|
<TooltipProvider>
|
|
<Tooltip>
|
|
<TooltipTrigger asChild>
|
|
<Info className="w-4 h-4 text-muted-foreground cursor-help" />
|
|
</TooltipTrigger>
|
|
<TooltipContent>
|
|
<p>Cron expression format: minute hour day month weekday</p>
|
|
<p>Example: 0 0 * * * (daily at midnight)</p>
|
|
</TooltipContent>
|
|
</Tooltip>
|
|
</TooltipProvider>
|
|
</FormLabel>
|
|
<div className="flex flex-col gap-2">
|
|
<Select
|
|
value={selectedOption}
|
|
onValueChange={(value) => {
|
|
setSelectedOption(value);
|
|
field.onChange(value === "custom" ? "" : value);
|
|
}}
|
|
>
|
|
<FormControl>
|
|
<SelectTrigger>
|
|
<SelectValue placeholder="Select a predefined schedule" />
|
|
</SelectTrigger>
|
|
</FormControl>
|
|
<SelectContent>
|
|
{commonCronExpressions.map((expr) => (
|
|
<SelectItem key={expr.value} value={expr.value}>
|
|
{expr.label}
|
|
{expr.value !== "custom" && ` (${expr.value})`}
|
|
</SelectItem>
|
|
))}
|
|
</SelectContent>
|
|
</Select>
|
|
<div className="relative">
|
|
<FormControl>
|
|
<Input
|
|
placeholder="Custom cron expression (e.g., 0 0 * * *)"
|
|
{...field}
|
|
onChange={(e) => {
|
|
const value = e.target.value;
|
|
const commonExpression = commonCronExpressions.find(
|
|
(expression) => expression.value === value,
|
|
);
|
|
if (commonExpression) {
|
|
setSelectedOption(commonExpression.value);
|
|
} else {
|
|
setSelectedOption("custom");
|
|
}
|
|
field.onChange(e);
|
|
}}
|
|
/>
|
|
</FormControl>
|
|
</div>
|
|
</div>
|
|
<FormDescription>
|
|
Choose a predefined schedule or enter a custom cron expression
|
|
</FormDescription>
|
|
<FormMessage />
|
|
</FormItem>
|
|
)}
|
|
/>
|
|
);
|
|
};
|
|
|
|
export const HandleSchedules = ({ id, scheduleId, scheduleType }: Props) => {
|
|
const [isOpen, setIsOpen] = useState(false);
|
|
const [cacheType, setCacheType] = useState<CacheType>("cache");
|
|
const utils = api.useUtils();
|
|
const form = useForm({
|
|
resolver: standardSchemaResolver(formSchema),
|
|
defaultValues: {
|
|
name: "",
|
|
description: "",
|
|
cronExpression: "",
|
|
shellType: "bash",
|
|
command: "",
|
|
enabled: true,
|
|
serviceName: "",
|
|
scheduleType: scheduleType || "application",
|
|
script: "",
|
|
timezone: undefined,
|
|
},
|
|
});
|
|
|
|
const scheduleTypeForm = form.watch("scheduleType");
|
|
|
|
const { data: schedule } = api.schedule.one.useQuery(
|
|
{ scheduleId: scheduleId || "" },
|
|
{ enabled: !!scheduleId },
|
|
);
|
|
|
|
const {
|
|
data: services,
|
|
isFetching: isLoadingServices,
|
|
error: errorServices,
|
|
refetch: refetchServices,
|
|
} = api.compose.loadServices.useQuery(
|
|
{
|
|
composeId: id || "",
|
|
type: cacheType,
|
|
},
|
|
{
|
|
retry: false,
|
|
refetchOnWindowFocus: false,
|
|
enabled: !!id && scheduleType === "compose",
|
|
},
|
|
);
|
|
|
|
useEffect(() => {
|
|
if (scheduleId && schedule) {
|
|
form.reset({
|
|
name: schedule.name,
|
|
description: schedule.description || "",
|
|
cronExpression: schedule.cronExpression,
|
|
shellType: schedule.shellType,
|
|
command: schedule.command,
|
|
enabled: schedule.enabled,
|
|
serviceName: schedule.serviceName || "",
|
|
scheduleType: schedule.scheduleType,
|
|
script: schedule.script || "",
|
|
timezone: schedule.timezone || undefined,
|
|
});
|
|
}
|
|
}, [form, schedule, scheduleId]);
|
|
|
|
const { mutateAsync, isPending } = scheduleId
|
|
? api.schedule.update.useMutation()
|
|
: api.schedule.create.useMutation();
|
|
|
|
const onSubmit = async (values: z.output<typeof formSchema>) => {
|
|
if (!id && !scheduleId) return;
|
|
|
|
await mutateAsync({
|
|
...values,
|
|
scheduleId: scheduleId || "",
|
|
...(scheduleType === "application" && {
|
|
applicationId: id || "",
|
|
}),
|
|
...(scheduleType === "compose" && {
|
|
composeId: id || "",
|
|
}),
|
|
...(scheduleType === "server" && {
|
|
serverId: id || "",
|
|
}),
|
|
...(scheduleType === "dokploy-server" && {
|
|
userId: id || "",
|
|
}),
|
|
})
|
|
.then(() => {
|
|
toast.success(
|
|
`Schedule ${scheduleId ? "updated" : "created"} successfully`,
|
|
);
|
|
utils.schedule.list.invalidate({
|
|
id,
|
|
scheduleType,
|
|
});
|
|
setIsOpen(false);
|
|
})
|
|
.catch((error) => {
|
|
toast.error(
|
|
error instanceof Error ? error.message : "An unknown error occurred",
|
|
);
|
|
});
|
|
};
|
|
|
|
return (
|
|
<Dialog open={isOpen} onOpenChange={setIsOpen}>
|
|
<DialogTrigger asChild>
|
|
{scheduleId ? (
|
|
<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 Schedule
|
|
</Button>
|
|
)}
|
|
</DialogTrigger>
|
|
<DialogContent
|
|
className={cn(
|
|
scheduleTypeForm === "dokploy-server" || scheduleTypeForm === "server"
|
|
? "sm:max-w-2xl"
|
|
: "sm:max-w-lg",
|
|
)}
|
|
>
|
|
<DialogHeader>
|
|
<DialogTitle>{scheduleId ? "Edit" : "Create"} Schedule</DialogTitle>
|
|
<DialogDescription>
|
|
{scheduleId ? "Manage" : "Create"} a schedule to run a task at a
|
|
specific time or interval.
|
|
</DialogDescription>
|
|
</DialogHeader>
|
|
<Form {...form}>
|
|
<form onSubmit={form.handleSubmit(onSubmit)} className="space-y-6">
|
|
{scheduleTypeForm === "compose" && (
|
|
<div className="flex flex-col w-full gap-4">
|
|
{errorServices && (
|
|
<AlertBlock type="warning" className="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-40"
|
|
>
|
|
<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-40"
|
|
>
|
|
<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="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>
|
|
)}
|
|
/>
|
|
|
|
<FormField
|
|
control={form.control}
|
|
name="description"
|
|
render={({ field }) => (
|
|
<FormItem>
|
|
<FormLabel>Description</FormLabel>
|
|
<FormControl>
|
|
<Input
|
|
placeholder="Backs up the database every day at midnight"
|
|
{...field}
|
|
/>
|
|
</FormControl>
|
|
<FormDescription>
|
|
Optional description of what this schedule does
|
|
</FormDescription>
|
|
<FormMessage />
|
|
</FormItem>
|
|
)}
|
|
/>
|
|
|
|
<ScheduleFormField
|
|
name="cronExpression"
|
|
formControl={form.control}
|
|
/>
|
|
|
|
<FormField
|
|
control={form.control}
|
|
name="timezone"
|
|
render={({ field }) => (
|
|
<FormItem>
|
|
<FormLabel className="flex items-center gap-2">
|
|
Timezone
|
|
<TooltipProvider>
|
|
<Tooltip>
|
|
<TooltipTrigger asChild>
|
|
<Info className="w-4 h-4 text-muted-foreground cursor-help" />
|
|
</TooltipTrigger>
|
|
<TooltipContent>
|
|
<p>
|
|
Select a timezone for the schedule. If not
|
|
specified, UTC will be used.
|
|
</p>
|
|
</TooltipContent>
|
|
</Tooltip>
|
|
</TooltipProvider>
|
|
</FormLabel>
|
|
<Popover>
|
|
<PopoverTrigger asChild>
|
|
<FormControl>
|
|
<Button
|
|
variant="outline"
|
|
className={cn(
|
|
"w-full justify-between",
|
|
!field.value && "text-muted-foreground",
|
|
)}
|
|
>
|
|
{getTimezoneLabel(field.value)}
|
|
<ChevronsUpDown className="ml-2 h-4 w-4 shrink-0 opacity-50" />
|
|
</Button>
|
|
</FormControl>
|
|
</PopoverTrigger>
|
|
<PopoverContent className="w-[400px] p-0" align="start">
|
|
<Command>
|
|
<CommandInput
|
|
placeholder="Search timezone..."
|
|
className="h-9"
|
|
/>
|
|
<CommandList>
|
|
<CommandEmpty>No timezone found.</CommandEmpty>
|
|
<ScrollArea className="h-72">
|
|
{Object.entries(TIMEZONES).map(
|
|
([region, zones]) => (
|
|
<CommandGroup key={region} heading={region}>
|
|
{zones.map((tz) => (
|
|
<CommandItem
|
|
key={tz.value}
|
|
value={`${region} ${tz.label} ${tz.value}`}
|
|
onSelect={() => {
|
|
field.onChange(tz.value);
|
|
}}
|
|
>
|
|
{tz.value}
|
|
<CheckIcon
|
|
className={cn(
|
|
"ml-auto h-4 w-4",
|
|
field.value === tz.value
|
|
? "opacity-100"
|
|
: "opacity-0",
|
|
)}
|
|
/>
|
|
</CommandItem>
|
|
))}
|
|
</CommandGroup>
|
|
),
|
|
)}
|
|
</ScrollArea>
|
|
</CommandList>
|
|
</Command>
|
|
</PopoverContent>
|
|
</Popover>
|
|
<FormDescription>
|
|
Optional: Choose a timezone for the schedule execution time
|
|
</FormDescription>
|
|
<FormMessage />
|
|
</FormItem>
|
|
)}
|
|
/>
|
|
|
|
{(scheduleTypeForm === "application" ||
|
|
scheduleTypeForm === "compose") && (
|
|
<>
|
|
<FormField
|
|
control={form.control}
|
|
name="shellType"
|
|
render={({ field }) => (
|
|
<FormItem>
|
|
<FormLabel className="flex items-center gap-2">
|
|
Shell Type
|
|
</FormLabel>
|
|
<Select
|
|
onValueChange={field.onChange}
|
|
defaultValue={field.value}
|
|
>
|
|
<FormControl>
|
|
<SelectTrigger>
|
|
<SelectValue placeholder="Select shell type" />
|
|
</SelectTrigger>
|
|
</FormControl>
|
|
<SelectContent>
|
|
<SelectItem value="bash">Bash</SelectItem>
|
|
<SelectItem value="sh">Sh</SelectItem>
|
|
</SelectContent>
|
|
</Select>
|
|
<FormDescription>
|
|
Choose the shell to execute your command
|
|
</FormDescription>
|
|
<FormMessage />
|
|
</FormItem>
|
|
)}
|
|
/>
|
|
<FormField
|
|
control={form.control}
|
|
name="command"
|
|
render={({ field }) => (
|
|
<FormItem>
|
|
<FormLabel className="flex items-center gap-2">
|
|
Command
|
|
</FormLabel>
|
|
<FormControl>
|
|
<Input placeholder="npm run backup" {...field} />
|
|
</FormControl>
|
|
<FormDescription>
|
|
The command to execute in your container
|
|
</FormDescription>
|
|
<FormMessage />
|
|
</FormItem>
|
|
)}
|
|
/>
|
|
</>
|
|
)}
|
|
|
|
{(scheduleTypeForm === "dokploy-server" ||
|
|
scheduleTypeForm === "server") && (
|
|
<FormField
|
|
control={form.control}
|
|
name="script"
|
|
render={({ field }) => (
|
|
<FormItem>
|
|
<FormLabel>Script</FormLabel>
|
|
<FormControl>
|
|
<FormControl>
|
|
<CodeEditor
|
|
language="shell"
|
|
placeholder={`# This is a comment
|
|
echo "Hello, world!"
|
|
`}
|
|
className="h-96 font-mono"
|
|
{...field}
|
|
/>
|
|
</FormControl>
|
|
</FormControl>
|
|
<FormMessage />
|
|
</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">
|
|
{scheduleId ? "Update" : "Create"} Schedule
|
|
</Button>
|
|
</form>
|
|
</Form>
|
|
</DialogContent>
|
|
</Dialog>
|
|
);
|
|
};
|