mirror of
https://github.com/Dokploy/dokploy
synced 2025-06-26 18:27:59 +00:00
Enhance schedule management with new fields and improved components
- Introduced new fields in the schedule schema: `serviceName`, `scheduleType`, and `script`, allowing for more flexible schedule configurations. - Updated the `HandleSchedules` component to incorporate the new fields, enhancing user input options for schedule creation and updates. - Refactored the `ShowSchedules` component to support the new `scheduleType` and display relevant information based on the selected type. - Improved API handling for schedule creation and updates to accommodate the new fields, ensuring proper validation and error handling. - Added a new `ShowSchedulesModal` component for better integration of schedule viewing in server settings, enhancing user experience.
This commit is contained in:
parent
49e55961db
commit
98d0f1d5bf
@ -13,7 +13,15 @@ import { api } from "@/utils/api";
|
||||
import { zodResolver } from "@hookform/resolvers/zod";
|
||||
import { useForm } from "react-hook-form";
|
||||
import { z } from "zod";
|
||||
import { Clock, Terminal, Info, PlusCircle, PenBoxIcon } from "lucide-react";
|
||||
import {
|
||||
Clock,
|
||||
Terminal,
|
||||
Info,
|
||||
PlusCircle,
|
||||
PenBoxIcon,
|
||||
RefreshCw,
|
||||
DatabaseZap,
|
||||
} from "lucide-react";
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
@ -37,7 +45,10 @@ import {
|
||||
DialogTrigger,
|
||||
} from "@/components/ui/dialog";
|
||||
import { toast } from "sonner";
|
||||
|
||||
import type { CacheType } from "../../compose/domains/add-domain";
|
||||
import { AlertBlock } from "@/components/shared/alert-block";
|
||||
import { CodeEditor } from "@/components/shared/code-editor";
|
||||
import { cn } from "@/lib/utils";
|
||||
const commonCronExpressions = [
|
||||
{ label: "Every minute", value: "* * * * *" },
|
||||
{ label: "Every hour", value: "0 * * * *" },
|
||||
@ -48,21 +59,66 @@ const commonCronExpressions = [
|
||||
{ label: "Every weekday at midnight", value: "0 0 * * 1-5" },
|
||||
];
|
||||
|
||||
const formSchema = z.object({
|
||||
name: z.string().min(1, "Name is required"),
|
||||
cronExpression: z.string().min(1, "Cron expression is required"),
|
||||
shellType: z.enum(["bash", "sh"]).default("bash"),
|
||||
command: z.string().min(1, "Command is required"),
|
||||
enabled: z.boolean().default(true),
|
||||
});
|
||||
const formSchema = z
|
||||
.object({
|
||||
name: z.string().min(1, "Name is required"),
|
||||
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(),
|
||||
})
|
||||
.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 {
|
||||
applicationId?: string;
|
||||
id?: string;
|
||||
scheduleId?: string;
|
||||
scheduleType?: "application" | "compose" | "server" | "dokploy-server";
|
||||
}
|
||||
|
||||
export const HandleSchedules = ({ applicationId, scheduleId }: Props) => {
|
||||
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<z.infer<typeof formSchema>>({
|
||||
resolver: zodResolver(formSchema),
|
||||
@ -72,14 +128,36 @@ export const HandleSchedules = ({ applicationId, scheduleId }: Props) => {
|
||||
shellType: "bash",
|
||||
command: "",
|
||||
enabled: true,
|
||||
serviceName: "",
|
||||
scheduleType: scheduleType || "application",
|
||||
script: "",
|
||||
},
|
||||
});
|
||||
|
||||
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({
|
||||
@ -88,6 +166,9 @@ export const HandleSchedules = ({ applicationId, scheduleId }: Props) => {
|
||||
shellType: schedule.shellType,
|
||||
command: schedule.command,
|
||||
enabled: schedule.enabled,
|
||||
serviceName: schedule.serviceName || "",
|
||||
scheduleType: schedule.scheduleType,
|
||||
script: schedule.script || "",
|
||||
});
|
||||
}
|
||||
}, [form, schedule, scheduleId]);
|
||||
@ -97,18 +178,32 @@ export const HandleSchedules = ({ applicationId, scheduleId }: Props) => {
|
||||
: api.schedule.create.useMutation();
|
||||
|
||||
const onSubmit = async (values: z.infer<typeof formSchema>) => {
|
||||
if (!applicationId && !scheduleId) return;
|
||||
if (!id && !scheduleId) return;
|
||||
|
||||
await mutateAsync({
|
||||
...values,
|
||||
scheduleId: scheduleId || "",
|
||||
applicationId: applicationId || "",
|
||||
...(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({ applicationId });
|
||||
utils.schedule.list.invalidate({
|
||||
id,
|
||||
scheduleType,
|
||||
});
|
||||
setIsOpen(false);
|
||||
})
|
||||
.catch((error) => {
|
||||
@ -136,12 +231,130 @@ export const HandleSchedules = ({ applicationId, scheduleId }: Props) => {
|
||||
</Button>
|
||||
)}
|
||||
</DialogTrigger>
|
||||
<DialogContent>
|
||||
<DialogContent
|
||||
className={cn(
|
||||
"max-h-screen overflow-y-auto",
|
||||
scheduleTypeForm === "dokploy-server" || scheduleTypeForm === "server"
|
||||
? "max-h-[95vh] sm:max-w-2xl"
|
||||
: " sm:max-w-lg",
|
||||
)}
|
||||
>
|
||||
<DialogHeader>
|
||||
<DialogTitle>{scheduleId ? "Edit" : "Create"} Schedule</DialogTitle>
|
||||
</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="[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>
|
||||
)}
|
||||
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="name"
|
||||
@ -222,59 +435,86 @@ export const HandleSchedules = ({ applicationId, scheduleId }: Props) => {
|
||||
)}
|
||||
/>
|
||||
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="shellType"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel className="flex items-center gap-2">
|
||||
<Terminal className="w-4 h-4" />
|
||||
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>
|
||||
)}
|
||||
/>
|
||||
{(scheduleTypeForm === "application" ||
|
||||
scheduleTypeForm === "compose") && (
|
||||
<>
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="shellType"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel className="flex items-center gap-2">
|
||||
<Terminal className="w-4 h-4" />
|
||||
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">
|
||||
<Terminal className="w-4 h-4" />
|
||||
Command
|
||||
</FormLabel>
|
||||
<FormControl>
|
||||
<Input placeholder="npm run backup" {...field} />
|
||||
</FormControl>
|
||||
<FormDescription>
|
||||
The command to execute in your container
|
||||
</FormDescription>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="command"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel className="flex items-center gap-2">
|
||||
<Terminal className="w-4 h-4" />
|
||||
Command
|
||||
</FormLabel>
|
||||
<FormControl>
|
||||
<Input
|
||||
placeholder="docker exec my-container 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}
|
||||
@ -292,15 +532,8 @@ export const HandleSchedules = ({ applicationId, scheduleId }: Props) => {
|
||||
)}
|
||||
/>
|
||||
|
||||
<Button type="submit" disabled={isLoading} className="w-full">
|
||||
{isLoading ? (
|
||||
<>
|
||||
<Clock className="mr-2 h-4 w-4 animate-spin" />
|
||||
{scheduleId ? "Updating..." : "Creating..."}
|
||||
</>
|
||||
) : (
|
||||
<>{scheduleId ? "Update" : "Create"} Schedule</>
|
||||
)}
|
||||
<Button type="submit" isLoading={isLoading} className="w-full">
|
||||
{scheduleId ? "Update" : "Create"} Schedule
|
||||
</Button>
|
||||
</form>
|
||||
</Form>
|
||||
|
@ -28,14 +28,25 @@ import {
|
||||
import { DialogAction } from "@/components/shared/dialog-action";
|
||||
|
||||
interface Props {
|
||||
applicationId: string;
|
||||
id: string;
|
||||
scheduleType?: "application" | "compose" | "server" | "dokploy-server";
|
||||
}
|
||||
|
||||
export const ShowSchedules = ({ applicationId }: Props) => {
|
||||
const { data: schedules, isLoading: isLoadingSchedules } =
|
||||
api.schedule.list.useQuery({
|
||||
applicationId,
|
||||
});
|
||||
export const ShowSchedules = ({ id, scheduleType = "application" }: Props) => {
|
||||
const {
|
||||
data: schedules,
|
||||
isLoading: isLoadingSchedules,
|
||||
refetch: refetchSchedules,
|
||||
} = api.schedule.list.useQuery(
|
||||
{
|
||||
id: id || "",
|
||||
scheduleType,
|
||||
},
|
||||
{
|
||||
enabled: !!id,
|
||||
},
|
||||
);
|
||||
|
||||
const utils = api.useUtils();
|
||||
|
||||
const { mutateAsync: deleteSchedule, isLoading: isDeleting } =
|
||||
@ -45,7 +56,7 @@ export const ShowSchedules = ({ applicationId }: Props) => {
|
||||
api.schedule.runManually.useMutation();
|
||||
|
||||
return (
|
||||
<Card className="border px-4 shadow-none bg-transparent">
|
||||
<Card className="border px-6 shadow-none bg-transparent h-full min-h-[50vh]">
|
||||
<CardHeader className="px-0">
|
||||
<div className="flex justify-between items-center">
|
||||
<div className="flex flex-col gap-2">
|
||||
@ -58,13 +69,13 @@ export const ShowSchedules = ({ applicationId }: Props) => {
|
||||
</div>
|
||||
|
||||
{schedules && schedules.length > 0 && (
|
||||
<HandleSchedules applicationId={applicationId} />
|
||||
<HandleSchedules id={id} scheduleType={scheduleType} />
|
||||
)}
|
||||
</div>
|
||||
</CardHeader>
|
||||
<CardContent className="px-0">
|
||||
{isLoadingSchedules ? (
|
||||
<div className="flex gap-4 min-h-[35vh] w-full items-center justify-center text-center mx-auto">
|
||||
<div className="flex gap-4 w-full items-center justify-center text-center mx-auto">
|
||||
<Loader2 className="size-4 text-muted-foreground/70 transition-colors animate-spin self-center" />
|
||||
<span className="text-sm text-muted-foreground/70">
|
||||
Loading scheduled tasks...
|
||||
@ -73,7 +84,10 @@ export const ShowSchedules = ({ applicationId }: Props) => {
|
||||
) : schedules && schedules.length > 0 ? (
|
||||
<div className="grid xl:grid-cols-2 gap-4 grid-cols-1 h-full">
|
||||
{schedules.map((schedule) => {
|
||||
const application = schedule.application;
|
||||
const serverId =
|
||||
schedule.serverId ||
|
||||
schedule.application?.serverId ||
|
||||
schedule.compose?.serverId;
|
||||
const deployments = schedule.deployments;
|
||||
return (
|
||||
<div
|
||||
@ -101,31 +115,38 @@ export const ShowSchedules = ({ applicationId }: Props) => {
|
||||
variant="outline"
|
||||
className="font-mono text-[10px] bg-transparent"
|
||||
>
|
||||
{schedule.cronExpression}
|
||||
</Badge>
|
||||
<span className="text-xs text-muted-foreground/50">
|
||||
•
|
||||
</span>
|
||||
<Badge
|
||||
variant="outline"
|
||||
className="font-mono text-[10px] bg-transparent"
|
||||
>
|
||||
{schedule.shellType}
|
||||
Cron: {schedule.cronExpression}
|
||||
</Badge>
|
||||
{schedule.scheduleType !== "server" &&
|
||||
schedule.scheduleType !== "dokploy-server" && (
|
||||
<>
|
||||
<span className="text-xs text-muted-foreground/50">
|
||||
•
|
||||
</span>
|
||||
<Badge
|
||||
variant="outline"
|
||||
className="font-mono text-[10px] bg-transparent"
|
||||
>
|
||||
{schedule.shellType}
|
||||
</Badge>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<Terminal className="size-3.5 text-muted-foreground/70" />
|
||||
<code className="font-mono text-[10px] text-muted-foreground/70">
|
||||
{schedule.command}
|
||||
</code>
|
||||
</div>
|
||||
{schedule.command && (
|
||||
<div className="flex items-center gap-2">
|
||||
<Terminal className="size-3.5 text-muted-foreground/70" />
|
||||
<code className="font-mono text-[10px] text-muted-foreground/70">
|
||||
{schedule.command}
|
||||
</code>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-1.5">
|
||||
<ShowSchedulesLogs
|
||||
deployments={deployments || []}
|
||||
serverId={application.serverId || undefined}
|
||||
serverId={serverId}
|
||||
>
|
||||
<Button variant="ghost" size="icon">
|
||||
<ClipboardList className="size-4 transition-colors " />
|
||||
@ -146,15 +167,10 @@ export const ShowSchedules = ({ applicationId }: Props) => {
|
||||
})
|
||||
.then(() => {
|
||||
toast.success("Schedule run successfully");
|
||||
utils.schedule.list.invalidate({
|
||||
applicationId,
|
||||
});
|
||||
refetchSchedules();
|
||||
})
|
||||
.catch((error) => {
|
||||
console.log(error);
|
||||
toast.error(
|
||||
`Error running schedule: ${error}`,
|
||||
);
|
||||
.catch(() => {
|
||||
toast.error("Error running schedule:");
|
||||
});
|
||||
}}
|
||||
>
|
||||
@ -167,7 +183,8 @@ export const ShowSchedules = ({ applicationId }: Props) => {
|
||||
|
||||
<HandleSchedules
|
||||
scheduleId={schedule.scheduleId}
|
||||
applicationId={applicationId}
|
||||
id={id}
|
||||
scheduleType={scheduleType}
|
||||
/>
|
||||
|
||||
<DialogAction
|
||||
@ -180,7 +197,8 @@ export const ShowSchedules = ({ applicationId }: Props) => {
|
||||
})
|
||||
.then(() => {
|
||||
utils.schedule.list.invalidate({
|
||||
applicationId,
|
||||
id,
|
||||
scheduleType,
|
||||
});
|
||||
toast.success("Schedule deleted successfully");
|
||||
})
|
||||
@ -204,7 +222,7 @@ export const ShowSchedules = ({ applicationId }: Props) => {
|
||||
})}
|
||||
</div>
|
||||
) : (
|
||||
<div className="flex flex-col gap-2 items-center justify-center py-12 border rounded-lg">
|
||||
<div className="flex flex-col gap-2 items-center justify-center py-12 rounded-lg">
|
||||
<Clock className="size-8 mb-4 text-muted-foreground" />
|
||||
<p className="text-lg font-medium text-muted-foreground">
|
||||
No scheduled tasks
|
||||
@ -212,7 +230,7 @@ export const ShowSchedules = ({ applicationId }: Props) => {
|
||||
<p className="text-sm text-muted-foreground mt-1">
|
||||
Create your first scheduled task to automate your workflows
|
||||
</p>
|
||||
<HandleSchedules applicationId={applicationId} />
|
||||
<HandleSchedules id={id} scheduleType={scheduleType} />
|
||||
</div>
|
||||
)}
|
||||
</CardContent>
|
||||
|
@ -0,0 +1,28 @@
|
||||
import { useState } from "react";
|
||||
import { Dialog, DialogContent, DialogTrigger } from "@/components/ui/dialog";
|
||||
import { DropdownMenuItem } from "@/components/ui/dropdown-menu";
|
||||
import { ShowSchedules } from "@/components/dashboard/application/schedules/show-schedules";
|
||||
|
||||
interface Props {
|
||||
serverId: string;
|
||||
}
|
||||
|
||||
export const ShowSchedulesModal = ({ serverId }: Props) => {
|
||||
const [isOpen, setIsOpen] = useState(false);
|
||||
|
||||
return (
|
||||
<Dialog open={isOpen} onOpenChange={setIsOpen}>
|
||||
<DialogTrigger asChild>
|
||||
<DropdownMenuItem
|
||||
className="w-full cursor-pointer "
|
||||
onSelect={(e) => e.preventDefault()}
|
||||
>
|
||||
Show Schedules
|
||||
</DropdownMenuItem>
|
||||
</DialogTrigger>
|
||||
<DialogContent className="sm:max-w-5xl overflow-y-auto max-h-screen ">
|
||||
<ShowSchedules id={serverId} scheduleType="server" />
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
};
|
@ -43,6 +43,7 @@ import { ShowMonitoringModal } from "./show-monitoring-modal";
|
||||
import { ShowSwarmOverviewModal } from "./show-swarm-overview-modal";
|
||||
import { ShowTraefikFileSystemModal } from "./show-traefik-file-system-modal";
|
||||
import { WelcomeSuscription } from "./welcome-stripe/welcome-suscription";
|
||||
import { ShowSchedulesModal } from "./show-schedules-modal";
|
||||
|
||||
export const ShowServers = () => {
|
||||
const { t } = useTranslation("settings");
|
||||
@ -332,6 +333,10 @@ export const ShowServers = () => {
|
||||
<ShowNodesModal
|
||||
serverId={server.serverId}
|
||||
/>
|
||||
|
||||
<ShowSchedulesModal
|
||||
serverId={server.serverId}
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
</DropdownMenuContent>
|
||||
|
@ -10,6 +10,7 @@ import {
|
||||
ChevronRight,
|
||||
ChevronsUpDown,
|
||||
CircleHelp,
|
||||
Clock,
|
||||
CreditCard,
|
||||
Database,
|
||||
Folder,
|
||||
@ -158,6 +159,14 @@ const MENU: Menu = {
|
||||
// Only enabled in non-cloud environments
|
||||
isEnabled: ({ isCloud }) => !isCloud,
|
||||
},
|
||||
{
|
||||
isSingle: true,
|
||||
title: "Schedules",
|
||||
url: "/dashboard/schedules",
|
||||
icon: Clock,
|
||||
// Only enabled in non-cloud environments
|
||||
isEnabled: ({ isCloud }) => !isCloud,
|
||||
},
|
||||
{
|
||||
isSingle: true,
|
||||
title: "Traefik File System",
|
||||
|
8
apps/dokploy/drizzle/0093_abnormal_machine_man.sql
Normal file
8
apps/dokploy/drizzle/0093_abnormal_machine_man.sql
Normal file
@ -0,0 +1,8 @@
|
||||
CREATE TYPE "public"."scheduleType" AS ENUM('application', 'compose', 'server');--> statement-breakpoint
|
||||
ALTER TABLE "schedule" ALTER COLUMN "applicationId" DROP NOT NULL;--> statement-breakpoint
|
||||
ALTER TABLE "schedule" ADD COLUMN "serviceName" text;--> statement-breakpoint
|
||||
ALTER TABLE "schedule" ADD COLUMN "scheduleType" "scheduleType" DEFAULT 'application' NOT NULL;--> statement-breakpoint
|
||||
ALTER TABLE "schedule" ADD COLUMN "composeId" text;--> statement-breakpoint
|
||||
ALTER TABLE "schedule" ADD COLUMN "serverId" text;--> statement-breakpoint
|
||||
ALTER TABLE "schedule" ADD CONSTRAINT "schedule_composeId_compose_composeId_fk" FOREIGN KEY ("composeId") REFERENCES "public"."compose"("composeId") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
|
||||
ALTER TABLE "schedule" ADD CONSTRAINT "schedule_serverId_server_serverId_fk" FOREIGN KEY ("serverId") REFERENCES "public"."server"("serverId") ON DELETE cascade ON UPDATE no action;
|
1
apps/dokploy/drizzle/0094_easy_butterfly.sql
Normal file
1
apps/dokploy/drizzle/0094_easy_butterfly.sql
Normal file
@ -0,0 +1 @@
|
||||
ALTER TABLE "schedule" ADD COLUMN "script" text;
|
3
apps/dokploy/drizzle/0095_friendly_cobalt_man.sql
Normal file
3
apps/dokploy/drizzle/0095_friendly_cobalt_man.sql
Normal file
@ -0,0 +1,3 @@
|
||||
ALTER TYPE "public"."scheduleType" ADD VALUE 'dokploy-server';--> statement-breakpoint
|
||||
ALTER TABLE "schedule" ADD COLUMN "userId" text;--> statement-breakpoint
|
||||
ALTER TABLE "schedule" ADD CONSTRAINT "schedule_userId_user_temp_id_fk" FOREIGN KEY ("userId") REFERENCES "public"."user_temp"("id") ON DELETE cascade ON UPDATE no action;
|
5579
apps/dokploy/drizzle/meta/0093_snapshot.json
Normal file
5579
apps/dokploy/drizzle/meta/0093_snapshot.json
Normal file
File diff suppressed because it is too large
Load Diff
5585
apps/dokploy/drizzle/meta/0094_snapshot.json
Normal file
5585
apps/dokploy/drizzle/meta/0094_snapshot.json
Normal file
File diff suppressed because it is too large
Load Diff
5605
apps/dokploy/drizzle/meta/0095_snapshot.json
Normal file
5605
apps/dokploy/drizzle/meta/0095_snapshot.json
Normal file
File diff suppressed because it is too large
Load Diff
@ -652,6 +652,27 @@
|
||||
"when": 1746221961240,
|
||||
"tag": "0092_safe_scarlet_witch",
|
||||
"breakpoints": true
|
||||
},
|
||||
{
|
||||
"idx": 93,
|
||||
"version": "7",
|
||||
"when": 1746228754403,
|
||||
"tag": "0093_abnormal_machine_man",
|
||||
"breakpoints": true
|
||||
},
|
||||
{
|
||||
"idx": 94,
|
||||
"version": "7",
|
||||
"when": 1746228771046,
|
||||
"tag": "0094_easy_butterfly",
|
||||
"breakpoints": true
|
||||
},
|
||||
{
|
||||
"idx": 95,
|
||||
"version": "7",
|
||||
"when": 1746232483345,
|
||||
"tag": "0095_friendly_cobalt_man",
|
||||
"breakpoints": true
|
||||
}
|
||||
]
|
||||
}
|
@ -312,7 +312,10 @@ const Service = (
|
||||
</TabsContent>
|
||||
<TabsContent value="schedules">
|
||||
<div className="flex flex-col gap-4 pt-2.5">
|
||||
<ShowSchedules applicationId={applicationId} />
|
||||
<ShowSchedules
|
||||
id={applicationId}
|
||||
scheduleType="application"
|
||||
/>
|
||||
</div>
|
||||
</TabsContent>
|
||||
<TabsContent value="deployments" className="w-full">
|
||||
|
@ -1,6 +1,7 @@
|
||||
import { ShowImport } from "@/components/dashboard/application/advanced/import/show-import";
|
||||
import { ShowVolumes } from "@/components/dashboard/application/advanced/volumes/show-volumes";
|
||||
import { ShowEnvironment } from "@/components/dashboard/application/environment/show-enviroment";
|
||||
import { ShowSchedules } from "@/components/dashboard/application/schedules/show-schedules";
|
||||
import { AddCommandCompose } from "@/components/dashboard/compose/advanced/add-command";
|
||||
import { DeleteService } from "@/components/dashboard/compose/delete-service";
|
||||
import { ShowDeploymentsCompose } from "@/components/dashboard/compose/deployments/show-deployments-compose";
|
||||
@ -217,10 +218,10 @@ const Service = (
|
||||
className={cn(
|
||||
"lg:grid lg:w-fit max-md:overflow-y-scroll justify-start",
|
||||
isCloud && data?.serverId
|
||||
? "lg:grid-cols-7"
|
||||
? "lg:grid-cols-8"
|
||||
: data?.serverId
|
||||
? "lg:grid-cols-6"
|
||||
: "lg:grid-cols-7",
|
||||
? "lg:grid-cols-7"
|
||||
: "lg:grid-cols-8",
|
||||
)}
|
||||
>
|
||||
<TabsTrigger value="general">General</TabsTrigger>
|
||||
@ -228,6 +229,7 @@ const Service = (
|
||||
<TabsTrigger value="domains">Domains</TabsTrigger>
|
||||
<TabsTrigger value="deployments">Deployments</TabsTrigger>
|
||||
<TabsTrigger value="logs">Logs</TabsTrigger>
|
||||
<TabsTrigger value="schedules">Schedules</TabsTrigger>
|
||||
{((data?.serverId && isCloud) || !data?.server) && (
|
||||
<TabsTrigger value="monitoring">Monitoring</TabsTrigger>
|
||||
)}
|
||||
@ -246,6 +248,12 @@ const Service = (
|
||||
</div>
|
||||
</TabsContent>
|
||||
|
||||
<TabsContent value="schedules">
|
||||
<div className="flex flex-col gap-4 pt-2.5">
|
||||
<ShowSchedules id={composeId} scheduleType="compose" />
|
||||
</div>
|
||||
</TabsContent>
|
||||
|
||||
<TabsContent value="monitoring">
|
||||
<div className="pt-2.5">
|
||||
<div className="flex flex-col border rounded-lg ">
|
||||
|
54
apps/dokploy/pages/dashboard/schedules.tsx
Normal file
54
apps/dokploy/pages/dashboard/schedules.tsx
Normal file
@ -0,0 +1,54 @@
|
||||
import { DashboardLayout } from "@/components/layouts/dashboard-layout";
|
||||
import type { ReactElement } from "react";
|
||||
import type { GetServerSidePropsContext } from "next";
|
||||
import { validateRequest } from "@dokploy/server/lib/auth";
|
||||
import { IS_CLOUD } from "@dokploy/server/constants";
|
||||
import { api } from "@/utils/api";
|
||||
import { ShowSchedules } from "@/components/dashboard/application/schedules/show-schedules";
|
||||
import { Card } from "@/components/ui/card";
|
||||
function SchedulesPage() {
|
||||
const { data: user } = api.user.get.useQuery();
|
||||
return (
|
||||
<div className="w-full">
|
||||
<Card className="h-full bg-sidebar p-2.5 rounded-xl max-w-8xl mx-auto min-h-[45vh]">
|
||||
<div className="rounded-xl bg-background shadow-md h-full">
|
||||
<ShowSchedules
|
||||
scheduleType="dokploy-server"
|
||||
id={user?.user.id || ""}
|
||||
/>
|
||||
</div>
|
||||
</Card>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
export default SchedulesPage;
|
||||
|
||||
SchedulesPage.getLayout = (page: ReactElement) => {
|
||||
return <DashboardLayout>{page}</DashboardLayout>;
|
||||
};
|
||||
|
||||
export async function getServerSideProps(
|
||||
ctx: GetServerSidePropsContext<{ serviceId: string }>,
|
||||
) {
|
||||
if (IS_CLOUD) {
|
||||
return {
|
||||
redirect: {
|
||||
permanent: true,
|
||||
destination: "/dashboard/projects",
|
||||
},
|
||||
};
|
||||
}
|
||||
const { user } = await validateRequest(ctx.req);
|
||||
if (!user) {
|
||||
return {
|
||||
redirect: {
|
||||
permanent: true,
|
||||
destination: "/",
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
props: {},
|
||||
};
|
||||
}
|
@ -6,67 +6,65 @@ import {
|
||||
updateScheduleSchema,
|
||||
} from "@dokploy/server/db/schema/schedule";
|
||||
import { desc, eq } from "drizzle-orm";
|
||||
import { db } from "@dokploy/server/db";
|
||||
import { createTRPCRouter, protectedProcedure } from "../trpc";
|
||||
import { runCommand } from "@dokploy/server/index";
|
||||
import { deployments } from "@dokploy/server/db/schema/deployment";
|
||||
import {
|
||||
deleteSchedule,
|
||||
findScheduleById,
|
||||
createSchedule,
|
||||
updateSchedule,
|
||||
} from "@dokploy/server/services/schedule";
|
||||
|
||||
export const scheduleRouter = createTRPCRouter({
|
||||
create: protectedProcedure
|
||||
.input(createScheduleSchema)
|
||||
.mutation(async ({ ctx, input }) => {
|
||||
const { scheduleId, ...rest } = input;
|
||||
const [schedule] = await ctx.db
|
||||
.insert(schedules)
|
||||
.values(rest)
|
||||
.returning();
|
||||
.mutation(async ({ input }) => {
|
||||
const schedule = await createSchedule(input);
|
||||
return schedule;
|
||||
}),
|
||||
|
||||
update: protectedProcedure
|
||||
.input(updateScheduleSchema)
|
||||
.mutation(async ({ ctx, input }) => {
|
||||
const { scheduleId, ...rest } = input;
|
||||
const [schedule] = await ctx.db
|
||||
.update(schedules)
|
||||
.set(rest)
|
||||
.where(eq(schedules.scheduleId, scheduleId))
|
||||
.returning();
|
||||
|
||||
if (!schedule) {
|
||||
throw new TRPCError({
|
||||
code: "NOT_FOUND",
|
||||
message: "Schedule not found",
|
||||
});
|
||||
}
|
||||
|
||||
.mutation(async ({ input }) => {
|
||||
const schedule = await updateSchedule(input);
|
||||
return schedule;
|
||||
}),
|
||||
|
||||
delete: protectedProcedure
|
||||
.input(z.object({ scheduleId: z.string() }))
|
||||
.mutation(async ({ ctx, input }) => {
|
||||
const [schedule] = await ctx.db
|
||||
.delete(schedules)
|
||||
.where(eq(schedules.scheduleId, input.scheduleId))
|
||||
.returning();
|
||||
.mutation(async ({ input }) => {
|
||||
await deleteSchedule(input.scheduleId);
|
||||
|
||||
if (!schedule) {
|
||||
throw new TRPCError({
|
||||
code: "NOT_FOUND",
|
||||
message: "Schedule not found",
|
||||
});
|
||||
}
|
||||
|
||||
return schedule;
|
||||
return true;
|
||||
}),
|
||||
|
||||
list: protectedProcedure
|
||||
.input(z.object({ applicationId: z.string() }))
|
||||
.query(async ({ ctx, input }) => {
|
||||
return ctx.db.query.schedules.findMany({
|
||||
where: eq(schedules.applicationId, input.applicationId),
|
||||
.input(
|
||||
z.object({
|
||||
id: z.string(),
|
||||
scheduleType: z.enum([
|
||||
"application",
|
||||
"compose",
|
||||
"server",
|
||||
"dokploy-server",
|
||||
]),
|
||||
}),
|
||||
)
|
||||
.query(async ({ input }) => {
|
||||
const where = {
|
||||
application: eq(schedules.applicationId, input.id),
|
||||
compose: eq(schedules.composeId, input.id),
|
||||
server: eq(schedules.serverId, input.id),
|
||||
"dokploy-server": eq(schedules.userId, input.id),
|
||||
};
|
||||
return db.query.schedules.findMany({
|
||||
where: where[input.scheduleType],
|
||||
with: {
|
||||
application: true,
|
||||
server: true,
|
||||
compose: true,
|
||||
deployments: {
|
||||
orderBy: [desc(deployments.createdAt)],
|
||||
},
|
||||
@ -76,20 +74,8 @@ export const scheduleRouter = createTRPCRouter({
|
||||
|
||||
one: protectedProcedure
|
||||
.input(z.object({ scheduleId: z.string() }))
|
||||
.query(async ({ ctx, input }) => {
|
||||
const [schedule] = await ctx.db
|
||||
.select()
|
||||
.from(schedules)
|
||||
.where(eq(schedules.scheduleId, input.scheduleId));
|
||||
|
||||
if (!schedule) {
|
||||
throw new TRPCError({
|
||||
code: "NOT_FOUND",
|
||||
message: "Schedule not found",
|
||||
});
|
||||
}
|
||||
|
||||
return schedule;
|
||||
.query(async ({ input }) => {
|
||||
return await findScheduleById(input.scheduleId);
|
||||
}),
|
||||
|
||||
runManually: protectedProcedure
|
||||
|
@ -15,7 +15,7 @@ import { server } from "./server";
|
||||
import { applicationStatus, triggerType } from "./shared";
|
||||
import { sshKeys } from "./ssh-key";
|
||||
import { generateAppName } from "./utils";
|
||||
|
||||
import { schedules } from "./schedule";
|
||||
export const sourceTypeCompose = pgEnum("sourceTypeCompose", [
|
||||
"git",
|
||||
"github",
|
||||
@ -135,6 +135,7 @@ export const composeRelations = relations(compose, ({ one, many }) => ({
|
||||
fields: [compose.serverId],
|
||||
references: [server.serverId],
|
||||
}),
|
||||
schedules: many(schedules),
|
||||
}));
|
||||
|
||||
const createSchema = createInsertSchema(compose, {
|
||||
|
@ -6,9 +6,18 @@ import { z } from "zod";
|
||||
import { applications } from "./application";
|
||||
import { deployments } from "./deployment";
|
||||
import { generateAppName } from "./utils";
|
||||
|
||||
import { compose } from "./compose";
|
||||
import { server } from "./server";
|
||||
import { users_temp } from "./user";
|
||||
export const shellTypes = pgEnum("shellType", ["bash", "sh"]);
|
||||
|
||||
export const scheduleType = pgEnum("scheduleType", [
|
||||
"application",
|
||||
"compose",
|
||||
"server",
|
||||
"dokploy-server",
|
||||
]);
|
||||
|
||||
export const schedules = pgTable("schedule", {
|
||||
scheduleId: text("scheduleId")
|
||||
.notNull()
|
||||
@ -19,13 +28,26 @@ export const schedules = pgTable("schedule", {
|
||||
appName: text("appName")
|
||||
.notNull()
|
||||
.$defaultFn(() => generateAppName("schedule")),
|
||||
serviceName: text("serviceName"),
|
||||
shellType: shellTypes("shellType").notNull().default("bash"),
|
||||
scheduleType: scheduleType("scheduleType").notNull().default("application"),
|
||||
command: text("command").notNull(),
|
||||
applicationId: text("applicationId")
|
||||
.notNull()
|
||||
.references(() => applications.applicationId, {
|
||||
script: text("script"),
|
||||
applicationId: text("applicationId").references(
|
||||
() => applications.applicationId,
|
||||
{
|
||||
onDelete: "cascade",
|
||||
}),
|
||||
},
|
||||
),
|
||||
composeId: text("composeId").references(() => compose.composeId, {
|
||||
onDelete: "cascade",
|
||||
}),
|
||||
serverId: text("serverId").references(() => server.serverId, {
|
||||
onDelete: "cascade",
|
||||
}),
|
||||
userId: text("userId").references(() => users_temp.id, {
|
||||
onDelete: "cascade",
|
||||
}),
|
||||
enabled: boolean("enabled").notNull().default(true),
|
||||
createdAt: text("createdAt")
|
||||
.notNull()
|
||||
@ -39,15 +61,22 @@ export const schedulesRelations = relations(schedules, ({ one, many }) => ({
|
||||
fields: [schedules.applicationId],
|
||||
references: [applications.applicationId],
|
||||
}),
|
||||
compose: one(compose, {
|
||||
fields: [schedules.composeId],
|
||||
references: [compose.composeId],
|
||||
}),
|
||||
server: one(server, {
|
||||
fields: [schedules.serverId],
|
||||
references: [server.serverId],
|
||||
}),
|
||||
user: one(users_temp, {
|
||||
fields: [schedules.userId],
|
||||
references: [users_temp.id],
|
||||
}),
|
||||
deployments: many(deployments),
|
||||
}));
|
||||
|
||||
export const createScheduleSchema = createInsertSchema(schedules, {
|
||||
name: z.string().min(1),
|
||||
cronExpression: z.string().min(1),
|
||||
command: z.string().min(1),
|
||||
applicationId: z.string().min(1),
|
||||
});
|
||||
export const createScheduleSchema = createInsertSchema(schedules);
|
||||
|
||||
export const updateScheduleSchema = createUpdateSchema(schedules).extend({
|
||||
scheduleId: z.string().min(1),
|
||||
|
@ -22,7 +22,7 @@ import { postgres } from "./postgres";
|
||||
import { redis } from "./redis";
|
||||
import { sshKeys } from "./ssh-key";
|
||||
import { generateAppName } from "./utils";
|
||||
|
||||
import { schedules } from "./schedule";
|
||||
export const serverStatus = pgEnum("serverStatus", ["active", "inactive"]);
|
||||
|
||||
export const server = pgTable("server", {
|
||||
@ -114,6 +114,7 @@ export const serverRelations = relations(server, ({ one, many }) => ({
|
||||
fields: [server.organizationId],
|
||||
references: [organization.id],
|
||||
}),
|
||||
schedules: many(schedules),
|
||||
}));
|
||||
|
||||
const createSchema = createInsertSchema(server, {
|
||||
|
@ -14,6 +14,7 @@ import { account, apikey, organization } from "./account";
|
||||
import { projects } from "./project";
|
||||
import { certificateType } from "./shared";
|
||||
import { backups } from "./backups";
|
||||
import { schedules } from "./schedule";
|
||||
/**
|
||||
* This is an example of how to use the multi-project schema feature of Drizzle ORM. Use the same
|
||||
* database instance for multiple projects.
|
||||
@ -127,6 +128,7 @@ export const usersRelations = relations(users_temp, ({ one, many }) => ({
|
||||
projects: many(projects),
|
||||
apiKeys: many(apikey),
|
||||
backups: many(backups),
|
||||
schedules: many(schedules),
|
||||
}));
|
||||
|
||||
const createSchema = createInsertSchema(users_temp, {
|
||||
|
@ -281,17 +281,19 @@ export const createDeploymentSchedule = async (
|
||||
const schedule = await findScheduleById(deployment.scheduleId);
|
||||
|
||||
try {
|
||||
await removeDeploymentsSchedule(
|
||||
deployment.scheduleId,
|
||||
schedule.application.serverId,
|
||||
);
|
||||
const { SCHEDULES_PATH } = paths(!!schedule.application.serverId);
|
||||
const serverId =
|
||||
schedule.application?.serverId ||
|
||||
schedule.compose?.serverId ||
|
||||
schedule.server?.serverId;
|
||||
await removeDeploymentsSchedule(deployment.scheduleId, serverId);
|
||||
const { SCHEDULES_PATH } = paths(!!serverId);
|
||||
const formattedDateTime = format(new Date(), "yyyy-MM-dd:HH:mm:ss");
|
||||
const fileName = `${schedule.appName}-${formattedDateTime}.log`;
|
||||
const logFilePath = path.join(SCHEDULES_PATH, schedule.appName, fileName);
|
||||
|
||||
if (schedule.application.serverId) {
|
||||
const server = await findServerById(schedule.application.serverId);
|
||||
if (serverId) {
|
||||
console.log("serverId", serverId);
|
||||
const server = await findServerById(serverId);
|
||||
|
||||
const command = `
|
||||
mkdir -p ${SCHEDULES_PATH}/${schedule.appName};
|
||||
@ -324,6 +326,7 @@ export const createDeploymentSchedule = async (
|
||||
}
|
||||
return deploymentCreate[0];
|
||||
} catch (error) {
|
||||
console.log(error);
|
||||
await db
|
||||
.insert(deployments)
|
||||
.values({
|
||||
@ -476,7 +479,7 @@ export const removeLastTenPreviewDeploymenById = async (
|
||||
|
||||
export const removeDeploymentsSchedule = async (
|
||||
scheduleId: string,
|
||||
serverId: string | null,
|
||||
serverId?: string | null,
|
||||
) => {
|
||||
const deploymentList = await db.query.deployments.findMany({
|
||||
where: eq(deployments.scheduleId, scheduleId),
|
||||
|
@ -1,13 +1,41 @@
|
||||
import { schedules } from "../db/schema/schedule";
|
||||
import { type Schedule, schedules } from "../db/schema/schedule";
|
||||
import { db } from "../db";
|
||||
import { eq } from "drizzle-orm";
|
||||
import { TRPCError } from "@trpc/server";
|
||||
import type { z } from "zod";
|
||||
import type {
|
||||
createScheduleSchema,
|
||||
updateScheduleSchema,
|
||||
} from "../db/schema/schedule";
|
||||
import { execAsync, execAsyncRemote } from "../utils/process/execAsync";
|
||||
import { paths } from "../constants";
|
||||
import path from "node:path";
|
||||
|
||||
export type ScheduleExtended = Awaited<ReturnType<typeof findScheduleById>>;
|
||||
|
||||
export const createSchedule = async (
|
||||
input: z.infer<typeof createScheduleSchema>,
|
||||
) => {
|
||||
const { scheduleId, ...rest } = input;
|
||||
const [newSchedule] = await db.insert(schedules).values(rest).returning();
|
||||
|
||||
if (
|
||||
newSchedule &&
|
||||
(newSchedule.scheduleType === "dokploy-server" ||
|
||||
newSchedule.scheduleType === "server")
|
||||
) {
|
||||
await handleScript(newSchedule);
|
||||
}
|
||||
return newSchedule;
|
||||
};
|
||||
|
||||
export const findScheduleById = async (scheduleId: string) => {
|
||||
const schedule = await db.query.schedules.findFirst({
|
||||
where: eq(schedules.scheduleId, scheduleId),
|
||||
with: {
|
||||
application: true,
|
||||
compose: true,
|
||||
server: true,
|
||||
},
|
||||
});
|
||||
|
||||
@ -19,3 +47,66 @@ export const findScheduleById = async (scheduleId: string) => {
|
||||
}
|
||||
return schedule;
|
||||
};
|
||||
|
||||
export const deleteSchedule = async (scheduleId: string) => {
|
||||
const schedule = await findScheduleById(scheduleId);
|
||||
|
||||
const { SCHEDULES_PATH } = paths(!!schedule?.serverId);
|
||||
const fullPath = path.join(SCHEDULES_PATH, schedule?.appName || "");
|
||||
const command = `rm -rf ${fullPath}`;
|
||||
if (schedule.serverId) {
|
||||
await execAsyncRemote(schedule.serverId, command);
|
||||
} else {
|
||||
await execAsync(command);
|
||||
}
|
||||
|
||||
const scheduleResult = await db
|
||||
.delete(schedules)
|
||||
.where(eq(schedules.scheduleId, scheduleId));
|
||||
if (!scheduleResult) {
|
||||
throw new TRPCError({
|
||||
code: "NOT_FOUND",
|
||||
message: "Schedule not found",
|
||||
});
|
||||
}
|
||||
|
||||
return true;
|
||||
};
|
||||
|
||||
export const updateSchedule = async (
|
||||
input: z.infer<typeof updateScheduleSchema>,
|
||||
) => {
|
||||
const { scheduleId, ...rest } = input;
|
||||
const [updatedSchedule] = await db
|
||||
.update(schedules)
|
||||
.set(rest)
|
||||
.where(eq(schedules.scheduleId, scheduleId))
|
||||
.returning();
|
||||
|
||||
if (
|
||||
updatedSchedule?.scheduleType === "dokploy-server" ||
|
||||
updatedSchedule?.scheduleType === "server"
|
||||
) {
|
||||
await handleScript(updatedSchedule);
|
||||
}
|
||||
return updatedSchedule;
|
||||
};
|
||||
|
||||
const handleScript = async (schedule: Schedule) => {
|
||||
const { SCHEDULES_PATH } = paths(!!schedule?.serverId);
|
||||
const fullPath = path.join(SCHEDULES_PATH, schedule?.appName || "");
|
||||
|
||||
const script = `
|
||||
mkdir -p ${fullPath}
|
||||
rm -f ${fullPath}/script.sh
|
||||
touch ${fullPath}/script.sh
|
||||
chmod +x ${fullPath}/script.sh
|
||||
echo "${schedule?.script}" > ${fullPath}/script.sh
|
||||
`;
|
||||
|
||||
if (schedule?.scheduleType === "dokploy-server") {
|
||||
await execAsync(script);
|
||||
} else if (schedule?.scheduleType === "server") {
|
||||
await execAsyncRemote(schedule?.serverId || "", script);
|
||||
}
|
||||
};
|
||||
|
@ -13,6 +13,7 @@ import type { RedisNested } from "../databases/redis";
|
||||
import { execAsync, execAsyncRemote } from "../process/execAsync";
|
||||
import { spawnAsync } from "../process/spawnAsync";
|
||||
import { getRemoteDocker } from "../servers/remote-docker";
|
||||
import type { Compose } from "@dokploy/server/services/compose";
|
||||
|
||||
interface RegistryAuth {
|
||||
username: string;
|
||||
@ -541,3 +542,67 @@ export const getRemoteServiceContainer = async (
|
||||
throw error;
|
||||
}
|
||||
};
|
||||
|
||||
export const getServiceContainerIV2 = async (
|
||||
appName: string,
|
||||
serverId?: string | null,
|
||||
) => {
|
||||
try {
|
||||
const filter = {
|
||||
status: ["running"],
|
||||
label: [`com.docker.swarm.service.name=${appName}`],
|
||||
};
|
||||
const remoteDocker = await getRemoteDocker(serverId);
|
||||
const containers = await remoteDocker.listContainers({
|
||||
filters: JSON.stringify(filter),
|
||||
});
|
||||
|
||||
if (containers.length === 0 || !containers[0]) {
|
||||
throw new Error(`No container found with name: ${appName}`);
|
||||
}
|
||||
|
||||
const container = containers[0];
|
||||
return container;
|
||||
} catch (error) {
|
||||
throw error;
|
||||
}
|
||||
};
|
||||
|
||||
export const getComposeContainer = async (
|
||||
compose: Compose,
|
||||
serviceName: string,
|
||||
) => {
|
||||
try {
|
||||
const { appName, composeType, serverId } = compose;
|
||||
// 1. Determine the correct labels based on composeType
|
||||
const labels: string[] = [];
|
||||
if (composeType === "stack") {
|
||||
// Labels for Docker Swarm stack services
|
||||
labels.push(`com.docker.stack.namespace=${appName}`);
|
||||
labels.push(`com.docker.swarm.service.name=${appName}_${serviceName}`);
|
||||
} else {
|
||||
// Labels for Docker Compose projects (default)
|
||||
labels.push(`com.docker.compose.project=${appName}`);
|
||||
labels.push(`com.docker.compose.service=${serviceName}`);
|
||||
}
|
||||
const filter = {
|
||||
status: ["running"],
|
||||
label: labels,
|
||||
};
|
||||
|
||||
const remoteDocker = await getRemoteDocker(serverId);
|
||||
const containers = await remoteDocker.listContainers({
|
||||
filters: JSON.stringify(filter),
|
||||
limit: 1,
|
||||
});
|
||||
|
||||
if (containers.length === 0 || !containers[0]) {
|
||||
throw new Error(`No container found with name: ${appName}`);
|
||||
}
|
||||
|
||||
const container = containers[0];
|
||||
return container;
|
||||
} catch (error) {
|
||||
throw error;
|
||||
}
|
||||
};
|
||||
|
@ -1,15 +1,15 @@
|
||||
import type { Schedule } from "@dokploy/server/db/schema/schedule";
|
||||
import { findScheduleById } from "@dokploy/server/services/schedule";
|
||||
import { scheduleJob as scheduleJobNode } from "node-schedule";
|
||||
import {
|
||||
getRemoteServiceContainer,
|
||||
getServiceContainer,
|
||||
} from "../docker/utils";
|
||||
import { getComposeContainer, getServiceContainerIV2 } from "../docker/utils";
|
||||
import { execAsyncRemote } from "../process/execAsync";
|
||||
import { spawnAsync } from "../process/spawnAsync";
|
||||
import { createDeploymentSchedule } from "@dokploy/server/services/deployment";
|
||||
import { createWriteStream } from "node:fs";
|
||||
import { updateDeploymentStatus } from "@dokploy/server/services/deployment";
|
||||
import { paths } from "@dokploy/server/constants";
|
||||
import path from "node:path";
|
||||
|
||||
export const scheduleJob = (schedule: Schedule) => {
|
||||
const { cronExpression, scheduleId } = schedule;
|
||||
|
||||
@ -19,17 +19,16 @@ export const scheduleJob = (schedule: Schedule) => {
|
||||
};
|
||||
|
||||
export const runCommand = async (scheduleId: string) => {
|
||||
const { application, command, shellType } =
|
||||
await findScheduleById(scheduleId);
|
||||
|
||||
const isServer = !!application.serverId;
|
||||
|
||||
const { Id: containerId } = isServer
|
||||
? await getRemoteServiceContainer(
|
||||
application.serverId || "",
|
||||
application.appName,
|
||||
)
|
||||
: await getServiceContainer(application.appName);
|
||||
const {
|
||||
application,
|
||||
command,
|
||||
shellType,
|
||||
scheduleType,
|
||||
compose,
|
||||
serviceName,
|
||||
appName,
|
||||
serverId,
|
||||
} = await findScheduleById(scheduleId);
|
||||
|
||||
const deployment = await createDeploymentSchedule({
|
||||
scheduleId,
|
||||
@ -37,51 +36,109 @@ export const runCommand = async (scheduleId: string) => {
|
||||
description: "Schedule",
|
||||
});
|
||||
|
||||
if (isServer) {
|
||||
try {
|
||||
await execAsyncRemote(
|
||||
if (scheduleType === "application" || scheduleType === "compose") {
|
||||
let containerId = "";
|
||||
let serverId = "";
|
||||
if (scheduleType === "application" && application) {
|
||||
const container = await getServiceContainerIV2(
|
||||
application.appName,
|
||||
application.serverId,
|
||||
`
|
||||
set -e
|
||||
echo "Running command: docker exec ${containerId} ${shellType} -c \"${command}\"" >> ${deployment.logPath};
|
||||
docker exec ${containerId} ${shellType} -c "${command}" || {
|
||||
echo "❌ Command failed" >> ${deployment.logPath};
|
||||
exit 1;
|
||||
}
|
||||
`,
|
||||
);
|
||||
} catch (error) {
|
||||
await updateDeploymentStatus(deployment.deploymentId, "error");
|
||||
throw error;
|
||||
containerId = container.Id;
|
||||
serverId = application.serverId || "";
|
||||
}
|
||||
if (scheduleType === "compose" && compose) {
|
||||
const container = await getComposeContainer(compose, serviceName || "");
|
||||
containerId = container.Id;
|
||||
serverId = compose.serverId || "";
|
||||
}
|
||||
} else {
|
||||
const writeStream = createWriteStream(deployment.logPath, { flags: "a" });
|
||||
|
||||
if (serverId) {
|
||||
try {
|
||||
await execAsyncRemote(
|
||||
serverId,
|
||||
`
|
||||
set -e
|
||||
echo "Running command: docker exec ${containerId} ${shellType} -c \"${command}\"" >> ${deployment.logPath};
|
||||
docker exec ${containerId} ${shellType} -c "${command}" >> ${deployment.logPath} 2>> ${deployment.logPath} || {
|
||||
echo "❌ Command failed" >> ${deployment.logPath};
|
||||
exit 1;
|
||||
}
|
||||
echo "✅ Command executed successfully" >> ${deployment.logPath};
|
||||
`,
|
||||
);
|
||||
} catch (error) {
|
||||
await updateDeploymentStatus(deployment.deploymentId, "error");
|
||||
throw error;
|
||||
}
|
||||
} else {
|
||||
const writeStream = createWriteStream(deployment.logPath, { flags: "a" });
|
||||
|
||||
try {
|
||||
writeStream.write(
|
||||
`docker exec ${containerId} ${shellType} -c "${command}"\n`,
|
||||
);
|
||||
await spawnAsync(
|
||||
"docker",
|
||||
["exec", containerId, shellType, "-c", command],
|
||||
(data) => {
|
||||
if (writeStream.writable) {
|
||||
writeStream.write(data);
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
writeStream.write("✅ Command executed successfully\n");
|
||||
} catch (error) {
|
||||
writeStream.write("❌ Command failed\n");
|
||||
writeStream.write(
|
||||
error instanceof Error ? error.message : "Unknown error",
|
||||
);
|
||||
writeStream.end();
|
||||
await updateDeploymentStatus(deployment.deploymentId, "error");
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
} else if (scheduleType === "dokploy-server") {
|
||||
try {
|
||||
writeStream.write(
|
||||
`docker exec ${containerId} ${shellType} -c "${command}"\n`,
|
||||
);
|
||||
const writeStream = createWriteStream(deployment.logPath, { flags: "a" });
|
||||
const { SCHEDULES_PATH } = paths();
|
||||
const fullPath = path.join(SCHEDULES_PATH, appName || "");
|
||||
|
||||
await spawnAsync(
|
||||
"docker",
|
||||
["exec", containerId, shellType, "-c", command],
|
||||
"bash",
|
||||
["-c", "./script.sh"],
|
||||
(data) => {
|
||||
if (writeStream.writable) {
|
||||
writeStream.write(data);
|
||||
}
|
||||
},
|
||||
{
|
||||
cwd: fullPath,
|
||||
},
|
||||
);
|
||||
|
||||
writeStream.write("✅ Command executed successfully\n");
|
||||
} catch (error) {
|
||||
writeStream.write("❌ Command failed\n");
|
||||
writeStream.write(
|
||||
error instanceof Error ? error.message : "Unknown error",
|
||||
);
|
||||
writeStream.end();
|
||||
await updateDeploymentStatus(deployment.deploymentId, "error");
|
||||
throw error;
|
||||
}
|
||||
} else if (scheduleType === "server") {
|
||||
try {
|
||||
const { SCHEDULES_PATH } = paths(true);
|
||||
const fullPath = path.join(SCHEDULES_PATH, appName || "");
|
||||
const command = `
|
||||
set -e
|
||||
echo "Running script" >> ${deployment.logPath};
|
||||
bash -c ${fullPath}/script.sh >> ${deployment.logPath} 2>> ${deployment.logPath} || {
|
||||
echo "❌ Command failed" >> ${deployment.logPath};
|
||||
exit 1;
|
||||
}
|
||||
echo "✅ Command executed successfully" >> ${deployment.logPath};
|
||||
`;
|
||||
await execAsyncRemote(serverId, command);
|
||||
} catch (error) {
|
||||
await updateDeploymentStatus(deployment.deploymentId, "error");
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
await updateDeploymentStatus(deployment.deploymentId, "done");
|
||||
};
|
||||
|
Loading…
Reference in New Issue
Block a user