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
@@ -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>
@@ -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>
@@ -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 { 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>
+9
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",
@@ -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;
@@ -0,0 +1 @@
ALTER TABLE "schedule" ADD COLUMN "script" text;
@@ -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
+21
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
} }
] ]
} }
@@ -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">
@@ -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 ">
@@ -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: {},
};
}
+38 -52
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
+2 -1
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, {
+40 -11
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),
+2 -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, {
+2
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, {
+11 -8
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),
+92 -1
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);
}
};
+65
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;
}
};
+101 -44
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");
}; };