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:
Mauricio Siu
2025-05-02 20:17:21 -06:00
parent 49e55961db
commit 98d0f1d5bf
24 changed files with 17632 additions and 237 deletions

View File

@@ -13,7 +13,15 @@ import { api } from "@/utils/api";
import { zodResolver } from "@hookform/resolvers/zod"; import { zodResolver } from "@hookform/resolvers/zod";
import { useForm } from "react-hook-form"; import { useForm } from "react-hook-form";
import { z } from "zod"; 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 { import {
Select, Select,
SelectContent, SelectContent,
@@ -37,7 +45,10 @@ import {
DialogTrigger, DialogTrigger,
} from "@/components/ui/dialog"; } from "@/components/ui/dialog";
import { toast } from "sonner"; 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 = [ const commonCronExpressions = [
{ label: "Every minute", value: "* * * * *" }, { label: "Every minute", value: "* * * * *" },
{ label: "Every hour", value: "0 * * * *" }, { label: "Every hour", value: "0 * * * *" },
@@ -48,21 +59,66 @@ const commonCronExpressions = [
{ label: "Every weekday at midnight", value: "0 0 * * 1-5" }, { label: "Every weekday at midnight", value: "0 0 * * 1-5" },
]; ];
const formSchema = z.object({ const formSchema = z
name: z.string().min(1, "Name is required"), .object({
cronExpression: z.string().min(1, "Cron expression is required"), name: z.string().min(1, "Name is required"),
shellType: z.enum(["bash", "sh"]).default("bash"), cronExpression: z.string().min(1, "Cron expression is required"),
command: z.string().min(1, "Command is required"), shellType: z.enum(["bash", "sh"]).default("bash"),
enabled: z.boolean().default(true), 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 { interface Props {
applicationId?: string; id?: string;
scheduleId?: 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 [isOpen, setIsOpen] = useState(false);
const [cacheType, setCacheType] = useState<CacheType>("cache");
const utils = api.useUtils(); const utils = api.useUtils();
const form = useForm<z.infer<typeof formSchema>>({ const form = useForm<z.infer<typeof formSchema>>({
resolver: zodResolver(formSchema), resolver: zodResolver(formSchema),
@@ -72,14 +128,36 @@ export const HandleSchedules = ({ applicationId, scheduleId }: Props) => {
shellType: "bash", shellType: "bash",
command: "", command: "",
enabled: true, enabled: true,
serviceName: "",
scheduleType: scheduleType || "application",
script: "",
}, },
}); });
const scheduleTypeForm = form.watch("scheduleType");
const { data: schedule } = api.schedule.one.useQuery( const { data: schedule } = api.schedule.one.useQuery(
{ scheduleId: scheduleId || "" }, { scheduleId: scheduleId || "" },
{ enabled: !!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(() => { useEffect(() => {
if (scheduleId && schedule) { if (scheduleId && schedule) {
form.reset({ form.reset({
@@ -88,6 +166,9 @@ export const HandleSchedules = ({ applicationId, scheduleId }: Props) => {
shellType: schedule.shellType, shellType: schedule.shellType,
command: schedule.command, command: schedule.command,
enabled: schedule.enabled, enabled: schedule.enabled,
serviceName: schedule.serviceName || "",
scheduleType: schedule.scheduleType,
script: schedule.script || "",
}); });
} }
}, [form, schedule, scheduleId]); }, [form, schedule, scheduleId]);
@@ -97,18 +178,32 @@ export const HandleSchedules = ({ applicationId, scheduleId }: Props) => {
: api.schedule.create.useMutation(); : api.schedule.create.useMutation();
const onSubmit = async (values: z.infer<typeof formSchema>) => { const onSubmit = async (values: z.infer<typeof formSchema>) => {
if (!applicationId && !scheduleId) return; if (!id && !scheduleId) return;
await mutateAsync({ await mutateAsync({
...values, ...values,
scheduleId: scheduleId || "", scheduleId: scheduleId || "",
applicationId: applicationId || "", ...(scheduleType === "application" && {
applicationId: id || "",
}),
...(scheduleType === "compose" && {
composeId: id || "",
}),
...(scheduleType === "server" && {
serverId: id || "",
}),
...(scheduleType === "dokploy-server" && {
userId: id || "",
}),
}) })
.then(() => { .then(() => {
toast.success( toast.success(
`Schedule ${scheduleId ? "updated" : "created"} successfully`, `Schedule ${scheduleId ? "updated" : "created"} successfully`,
); );
utils.schedule.list.invalidate({ applicationId }); utils.schedule.list.invalidate({
id,
scheduleType,
});
setIsOpen(false); setIsOpen(false);
}) })
.catch((error) => { .catch((error) => {
@@ -136,12 +231,130 @@ export const HandleSchedules = ({ applicationId, scheduleId }: Props) => {
</Button> </Button>
)} )}
</DialogTrigger> </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> <DialogHeader>
<DialogTitle>{scheduleId ? "Edit" : "Create"} Schedule</DialogTitle> <DialogTitle>{scheduleId ? "Edit" : "Create"} Schedule</DialogTitle>
</DialogHeader> </DialogHeader>
<Form {...form}> <Form {...form}>
<form onSubmit={form.handleSubmit(onSubmit)} className="space-y-6"> <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 <FormField
control={form.control} control={form.control}
name="name" name="name"
@@ -222,59 +435,86 @@ export const HandleSchedules = ({ applicationId, scheduleId }: Props) => {
)} )}
/> />
<FormField {(scheduleTypeForm === "application" ||
control={form.control} scheduleTypeForm === "compose") && (
name="shellType" <>
render={({ field }) => ( <FormField
<FormItem> control={form.control}
<FormLabel className="flex items-center gap-2"> name="shellType"
<Terminal className="w-4 h-4" /> render={({ field }) => (
Shell Type <FormItem>
</FormLabel> <FormLabel className="flex items-center gap-2">
<Select <Terminal className="w-4 h-4" />
onValueChange={field.onChange} Shell Type
defaultValue={field.value} </FormLabel>
> <Select
<FormControl> onValueChange={field.onChange}
<SelectTrigger> defaultValue={field.value}
<SelectValue placeholder="Select shell type" /> >
</SelectTrigger> <FormControl>
</FormControl> <SelectTrigger>
<SelectContent> <SelectValue placeholder="Select shell type" />
<SelectItem value="bash">Bash</SelectItem> </SelectTrigger>
<SelectItem value="sh">Sh</SelectItem> </FormControl>
</SelectContent> <SelectContent>
</Select> <SelectItem value="bash">Bash</SelectItem>
<FormDescription> <SelectItem value="sh">Sh</SelectItem>
Choose the shell to execute your command </SelectContent>
</FormDescription> </Select>
<FormMessage /> <FormDescription>
</FormItem> 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 {(scheduleTypeForm === "dokploy-server" ||
control={form.control} scheduleTypeForm === "server") && (
name="command" <FormField
render={({ field }) => ( control={form.control}
<FormItem> name="script"
<FormLabel className="flex items-center gap-2"> render={({ field }) => (
<Terminal className="w-4 h-4" /> <FormItem>
Command <FormLabel>Script</FormLabel>
</FormLabel> <FormControl>
<FormControl> <FormControl>
<Input <CodeEditor
placeholder="docker exec my-container npm run backup" language="shell"
{...field} placeholder={`# This is a comment
/> echo "Hello, world!"
</FormControl> `}
<FormDescription> className="h-96 font-mono"
The command to execute in your container {...field}
</FormDescription> />
<FormMessage /> </FormControl>
</FormItem> </FormControl>
)} <FormMessage />
/> </FormItem>
)}
/>
)}
<FormField <FormField
control={form.control} control={form.control}
@@ -292,15 +532,8 @@ export const HandleSchedules = ({ applicationId, scheduleId }: Props) => {
)} )}
/> />
<Button type="submit" disabled={isLoading} className="w-full"> <Button type="submit" isLoading={isLoading} className="w-full">
{isLoading ? ( {scheduleId ? "Update" : "Create"} Schedule
<>
<Clock className="mr-2 h-4 w-4 animate-spin" />
{scheduleId ? "Updating..." : "Creating..."}
</>
) : (
<>{scheduleId ? "Update" : "Create"} Schedule</>
)}
</Button> </Button>
</form> </form>
</Form> </Form>

View File

@@ -28,14 +28,25 @@ import {
import { DialogAction } from "@/components/shared/dialog-action"; import { DialogAction } from "@/components/shared/dialog-action";
interface Props { interface Props {
applicationId: string; id: string;
scheduleType?: "application" | "compose" | "server" | "dokploy-server";
} }
export const ShowSchedules = ({ applicationId }: Props) => { export const ShowSchedules = ({ id, scheduleType = "application" }: Props) => {
const { data: schedules, isLoading: isLoadingSchedules } = const {
api.schedule.list.useQuery({ data: schedules,
applicationId, isLoading: isLoadingSchedules,
}); refetch: refetchSchedules,
} = api.schedule.list.useQuery(
{
id: id || "",
scheduleType,
},
{
enabled: !!id,
},
);
const utils = api.useUtils(); const utils = api.useUtils();
const { mutateAsync: deleteSchedule, isLoading: isDeleting } = const { mutateAsync: deleteSchedule, isLoading: isDeleting } =
@@ -45,7 +56,7 @@ export const ShowSchedules = ({ applicationId }: Props) => {
api.schedule.runManually.useMutation(); api.schedule.runManually.useMutation();
return ( 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"> <CardHeader className="px-0">
<div className="flex justify-between items-center"> <div className="flex justify-between items-center">
<div className="flex flex-col gap-2"> <div className="flex flex-col gap-2">
@@ -58,13 +69,13 @@ export const ShowSchedules = ({ applicationId }: Props) => {
</div> </div>
{schedules && schedules.length > 0 && ( {schedules && schedules.length > 0 && (
<HandleSchedules applicationId={applicationId} /> <HandleSchedules id={id} scheduleType={scheduleType} />
)} )}
</div> </div>
</CardHeader> </CardHeader>
<CardContent className="px-0"> <CardContent className="px-0">
{isLoadingSchedules ? ( {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" /> <Loader2 className="size-4 text-muted-foreground/70 transition-colors animate-spin self-center" />
<span className="text-sm text-muted-foreground/70"> <span className="text-sm text-muted-foreground/70">
Loading scheduled tasks... Loading scheduled tasks...
@@ -73,7 +84,10 @@ export const ShowSchedules = ({ applicationId }: Props) => {
) : schedules && schedules.length > 0 ? ( ) : schedules && schedules.length > 0 ? (
<div className="grid xl:grid-cols-2 gap-4 grid-cols-1 h-full"> <div className="grid xl:grid-cols-2 gap-4 grid-cols-1 h-full">
{schedules.map((schedule) => { {schedules.map((schedule) => {
const application = schedule.application; const serverId =
schedule.serverId ||
schedule.application?.serverId ||
schedule.compose?.serverId;
const deployments = schedule.deployments; const deployments = schedule.deployments;
return ( return (
<div <div
@@ -101,31 +115,38 @@ export const ShowSchedules = ({ applicationId }: Props) => {
variant="outline" variant="outline"
className="font-mono text-[10px] bg-transparent" className="font-mono text-[10px] bg-transparent"
> >
{schedule.cronExpression} Cron: {schedule.cronExpression}
</Badge>
<span className="text-xs text-muted-foreground/50">
</span>
<Badge
variant="outline"
className="font-mono text-[10px] bg-transparent"
>
{schedule.shellType}
</Badge> </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>
<div className="flex items-center gap-2"> {schedule.command && (
<Terminal className="size-3.5 text-muted-foreground/70" /> <div className="flex items-center gap-2">
<code className="font-mono text-[10px] text-muted-foreground/70"> <Terminal className="size-3.5 text-muted-foreground/70" />
{schedule.command} <code className="font-mono text-[10px] text-muted-foreground/70">
</code> {schedule.command}
</div> </code>
</div>
)}
</div> </div>
</div> </div>
<div className="flex items-center gap-1.5"> <div className="flex items-center gap-1.5">
<ShowSchedulesLogs <ShowSchedulesLogs
deployments={deployments || []} deployments={deployments || []}
serverId={application.serverId || undefined} serverId={serverId}
> >
<Button variant="ghost" size="icon"> <Button variant="ghost" size="icon">
<ClipboardList className="size-4 transition-colors " /> <ClipboardList className="size-4 transition-colors " />
@@ -146,15 +167,10 @@ export const ShowSchedules = ({ applicationId }: Props) => {
}) })
.then(() => { .then(() => {
toast.success("Schedule run successfully"); toast.success("Schedule run successfully");
utils.schedule.list.invalidate({ refetchSchedules();
applicationId,
});
}) })
.catch((error) => { .catch(() => {
console.log(error); toast.error("Error running schedule:");
toast.error(
`Error running schedule: ${error}`,
);
}); });
}} }}
> >
@@ -167,7 +183,8 @@ export const ShowSchedules = ({ applicationId }: Props) => {
<HandleSchedules <HandleSchedules
scheduleId={schedule.scheduleId} scheduleId={schedule.scheduleId}
applicationId={applicationId} id={id}
scheduleType={scheduleType}
/> />
<DialogAction <DialogAction
@@ -180,7 +197,8 @@ export const ShowSchedules = ({ applicationId }: Props) => {
}) })
.then(() => { .then(() => {
utils.schedule.list.invalidate({ utils.schedule.list.invalidate({
applicationId, id,
scheduleType,
}); });
toast.success("Schedule deleted successfully"); toast.success("Schedule deleted successfully");
}) })
@@ -204,7 +222,7 @@ export const ShowSchedules = ({ applicationId }: Props) => {
})} })}
</div> </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" /> <Clock className="size-8 mb-4 text-muted-foreground" />
<p className="text-lg font-medium text-muted-foreground"> <p className="text-lg font-medium text-muted-foreground">
No scheduled tasks No scheduled tasks
@@ -212,7 +230,7 @@ export const ShowSchedules = ({ applicationId }: Props) => {
<p className="text-sm text-muted-foreground mt-1"> <p className="text-sm text-muted-foreground mt-1">
Create your first scheduled task to automate your workflows Create your first scheduled task to automate your workflows
</p> </p>
<HandleSchedules applicationId={applicationId} /> <HandleSchedules id={id} scheduleType={scheduleType} />
</div> </div>
)} )}
</CardContent> </CardContent>

View File

@@ -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>
);
};

View File

@@ -43,6 +43,7 @@ import { ShowMonitoringModal } from "./show-monitoring-modal";
import { ShowSwarmOverviewModal } from "./show-swarm-overview-modal"; import { ShowSwarmOverviewModal } from "./show-swarm-overview-modal";
import { ShowTraefikFileSystemModal } from "./show-traefik-file-system-modal"; import { ShowTraefikFileSystemModal } from "./show-traefik-file-system-modal";
import { WelcomeSuscription } from "./welcome-stripe/welcome-suscription"; import { WelcomeSuscription } from "./welcome-stripe/welcome-suscription";
import { ShowSchedulesModal } from "./show-schedules-modal";
export const ShowServers = () => { export const ShowServers = () => {
const { t } = useTranslation("settings"); const { t } = useTranslation("settings");
@@ -332,6 +333,10 @@ export const ShowServers = () => {
<ShowNodesModal <ShowNodesModal
serverId={server.serverId} serverId={server.serverId}
/> />
<ShowSchedulesModal
serverId={server.serverId}
/>
</> </>
)} )}
</DropdownMenuContent> </DropdownMenuContent>

View File

@@ -10,6 +10,7 @@ import {
ChevronRight, ChevronRight,
ChevronsUpDown, ChevronsUpDown,
CircleHelp, CircleHelp,
Clock,
CreditCard, CreditCard,
Database, Database,
Folder, Folder,
@@ -158,6 +159,14 @@ const MENU: Menu = {
// Only enabled in non-cloud environments // Only enabled in non-cloud environments
isEnabled: ({ isCloud }) => !isCloud, isEnabled: ({ isCloud }) => !isCloud,
}, },
{
isSingle: true,
title: "Schedules",
url: "/dashboard/schedules",
icon: Clock,
// Only enabled in non-cloud environments
isEnabled: ({ isCloud }) => !isCloud,
},
{ {
isSingle: true, isSingle: true,
title: "Traefik File System", title: "Traefik File System",

View 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;

View File

@@ -0,0 +1 @@
ALTER TABLE "schedule" ADD COLUMN "script" text;

View 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;

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

View File

@@ -652,6 +652,27 @@
"when": 1746221961240, "when": 1746221961240,
"tag": "0092_safe_scarlet_witch", "tag": "0092_safe_scarlet_witch",
"breakpoints": true "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
} }
] ]
} }

View File

@@ -312,7 +312,10 @@ const Service = (
</TabsContent> </TabsContent>
<TabsContent value="schedules"> <TabsContent value="schedules">
<div className="flex flex-col gap-4 pt-2.5"> <div className="flex flex-col gap-4 pt-2.5">
<ShowSchedules applicationId={applicationId} /> <ShowSchedules
id={applicationId}
scheduleType="application"
/>
</div> </div>
</TabsContent> </TabsContent>
<TabsContent value="deployments" className="w-full"> <TabsContent value="deployments" className="w-full">

View File

@@ -1,6 +1,7 @@
import { ShowImport } from "@/components/dashboard/application/advanced/import/show-import"; import { ShowImport } from "@/components/dashboard/application/advanced/import/show-import";
import { ShowVolumes } from "@/components/dashboard/application/advanced/volumes/show-volumes"; import { ShowVolumes } from "@/components/dashboard/application/advanced/volumes/show-volumes";
import { ShowEnvironment } from "@/components/dashboard/application/environment/show-enviroment"; 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 { AddCommandCompose } from "@/components/dashboard/compose/advanced/add-command";
import { DeleteService } from "@/components/dashboard/compose/delete-service"; import { DeleteService } from "@/components/dashboard/compose/delete-service";
import { ShowDeploymentsCompose } from "@/components/dashboard/compose/deployments/show-deployments-compose"; import { ShowDeploymentsCompose } from "@/components/dashboard/compose/deployments/show-deployments-compose";
@@ -217,10 +218,10 @@ const Service = (
className={cn( className={cn(
"lg:grid lg:w-fit max-md:overflow-y-scroll justify-start", "lg:grid lg:w-fit max-md:overflow-y-scroll justify-start",
isCloud && data?.serverId isCloud && data?.serverId
? "lg:grid-cols-7" ? "lg:grid-cols-8"
: data?.serverId : data?.serverId
? "lg:grid-cols-6" ? "lg:grid-cols-7"
: "lg:grid-cols-7", : "lg:grid-cols-8",
)} )}
> >
<TabsTrigger value="general">General</TabsTrigger> <TabsTrigger value="general">General</TabsTrigger>
@@ -228,6 +229,7 @@ const Service = (
<TabsTrigger value="domains">Domains</TabsTrigger> <TabsTrigger value="domains">Domains</TabsTrigger>
<TabsTrigger value="deployments">Deployments</TabsTrigger> <TabsTrigger value="deployments">Deployments</TabsTrigger>
<TabsTrigger value="logs">Logs</TabsTrigger> <TabsTrigger value="logs">Logs</TabsTrigger>
<TabsTrigger value="schedules">Schedules</TabsTrigger>
{((data?.serverId && isCloud) || !data?.server) && ( {((data?.serverId && isCloud) || !data?.server) && (
<TabsTrigger value="monitoring">Monitoring</TabsTrigger> <TabsTrigger value="monitoring">Monitoring</TabsTrigger>
)} )}
@@ -246,6 +248,12 @@ const Service = (
</div> </div>
</TabsContent> </TabsContent>
<TabsContent value="schedules">
<div className="flex flex-col gap-4 pt-2.5">
<ShowSchedules id={composeId} scheduleType="compose" />
</div>
</TabsContent>
<TabsContent value="monitoring"> <TabsContent value="monitoring">
<div className="pt-2.5"> <div className="pt-2.5">
<div className="flex flex-col border rounded-lg "> <div className="flex flex-col border rounded-lg ">

View 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: {},
};
}

View File

@@ -6,67 +6,65 @@ import {
updateScheduleSchema, updateScheduleSchema,
} from "@dokploy/server/db/schema/schedule"; } from "@dokploy/server/db/schema/schedule";
import { desc, eq } from "drizzle-orm"; import { desc, eq } from "drizzle-orm";
import { db } from "@dokploy/server/db";
import { createTRPCRouter, protectedProcedure } from "../trpc"; import { createTRPCRouter, protectedProcedure } from "../trpc";
import { runCommand } from "@dokploy/server/index"; import { runCommand } from "@dokploy/server/index";
import { deployments } from "@dokploy/server/db/schema/deployment"; import { deployments } from "@dokploy/server/db/schema/deployment";
import {
deleteSchedule,
findScheduleById,
createSchedule,
updateSchedule,
} from "@dokploy/server/services/schedule";
export const scheduleRouter = createTRPCRouter({ export const scheduleRouter = createTRPCRouter({
create: protectedProcedure create: protectedProcedure
.input(createScheduleSchema) .input(createScheduleSchema)
.mutation(async ({ ctx, input }) => { .mutation(async ({ input }) => {
const { scheduleId, ...rest } = input; const schedule = await createSchedule(input);
const [schedule] = await ctx.db
.insert(schedules)
.values(rest)
.returning();
return schedule; return schedule;
}), }),
update: protectedProcedure update: protectedProcedure
.input(updateScheduleSchema) .input(updateScheduleSchema)
.mutation(async ({ ctx, input }) => { .mutation(async ({ input }) => {
const { scheduleId, ...rest } = input; const schedule = await updateSchedule(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",
});
}
return schedule; return schedule;
}), }),
delete: protectedProcedure delete: protectedProcedure
.input(z.object({ scheduleId: z.string() })) .input(z.object({ scheduleId: z.string() }))
.mutation(async ({ ctx, input }) => { .mutation(async ({ input }) => {
const [schedule] = await ctx.db await deleteSchedule(input.scheduleId);
.delete(schedules)
.where(eq(schedules.scheduleId, input.scheduleId))
.returning();
if (!schedule) { return true;
throw new TRPCError({
code: "NOT_FOUND",
message: "Schedule not found",
});
}
return schedule;
}), }),
list: protectedProcedure list: protectedProcedure
.input(z.object({ applicationId: z.string() })) .input(
.query(async ({ ctx, input }) => { z.object({
return ctx.db.query.schedules.findMany({ id: z.string(),
where: eq(schedules.applicationId, input.applicationId), 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: { with: {
application: true, application: true,
server: true,
compose: true,
deployments: { deployments: {
orderBy: [desc(deployments.createdAt)], orderBy: [desc(deployments.createdAt)],
}, },
@@ -76,20 +74,8 @@ export const scheduleRouter = createTRPCRouter({
one: protectedProcedure one: protectedProcedure
.input(z.object({ scheduleId: z.string() })) .input(z.object({ scheduleId: z.string() }))
.query(async ({ ctx, input }) => { .query(async ({ input }) => {
const [schedule] = await ctx.db return await findScheduleById(input.scheduleId);
.select()
.from(schedules)
.where(eq(schedules.scheduleId, input.scheduleId));
if (!schedule) {
throw new TRPCError({
code: "NOT_FOUND",
message: "Schedule not found",
});
}
return schedule;
}), }),
runManually: protectedProcedure runManually: protectedProcedure

View File

@@ -15,7 +15,7 @@ import { server } from "./server";
import { applicationStatus, triggerType } from "./shared"; import { applicationStatus, triggerType } from "./shared";
import { sshKeys } from "./ssh-key"; import { sshKeys } from "./ssh-key";
import { generateAppName } from "./utils"; import { generateAppName } from "./utils";
import { schedules } from "./schedule";
export const sourceTypeCompose = pgEnum("sourceTypeCompose", [ export const sourceTypeCompose = pgEnum("sourceTypeCompose", [
"git", "git",
"github", "github",
@@ -135,6 +135,7 @@ export const composeRelations = relations(compose, ({ one, many }) => ({
fields: [compose.serverId], fields: [compose.serverId],
references: [server.serverId], references: [server.serverId],
}), }),
schedules: many(schedules),
})); }));
const createSchema = createInsertSchema(compose, { const createSchema = createInsertSchema(compose, {

View File

@@ -6,9 +6,18 @@ import { z } from "zod";
import { applications } from "./application"; import { applications } from "./application";
import { deployments } from "./deployment"; import { deployments } from "./deployment";
import { generateAppName } from "./utils"; 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 shellTypes = pgEnum("shellType", ["bash", "sh"]);
export const scheduleType = pgEnum("scheduleType", [
"application",
"compose",
"server",
"dokploy-server",
]);
export const schedules = pgTable("schedule", { export const schedules = pgTable("schedule", {
scheduleId: text("scheduleId") scheduleId: text("scheduleId")
.notNull() .notNull()
@@ -19,13 +28,26 @@ export const schedules = pgTable("schedule", {
appName: text("appName") appName: text("appName")
.notNull() .notNull()
.$defaultFn(() => generateAppName("schedule")), .$defaultFn(() => generateAppName("schedule")),
serviceName: text("serviceName"),
shellType: shellTypes("shellType").notNull().default("bash"), shellType: shellTypes("shellType").notNull().default("bash"),
scheduleType: scheduleType("scheduleType").notNull().default("application"),
command: text("command").notNull(), command: text("command").notNull(),
applicationId: text("applicationId") script: text("script"),
.notNull() applicationId: text("applicationId").references(
.references(() => applications.applicationId, { () => applications.applicationId,
{
onDelete: "cascade", 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), enabled: boolean("enabled").notNull().default(true),
createdAt: text("createdAt") createdAt: text("createdAt")
.notNull() .notNull()
@@ -39,15 +61,22 @@ export const schedulesRelations = relations(schedules, ({ one, many }) => ({
fields: [schedules.applicationId], fields: [schedules.applicationId],
references: [applications.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), deployments: many(deployments),
})); }));
export const createScheduleSchema = createInsertSchema(schedules, { 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 updateScheduleSchema = createUpdateSchema(schedules).extend({ export const updateScheduleSchema = createUpdateSchema(schedules).extend({
scheduleId: z.string().min(1), scheduleId: z.string().min(1),

View File

@@ -22,7 +22,7 @@ import { postgres } from "./postgres";
import { redis } from "./redis"; import { redis } from "./redis";
import { sshKeys } from "./ssh-key"; import { sshKeys } from "./ssh-key";
import { generateAppName } from "./utils"; import { generateAppName } from "./utils";
import { schedules } from "./schedule";
export const serverStatus = pgEnum("serverStatus", ["active", "inactive"]); export const serverStatus = pgEnum("serverStatus", ["active", "inactive"]);
export const server = pgTable("server", { export const server = pgTable("server", {
@@ -114,6 +114,7 @@ export const serverRelations = relations(server, ({ one, many }) => ({
fields: [server.organizationId], fields: [server.organizationId],
references: [organization.id], references: [organization.id],
}), }),
schedules: many(schedules),
})); }));
const createSchema = createInsertSchema(server, { const createSchema = createInsertSchema(server, {

View File

@@ -14,6 +14,7 @@ import { account, apikey, organization } from "./account";
import { projects } from "./project"; import { projects } from "./project";
import { certificateType } from "./shared"; import { certificateType } from "./shared";
import { backups } from "./backups"; 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 * This is an example of how to use the multi-project schema feature of Drizzle ORM. Use the same
* database instance for multiple projects. * database instance for multiple projects.
@@ -127,6 +128,7 @@ export const usersRelations = relations(users_temp, ({ one, many }) => ({
projects: many(projects), projects: many(projects),
apiKeys: many(apikey), apiKeys: many(apikey),
backups: many(backups), backups: many(backups),
schedules: many(schedules),
})); }));
const createSchema = createInsertSchema(users_temp, { const createSchema = createInsertSchema(users_temp, {

View File

@@ -281,17 +281,19 @@ export const createDeploymentSchedule = async (
const schedule = await findScheduleById(deployment.scheduleId); const schedule = await findScheduleById(deployment.scheduleId);
try { try {
await removeDeploymentsSchedule( const serverId =
deployment.scheduleId, schedule.application?.serverId ||
schedule.application.serverId, schedule.compose?.serverId ||
); schedule.server?.serverId;
const { SCHEDULES_PATH } = paths(!!schedule.application.serverId); await removeDeploymentsSchedule(deployment.scheduleId, serverId);
const { SCHEDULES_PATH } = paths(!!serverId);
const formattedDateTime = format(new Date(), "yyyy-MM-dd:HH:mm:ss"); const formattedDateTime = format(new Date(), "yyyy-MM-dd:HH:mm:ss");
const fileName = `${schedule.appName}-${formattedDateTime}.log`; const fileName = `${schedule.appName}-${formattedDateTime}.log`;
const logFilePath = path.join(SCHEDULES_PATH, schedule.appName, fileName); const logFilePath = path.join(SCHEDULES_PATH, schedule.appName, fileName);
if (schedule.application.serverId) { if (serverId) {
const server = await findServerById(schedule.application.serverId); console.log("serverId", serverId);
const server = await findServerById(serverId);
const command = ` const command = `
mkdir -p ${SCHEDULES_PATH}/${schedule.appName}; mkdir -p ${SCHEDULES_PATH}/${schedule.appName};
@@ -324,6 +326,7 @@ export const createDeploymentSchedule = async (
} }
return deploymentCreate[0]; return deploymentCreate[0];
} catch (error) { } catch (error) {
console.log(error);
await db await db
.insert(deployments) .insert(deployments)
.values({ .values({
@@ -476,7 +479,7 @@ export const removeLastTenPreviewDeploymenById = async (
export const removeDeploymentsSchedule = async ( export const removeDeploymentsSchedule = async (
scheduleId: string, scheduleId: string,
serverId: string | null, serverId?: string | null,
) => { ) => {
const deploymentList = await db.query.deployments.findMany({ const deploymentList = await db.query.deployments.findMany({
where: eq(deployments.scheduleId, scheduleId), where: eq(deployments.scheduleId, scheduleId),

View File

@@ -1,13 +1,41 @@
import { schedules } from "../db/schema/schedule"; import { type Schedule, schedules } from "../db/schema/schedule";
import { db } from "../db"; import { db } from "../db";
import { eq } from "drizzle-orm"; import { eq } from "drizzle-orm";
import { TRPCError } from "@trpc/server"; 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) => { export const findScheduleById = async (scheduleId: string) => {
const schedule = await db.query.schedules.findFirst({ const schedule = await db.query.schedules.findFirst({
where: eq(schedules.scheduleId, scheduleId), where: eq(schedules.scheduleId, scheduleId),
with: { with: {
application: true, application: true,
compose: true,
server: true,
}, },
}); });
@@ -19,3 +47,66 @@ export const findScheduleById = async (scheduleId: string) => {
} }
return schedule; 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);
}
};

View File

@@ -13,6 +13,7 @@ import type { RedisNested } from "../databases/redis";
import { execAsync, execAsyncRemote } from "../process/execAsync"; import { execAsync, execAsyncRemote } from "../process/execAsync";
import { spawnAsync } from "../process/spawnAsync"; import { spawnAsync } from "../process/spawnAsync";
import { getRemoteDocker } from "../servers/remote-docker"; import { getRemoteDocker } from "../servers/remote-docker";
import type { Compose } from "@dokploy/server/services/compose";
interface RegistryAuth { interface RegistryAuth {
username: string; username: string;
@@ -541,3 +542,67 @@ export const getRemoteServiceContainer = async (
throw error; 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;
}
};

View File

@@ -1,15 +1,15 @@
import type { Schedule } from "@dokploy/server/db/schema/schedule"; import type { Schedule } from "@dokploy/server/db/schema/schedule";
import { findScheduleById } from "@dokploy/server/services/schedule"; import { findScheduleById } from "@dokploy/server/services/schedule";
import { scheduleJob as scheduleJobNode } from "node-schedule"; import { scheduleJob as scheduleJobNode } from "node-schedule";
import { import { getComposeContainer, getServiceContainerIV2 } from "../docker/utils";
getRemoteServiceContainer,
getServiceContainer,
} from "../docker/utils";
import { execAsyncRemote } from "../process/execAsync"; import { execAsyncRemote } from "../process/execAsync";
import { spawnAsync } from "../process/spawnAsync"; import { spawnAsync } from "../process/spawnAsync";
import { createDeploymentSchedule } from "@dokploy/server/services/deployment"; import { createDeploymentSchedule } from "@dokploy/server/services/deployment";
import { createWriteStream } from "node:fs"; import { createWriteStream } from "node:fs";
import { updateDeploymentStatus } from "@dokploy/server/services/deployment"; import { updateDeploymentStatus } from "@dokploy/server/services/deployment";
import { paths } from "@dokploy/server/constants";
import path from "node:path";
export const scheduleJob = (schedule: Schedule) => { export const scheduleJob = (schedule: Schedule) => {
const { cronExpression, scheduleId } = schedule; const { cronExpression, scheduleId } = schedule;
@@ -19,17 +19,16 @@ export const scheduleJob = (schedule: Schedule) => {
}; };
export const runCommand = async (scheduleId: string) => { export const runCommand = async (scheduleId: string) => {
const { application, command, shellType } = const {
await findScheduleById(scheduleId); application,
command,
const isServer = !!application.serverId; shellType,
scheduleType,
const { Id: containerId } = isServer compose,
? await getRemoteServiceContainer( serviceName,
application.serverId || "", appName,
application.appName, serverId,
) } = await findScheduleById(scheduleId);
: await getServiceContainer(application.appName);
const deployment = await createDeploymentSchedule({ const deployment = await createDeploymentSchedule({
scheduleId, scheduleId,
@@ -37,51 +36,109 @@ export const runCommand = async (scheduleId: string) => {
description: "Schedule", description: "Schedule",
}); });
if (isServer) { if (scheduleType === "application" || scheduleType === "compose") {
try { let containerId = "";
await execAsyncRemote( let serverId = "";
if (scheduleType === "application" && application) {
const container = await getServiceContainerIV2(
application.appName,
application.serverId, 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) { containerId = container.Id;
await updateDeploymentStatus(deployment.deploymentId, "error"); serverId = application.serverId || "";
throw error; }
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 { try {
writeStream.write( const writeStream = createWriteStream(deployment.logPath, { flags: "a" });
`docker exec ${containerId} ${shellType} -c "${command}"\n`, const { SCHEDULES_PATH } = paths();
); const fullPath = path.join(SCHEDULES_PATH, appName || "");
await spawnAsync( await spawnAsync(
"docker", "bash",
["exec", containerId, shellType, "-c", command], ["-c", "./script.sh"],
(data) => { (data) => {
if (writeStream.writable) { if (writeStream.writable) {
writeStream.write(data); writeStream.write(data);
} }
}, },
{
cwd: fullPath,
},
); );
writeStream.write("✅ Command executed successfully\n");
} catch (error) { } catch (error) {
writeStream.write("❌ Command failed\n"); await updateDeploymentStatus(deployment.deploymentId, "error");
writeStream.write( throw error;
error instanceof Error ? error.message : "Unknown error", }
); } else if (scheduleType === "server") {
writeStream.end(); 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"); await updateDeploymentStatus(deployment.deploymentId, "error");
throw error; throw error;
} }
} }
await updateDeploymentStatus(deployment.deploymentId, "done"); await updateDeploymentStatus(deployment.deploymentId, "done");
}; };