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 { useForm } from "react-hook-form";
import { z } from "zod";
import { Clock, Terminal, Info, PlusCircle, PenBoxIcon } from "lucide-react";
import {
Clock,
Terminal,
Info,
PlusCircle,
PenBoxIcon,
RefreshCw,
DatabaseZap,
} from "lucide-react";
import {
Select,
SelectContent,
@ -37,7 +45,10 @@ import {
DialogTrigger,
} from "@/components/ui/dialog";
import { toast } from "sonner";
import type { CacheType } from "../../compose/domains/add-domain";
import { AlertBlock } from "@/components/shared/alert-block";
import { CodeEditor } from "@/components/shared/code-editor";
import { cn } from "@/lib/utils";
const commonCronExpressions = [
{ label: "Every minute", value: "* * * * *" },
{ label: "Every hour", value: "0 * * * *" },
@ -48,21 +59,66 @@ const commonCronExpressions = [
{ label: "Every weekday at midnight", value: "0 0 * * 1-5" },
];
const formSchema = z.object({
name: z.string().min(1, "Name is required"),
cronExpression: z.string().min(1, "Cron expression is required"),
shellType: z.enum(["bash", "sh"]).default("bash"),
command: z.string().min(1, "Command is required"),
enabled: z.boolean().default(true),
});
const formSchema = z
.object({
name: z.string().min(1, "Name is required"),
cronExpression: z.string().min(1, "Cron expression is required"),
shellType: z.enum(["bash", "sh"]).default("bash"),
command: z.string(),
enabled: z.boolean().default(true),
serviceName: z.string(),
scheduleType: z.enum([
"application",
"compose",
"server",
"dokploy-server",
]),
script: z.string(),
})
.superRefine((data, ctx) => {
if (data.scheduleType === "compose" && !data.serviceName) {
ctx.addIssue({
code: z.ZodIssueCode.custom,
message: "Service name is required",
path: ["serviceName"],
});
}
if (
(data.scheduleType === "dokploy-server" ||
data.scheduleType === "server") &&
!data.script
) {
ctx.addIssue({
code: z.ZodIssueCode.custom,
message: "Script is required",
path: ["script"],
});
}
if (
(data.scheduleType === "application" ||
data.scheduleType === "compose") &&
!data.command
) {
ctx.addIssue({
code: z.ZodIssueCode.custom,
message: "Command is required",
path: ["command"],
});
}
});
interface Props {
applicationId?: string;
id?: string;
scheduleId?: string;
scheduleType?: "application" | "compose" | "server" | "dokploy-server";
}
export const HandleSchedules = ({ applicationId, scheduleId }: Props) => {
export const HandleSchedules = ({ id, scheduleId, scheduleType }: Props) => {
const [isOpen, setIsOpen] = useState(false);
const [cacheType, setCacheType] = useState<CacheType>("cache");
const utils = api.useUtils();
const form = useForm<z.infer<typeof formSchema>>({
resolver: zodResolver(formSchema),
@ -72,14 +128,36 @@ export const HandleSchedules = ({ applicationId, scheduleId }: Props) => {
shellType: "bash",
command: "",
enabled: true,
serviceName: "",
scheduleType: scheduleType || "application",
script: "",
},
});
const scheduleTypeForm = form.watch("scheduleType");
const { data: schedule } = api.schedule.one.useQuery(
{ scheduleId: scheduleId || "" },
{ enabled: !!scheduleId },
);
const {
data: services,
isFetching: isLoadingServices,
error: errorServices,
refetch: refetchServices,
} = api.compose.loadServices.useQuery(
{
composeId: id || "",
type: cacheType,
},
{
retry: false,
refetchOnWindowFocus: false,
enabled: !!id && scheduleType === "compose",
},
);
useEffect(() => {
if (scheduleId && schedule) {
form.reset({
@ -88,6 +166,9 @@ export const HandleSchedules = ({ applicationId, scheduleId }: Props) => {
shellType: schedule.shellType,
command: schedule.command,
enabled: schedule.enabled,
serviceName: schedule.serviceName || "",
scheduleType: schedule.scheduleType,
script: schedule.script || "",
});
}
}, [form, schedule, scheduleId]);
@ -97,18 +178,32 @@ export const HandleSchedules = ({ applicationId, scheduleId }: Props) => {
: api.schedule.create.useMutation();
const onSubmit = async (values: z.infer<typeof formSchema>) => {
if (!applicationId && !scheduleId) return;
if (!id && !scheduleId) return;
await mutateAsync({
...values,
scheduleId: scheduleId || "",
applicationId: applicationId || "",
...(scheduleType === "application" && {
applicationId: id || "",
}),
...(scheduleType === "compose" && {
composeId: id || "",
}),
...(scheduleType === "server" && {
serverId: id || "",
}),
...(scheduleType === "dokploy-server" && {
userId: id || "",
}),
})
.then(() => {
toast.success(
`Schedule ${scheduleId ? "updated" : "created"} successfully`,
);
utils.schedule.list.invalidate({ applicationId });
utils.schedule.list.invalidate({
id,
scheduleType,
});
setIsOpen(false);
})
.catch((error) => {
@ -136,12 +231,130 @@ export const HandleSchedules = ({ applicationId, scheduleId }: Props) => {
</Button>
)}
</DialogTrigger>
<DialogContent>
<DialogContent
className={cn(
"max-h-screen overflow-y-auto",
scheduleTypeForm === "dokploy-server" || scheduleTypeForm === "server"
? "max-h-[95vh] sm:max-w-2xl"
: " sm:max-w-lg",
)}
>
<DialogHeader>
<DialogTitle>{scheduleId ? "Edit" : "Create"} Schedule</DialogTitle>
</DialogHeader>
<Form {...form}>
<form onSubmit={form.handleSubmit(onSubmit)} className="space-y-6">
{scheduleTypeForm === "compose" && (
<div className="flex flex-col w-full gap-4">
{errorServices && (
<AlertBlock
type="warning"
className="[overflow-wrap:anywhere]"
>
{errorServices?.message}
</AlertBlock>
)}
<FormField
control={form.control}
name="serviceName"
render={({ field }) => (
<FormItem className="w-full">
<FormLabel>Service Name</FormLabel>
<div className="flex gap-2">
<Select
onValueChange={field.onChange}
defaultValue={field.value || ""}
>
<FormControl>
<SelectTrigger>
<SelectValue placeholder="Select a service name" />
</SelectTrigger>
</FormControl>
<SelectContent>
{services?.map((service, index) => (
<SelectItem
value={service}
key={`${service}-${index}`}
>
{service}
</SelectItem>
))}
<SelectItem value="none" disabled>
Empty
</SelectItem>
</SelectContent>
</Select>
<TooltipProvider delayDuration={0}>
<Tooltip>
<TooltipTrigger asChild>
<Button
variant="secondary"
type="button"
isLoading={isLoadingServices}
onClick={() => {
if (cacheType === "fetch") {
refetchServices();
} else {
setCacheType("fetch");
}
}}
>
<RefreshCw className="size-4 text-muted-foreground" />
</Button>
</TooltipTrigger>
<TooltipContent
side="left"
sideOffset={5}
className="max-w-[10rem]"
>
<p>
Fetch: Will clone the repository and load the
services
</p>
</TooltipContent>
</Tooltip>
</TooltipProvider>
<TooltipProvider delayDuration={0}>
<Tooltip>
<TooltipTrigger asChild>
<Button
variant="secondary"
type="button"
isLoading={isLoadingServices}
onClick={() => {
if (cacheType === "cache") {
refetchServices();
} else {
setCacheType("cache");
}
}}
>
<DatabaseZap className="size-4 text-muted-foreground" />
</Button>
</TooltipTrigger>
<TooltipContent
side="left"
sideOffset={5}
className="max-w-[10rem]"
>
<p>
Cache: If you previously deployed this compose,
it will read the services from the last
deployment/fetch from the repository
</p>
</TooltipContent>
</Tooltip>
</TooltipProvider>
</div>
<FormMessage />
</FormItem>
)}
/>
</div>
)}
<FormField
control={form.control}
name="name"
@ -222,59 +435,86 @@ export const HandleSchedules = ({ applicationId, scheduleId }: Props) => {
)}
/>
<FormField
control={form.control}
name="shellType"
render={({ field }) => (
<FormItem>
<FormLabel className="flex items-center gap-2">
<Terminal className="w-4 h-4" />
Shell Type
</FormLabel>
<Select
onValueChange={field.onChange}
defaultValue={field.value}
>
<FormControl>
<SelectTrigger>
<SelectValue placeholder="Select shell type" />
</SelectTrigger>
</FormControl>
<SelectContent>
<SelectItem value="bash">Bash</SelectItem>
<SelectItem value="sh">Sh</SelectItem>
</SelectContent>
</Select>
<FormDescription>
Choose the shell to execute your command
</FormDescription>
<FormMessage />
</FormItem>
)}
/>
{(scheduleTypeForm === "application" ||
scheduleTypeForm === "compose") && (
<>
<FormField
control={form.control}
name="shellType"
render={({ field }) => (
<FormItem>
<FormLabel className="flex items-center gap-2">
<Terminal className="w-4 h-4" />
Shell Type
</FormLabel>
<Select
onValueChange={field.onChange}
defaultValue={field.value}
>
<FormControl>
<SelectTrigger>
<SelectValue placeholder="Select shell type" />
</SelectTrigger>
</FormControl>
<SelectContent>
<SelectItem value="bash">Bash</SelectItem>
<SelectItem value="sh">Sh</SelectItem>
</SelectContent>
</Select>
<FormDescription>
Choose the shell to execute your command
</FormDescription>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="command"
render={({ field }) => (
<FormItem>
<FormLabel className="flex items-center gap-2">
<Terminal className="w-4 h-4" />
Command
</FormLabel>
<FormControl>
<Input placeholder="npm run backup" {...field} />
</FormControl>
<FormDescription>
The command to execute in your container
</FormDescription>
<FormMessage />
</FormItem>
)}
/>
</>
)}
<FormField
control={form.control}
name="command"
render={({ field }) => (
<FormItem>
<FormLabel className="flex items-center gap-2">
<Terminal className="w-4 h-4" />
Command
</FormLabel>
<FormControl>
<Input
placeholder="docker exec my-container npm run backup"
{...field}
/>
</FormControl>
<FormDescription>
The command to execute in your container
</FormDescription>
<FormMessage />
</FormItem>
)}
/>
{(scheduleTypeForm === "dokploy-server" ||
scheduleTypeForm === "server") && (
<FormField
control={form.control}
name="script"
render={({ field }) => (
<FormItem>
<FormLabel>Script</FormLabel>
<FormControl>
<FormControl>
<CodeEditor
language="shell"
placeholder={`# This is a comment
echo "Hello, world!"
`}
className="h-96 font-mono"
{...field}
/>
</FormControl>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
)}
<FormField
control={form.control}
@ -292,15 +532,8 @@ export const HandleSchedules = ({ applicationId, scheduleId }: Props) => {
)}
/>
<Button type="submit" disabled={isLoading} className="w-full">
{isLoading ? (
<>
<Clock className="mr-2 h-4 w-4 animate-spin" />
{scheduleId ? "Updating..." : "Creating..."}
</>
) : (
<>{scheduleId ? "Update" : "Create"} Schedule</>
)}
<Button type="submit" isLoading={isLoading} className="w-full">
{scheduleId ? "Update" : "Create"} Schedule
</Button>
</form>
</Form>

View File

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

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

View File

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

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,
"tag": "0092_safe_scarlet_witch",
"breakpoints": true
},
{
"idx": 93,
"version": "7",
"when": 1746228754403,
"tag": "0093_abnormal_machine_man",
"breakpoints": true
},
{
"idx": 94,
"version": "7",
"when": 1746228771046,
"tag": "0094_easy_butterfly",
"breakpoints": true
},
{
"idx": 95,
"version": "7",
"when": 1746232483345,
"tag": "0095_friendly_cobalt_man",
"breakpoints": true
}
]
}

View File

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

View File

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

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

View File

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

View File

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

View File

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

View File

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

View File

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

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 { eq } from "drizzle-orm";
import { TRPCError } from "@trpc/server";
import type { z } from "zod";
import type {
createScheduleSchema,
updateScheduleSchema,
} from "../db/schema/schedule";
import { execAsync, execAsyncRemote } from "../utils/process/execAsync";
import { paths } from "../constants";
import path from "node:path";
export type ScheduleExtended = Awaited<ReturnType<typeof findScheduleById>>;
export const createSchedule = async (
input: z.infer<typeof createScheduleSchema>,
) => {
const { scheduleId, ...rest } = input;
const [newSchedule] = await db.insert(schedules).values(rest).returning();
if (
newSchedule &&
(newSchedule.scheduleType === "dokploy-server" ||
newSchedule.scheduleType === "server")
) {
await handleScript(newSchedule);
}
return newSchedule;
};
export const findScheduleById = async (scheduleId: string) => {
const schedule = await db.query.schedules.findFirst({
where: eq(schedules.scheduleId, scheduleId),
with: {
application: true,
compose: true,
server: true,
},
});
@ -19,3 +47,66 @@ export const findScheduleById = async (scheduleId: string) => {
}
return schedule;
};
export const deleteSchedule = async (scheduleId: string) => {
const schedule = await findScheduleById(scheduleId);
const { SCHEDULES_PATH } = paths(!!schedule?.serverId);
const fullPath = path.join(SCHEDULES_PATH, schedule?.appName || "");
const command = `rm -rf ${fullPath}`;
if (schedule.serverId) {
await execAsyncRemote(schedule.serverId, command);
} else {
await execAsync(command);
}
const scheduleResult = await db
.delete(schedules)
.where(eq(schedules.scheduleId, scheduleId));
if (!scheduleResult) {
throw new TRPCError({
code: "NOT_FOUND",
message: "Schedule not found",
});
}
return true;
};
export const updateSchedule = async (
input: z.infer<typeof updateScheduleSchema>,
) => {
const { scheduleId, ...rest } = input;
const [updatedSchedule] = await db
.update(schedules)
.set(rest)
.where(eq(schedules.scheduleId, scheduleId))
.returning();
if (
updatedSchedule?.scheduleType === "dokploy-server" ||
updatedSchedule?.scheduleType === "server"
) {
await handleScript(updatedSchedule);
}
return updatedSchedule;
};
const handleScript = async (schedule: Schedule) => {
const { SCHEDULES_PATH } = paths(!!schedule?.serverId);
const fullPath = path.join(SCHEDULES_PATH, schedule?.appName || "");
const script = `
mkdir -p ${fullPath}
rm -f ${fullPath}/script.sh
touch ${fullPath}/script.sh
chmod +x ${fullPath}/script.sh
echo "${schedule?.script}" > ${fullPath}/script.sh
`;
if (schedule?.scheduleType === "dokploy-server") {
await execAsync(script);
} else if (schedule?.scheduleType === "server") {
await execAsyncRemote(schedule?.serverId || "", script);
}
};

View File

@ -13,6 +13,7 @@ import type { RedisNested } from "../databases/redis";
import { execAsync, execAsyncRemote } from "../process/execAsync";
import { spawnAsync } from "../process/spawnAsync";
import { getRemoteDocker } from "../servers/remote-docker";
import type { Compose } from "@dokploy/server/services/compose";
interface RegistryAuth {
username: string;
@ -541,3 +542,67 @@ export const getRemoteServiceContainer = async (
throw error;
}
};
export const getServiceContainerIV2 = async (
appName: string,
serverId?: string | null,
) => {
try {
const filter = {
status: ["running"],
label: [`com.docker.swarm.service.name=${appName}`],
};
const remoteDocker = await getRemoteDocker(serverId);
const containers = await remoteDocker.listContainers({
filters: JSON.stringify(filter),
});
if (containers.length === 0 || !containers[0]) {
throw new Error(`No container found with name: ${appName}`);
}
const container = containers[0];
return container;
} catch (error) {
throw error;
}
};
export const getComposeContainer = async (
compose: Compose,
serviceName: string,
) => {
try {
const { appName, composeType, serverId } = compose;
// 1. Determine the correct labels based on composeType
const labels: string[] = [];
if (composeType === "stack") {
// Labels for Docker Swarm stack services
labels.push(`com.docker.stack.namespace=${appName}`);
labels.push(`com.docker.swarm.service.name=${appName}_${serviceName}`);
} else {
// Labels for Docker Compose projects (default)
labels.push(`com.docker.compose.project=${appName}`);
labels.push(`com.docker.compose.service=${serviceName}`);
}
const filter = {
status: ["running"],
label: labels,
};
const remoteDocker = await getRemoteDocker(serverId);
const containers = await remoteDocker.listContainers({
filters: JSON.stringify(filter),
limit: 1,
});
if (containers.length === 0 || !containers[0]) {
throw new Error(`No container found with name: ${appName}`);
}
const container = containers[0];
return container;
} catch (error) {
throw error;
}
};

View File

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