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 { useForm } from "react-hook-form";
import { z } from "zod";
import { Clock, Terminal, Info, PlusCircle, PenBoxIcon } from "lucide-react";
import {
Clock,
Terminal,
Info,
PlusCircle,
PenBoxIcon,
RefreshCw,
DatabaseZap,
} from "lucide-react";
import {
Select,
SelectContent,
@@ -37,7 +45,10 @@ import {
DialogTrigger,
} from "@/components/ui/dialog";
import { toast } from "sonner";
import type { CacheType } from "../../compose/domains/add-domain";
import { AlertBlock } from "@/components/shared/alert-block";
import { CodeEditor } from "@/components/shared/code-editor";
import { cn } from "@/lib/utils";
const commonCronExpressions = [
{ label: "Every minute", value: "* * * * *" },
{ label: "Every hour", value: "0 * * * *" },
@@ -48,21 +59,66 @@ const commonCronExpressions = [
{ label: "Every weekday at midnight", value: "0 0 * * 1-5" },
];
const formSchema = z.object({
name: z.string().min(1, "Name is required"),
cronExpression: z.string().min(1, "Cron expression is required"),
shellType: z.enum(["bash", "sh"]).default("bash"),
command: z.string().min(1, "Command is required"),
enabled: z.boolean().default(true),
});
const formSchema = z
.object({
name: z.string().min(1, "Name is required"),
cronExpression: z.string().min(1, "Cron expression is required"),
shellType: z.enum(["bash", "sh"]).default("bash"),
command: z.string(),
enabled: z.boolean().default(true),
serviceName: z.string(),
scheduleType: z.enum([
"application",
"compose",
"server",
"dokploy-server",
]),
script: z.string(),
})
.superRefine((data, ctx) => {
if (data.scheduleType === "compose" && !data.serviceName) {
ctx.addIssue({
code: z.ZodIssueCode.custom,
message: "Service name is required",
path: ["serviceName"],
});
}
if (
(data.scheduleType === "dokploy-server" ||
data.scheduleType === "server") &&
!data.script
) {
ctx.addIssue({
code: z.ZodIssueCode.custom,
message: "Script is required",
path: ["script"],
});
}
if (
(data.scheduleType === "application" ||
data.scheduleType === "compose") &&
!data.command
) {
ctx.addIssue({
code: z.ZodIssueCode.custom,
message: "Command is required",
path: ["command"],
});
}
});
interface Props {
applicationId?: string;
id?: string;
scheduleId?: string;
scheduleType?: "application" | "compose" | "server" | "dokploy-server";
}
export const HandleSchedules = ({ applicationId, scheduleId }: Props) => {
export const HandleSchedules = ({ id, scheduleId, scheduleType }: Props) => {
const [isOpen, setIsOpen] = useState(false);
const [cacheType, setCacheType] = useState<CacheType>("cache");
const utils = api.useUtils();
const form = useForm<z.infer<typeof formSchema>>({
resolver: zodResolver(formSchema),
@@ -72,14 +128,36 @@ export const HandleSchedules = ({ applicationId, scheduleId }: Props) => {
shellType: "bash",
command: "",
enabled: true,
serviceName: "",
scheduleType: scheduleType || "application",
script: "",
},
});
const scheduleTypeForm = form.watch("scheduleType");
const { data: schedule } = api.schedule.one.useQuery(
{ scheduleId: scheduleId || "" },
{ enabled: !!scheduleId },
);
const {
data: services,
isFetching: isLoadingServices,
error: errorServices,
refetch: refetchServices,
} = api.compose.loadServices.useQuery(
{
composeId: id || "",
type: cacheType,
},
{
retry: false,
refetchOnWindowFocus: false,
enabled: !!id && scheduleType === "compose",
},
);
useEffect(() => {
if (scheduleId && schedule) {
form.reset({
@@ -88,6 +166,9 @@ export const HandleSchedules = ({ applicationId, scheduleId }: Props) => {
shellType: schedule.shellType,
command: schedule.command,
enabled: schedule.enabled,
serviceName: schedule.serviceName || "",
scheduleType: schedule.scheduleType,
script: schedule.script || "",
});
}
}, [form, schedule, scheduleId]);
@@ -97,18 +178,32 @@ export const HandleSchedules = ({ applicationId, scheduleId }: Props) => {
: api.schedule.create.useMutation();
const onSubmit = async (values: z.infer<typeof formSchema>) => {
if (!applicationId && !scheduleId) return;
if (!id && !scheduleId) return;
await mutateAsync({
...values,
scheduleId: scheduleId || "",
applicationId: applicationId || "",
...(scheduleType === "application" && {
applicationId: id || "",
}),
...(scheduleType === "compose" && {
composeId: id || "",
}),
...(scheduleType === "server" && {
serverId: id || "",
}),
...(scheduleType === "dokploy-server" && {
userId: id || "",
}),
})
.then(() => {
toast.success(
`Schedule ${scheduleId ? "updated" : "created"} successfully`,
);
utils.schedule.list.invalidate({ applicationId });
utils.schedule.list.invalidate({
id,
scheduleType,
});
setIsOpen(false);
})
.catch((error) => {
@@ -136,12 +231,130 @@ export const HandleSchedules = ({ applicationId, scheduleId }: Props) => {
</Button>
)}
</DialogTrigger>
<DialogContent>
<DialogContent
className={cn(
"max-h-screen overflow-y-auto",
scheduleTypeForm === "dokploy-server" || scheduleTypeForm === "server"
? "max-h-[95vh] sm:max-w-2xl"
: " sm:max-w-lg",
)}
>
<DialogHeader>
<DialogTitle>{scheduleId ? "Edit" : "Create"} Schedule</DialogTitle>
</DialogHeader>
<Form {...form}>
<form onSubmit={form.handleSubmit(onSubmit)} className="space-y-6">
{scheduleTypeForm === "compose" && (
<div className="flex flex-col w-full gap-4">
{errorServices && (
<AlertBlock
type="warning"
className="[overflow-wrap:anywhere]"
>
{errorServices?.message}
</AlertBlock>
)}
<FormField
control={form.control}
name="serviceName"
render={({ field }) => (
<FormItem className="w-full">
<FormLabel>Service Name</FormLabel>
<div className="flex gap-2">
<Select
onValueChange={field.onChange}
defaultValue={field.value || ""}
>
<FormControl>
<SelectTrigger>
<SelectValue placeholder="Select a service name" />
</SelectTrigger>
</FormControl>
<SelectContent>
{services?.map((service, index) => (
<SelectItem
value={service}
key={`${service}-${index}`}
>
{service}
</SelectItem>
))}
<SelectItem value="none" disabled>
Empty
</SelectItem>
</SelectContent>
</Select>
<TooltipProvider delayDuration={0}>
<Tooltip>
<TooltipTrigger asChild>
<Button
variant="secondary"
type="button"
isLoading={isLoadingServices}
onClick={() => {
if (cacheType === "fetch") {
refetchServices();
} else {
setCacheType("fetch");
}
}}
>
<RefreshCw className="size-4 text-muted-foreground" />
</Button>
</TooltipTrigger>
<TooltipContent
side="left"
sideOffset={5}
className="max-w-[10rem]"
>
<p>
Fetch: Will clone the repository and load the
services
</p>
</TooltipContent>
</Tooltip>
</TooltipProvider>
<TooltipProvider delayDuration={0}>
<Tooltip>
<TooltipTrigger asChild>
<Button
variant="secondary"
type="button"
isLoading={isLoadingServices}
onClick={() => {
if (cacheType === "cache") {
refetchServices();
} else {
setCacheType("cache");
}
}}
>
<DatabaseZap className="size-4 text-muted-foreground" />
</Button>
</TooltipTrigger>
<TooltipContent
side="left"
sideOffset={5}
className="max-w-[10rem]"
>
<p>
Cache: If you previously deployed this compose,
it will read the services from the last
deployment/fetch from the repository
</p>
</TooltipContent>
</Tooltip>
</TooltipProvider>
</div>
<FormMessage />
</FormItem>
)}
/>
</div>
)}
<FormField
control={form.control}
name="name"
@@ -222,59 +435,86 @@ export const HandleSchedules = ({ applicationId, scheduleId }: Props) => {
)}
/>
<FormField
control={form.control}
name="shellType"
render={({ field }) => (
<FormItem>
<FormLabel className="flex items-center gap-2">
<Terminal className="w-4 h-4" />
Shell Type
</FormLabel>
<Select
onValueChange={field.onChange}
defaultValue={field.value}
>
<FormControl>
<SelectTrigger>
<SelectValue placeholder="Select shell type" />
</SelectTrigger>
</FormControl>
<SelectContent>
<SelectItem value="bash">Bash</SelectItem>
<SelectItem value="sh">Sh</SelectItem>
</SelectContent>
</Select>
<FormDescription>
Choose the shell to execute your command
</FormDescription>
<FormMessage />
</FormItem>
)}
/>
{(scheduleTypeForm === "application" ||
scheduleTypeForm === "compose") && (
<>
<FormField
control={form.control}
name="shellType"
render={({ field }) => (
<FormItem>
<FormLabel className="flex items-center gap-2">
<Terminal className="w-4 h-4" />
Shell Type
</FormLabel>
<Select
onValueChange={field.onChange}
defaultValue={field.value}
>
<FormControl>
<SelectTrigger>
<SelectValue placeholder="Select shell type" />
</SelectTrigger>
</FormControl>
<SelectContent>
<SelectItem value="bash">Bash</SelectItem>
<SelectItem value="sh">Sh</SelectItem>
</SelectContent>
</Select>
<FormDescription>
Choose the shell to execute your command
</FormDescription>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="command"
render={({ field }) => (
<FormItem>
<FormLabel className="flex items-center gap-2">
<Terminal className="w-4 h-4" />
Command
</FormLabel>
<FormControl>
<Input placeholder="npm run backup" {...field} />
</FormControl>
<FormDescription>
The command to execute in your container
</FormDescription>
<FormMessage />
</FormItem>
)}
/>
</>
)}
<FormField
control={form.control}
name="command"
render={({ field }) => (
<FormItem>
<FormLabel className="flex items-center gap-2">
<Terminal className="w-4 h-4" />
Command
</FormLabel>
<FormControl>
<Input
placeholder="docker exec my-container npm run backup"
{...field}
/>
</FormControl>
<FormDescription>
The command to execute in your container
</FormDescription>
<FormMessage />
</FormItem>
)}
/>
{(scheduleTypeForm === "dokploy-server" ||
scheduleTypeForm === "server") && (
<FormField
control={form.control}
name="script"
render={({ field }) => (
<FormItem>
<FormLabel>Script</FormLabel>
<FormControl>
<FormControl>
<CodeEditor
language="shell"
placeholder={`# This is a comment
echo "Hello, world!"
`}
className="h-96 font-mono"
{...field}
/>
</FormControl>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
)}
<FormField
control={form.control}
@@ -292,15 +532,8 @@ export const HandleSchedules = ({ applicationId, scheduleId }: Props) => {
)}
/>
<Button type="submit" disabled={isLoading} className="w-full">
{isLoading ? (
<>
<Clock className="mr-2 h-4 w-4 animate-spin" />
{scheduleId ? "Updating..." : "Creating..."}
</>
) : (
<>{scheduleId ? "Update" : "Create"} Schedule</>
)}
<Button type="submit" isLoading={isLoading} className="w-full">
{scheduleId ? "Update" : "Create"} Schedule
</Button>
</form>
</Form>
@@ -28,14 +28,25 @@ import {
import { DialogAction } from "@/components/shared/dialog-action";
interface Props {
applicationId: string;
id: string;
scheduleType?: "application" | "compose" | "server" | "dokploy-server";
}
export const ShowSchedules = ({ applicationId }: Props) => {
const { data: schedules, isLoading: isLoadingSchedules } =
api.schedule.list.useQuery({
applicationId,
});
export const ShowSchedules = ({ id, scheduleType = "application" }: Props) => {
const {
data: schedules,
isLoading: isLoadingSchedules,
refetch: refetchSchedules,
} = api.schedule.list.useQuery(
{
id: id || "",
scheduleType,
},
{
enabled: !!id,
},
);
const utils = api.useUtils();
const { mutateAsync: deleteSchedule, isLoading: isDeleting } =
@@ -45,7 +56,7 @@ export const ShowSchedules = ({ applicationId }: Props) => {
api.schedule.runManually.useMutation();
return (
<Card className="border px-4 shadow-none bg-transparent">
<Card className="border px-6 shadow-none bg-transparent h-full min-h-[50vh]">
<CardHeader className="px-0">
<div className="flex justify-between items-center">
<div className="flex flex-col gap-2">
@@ -58,13 +69,13 @@ export const ShowSchedules = ({ applicationId }: Props) => {
</div>
{schedules && schedules.length > 0 && (
<HandleSchedules applicationId={applicationId} />
<HandleSchedules id={id} scheduleType={scheduleType} />
)}
</div>
</CardHeader>
<CardContent className="px-0">
{isLoadingSchedules ? (
<div className="flex gap-4 min-h-[35vh] w-full items-center justify-center text-center mx-auto">
<div className="flex gap-4 w-full items-center justify-center text-center mx-auto">
<Loader2 className="size-4 text-muted-foreground/70 transition-colors animate-spin self-center" />
<span className="text-sm text-muted-foreground/70">
Loading scheduled tasks...
@@ -73,7 +84,10 @@ export const ShowSchedules = ({ applicationId }: Props) => {
) : schedules && schedules.length > 0 ? (
<div className="grid xl:grid-cols-2 gap-4 grid-cols-1 h-full">
{schedules.map((schedule) => {
const application = schedule.application;
const serverId =
schedule.serverId ||
schedule.application?.serverId ||
schedule.compose?.serverId;
const deployments = schedule.deployments;
return (
<div
@@ -101,31 +115,38 @@ export const ShowSchedules = ({ applicationId }: Props) => {
variant="outline"
className="font-mono text-[10px] bg-transparent"
>
{schedule.cronExpression}
</Badge>
<span className="text-xs text-muted-foreground/50">
•
</span>
<Badge
variant="outline"
className="font-mono text-[10px] bg-transparent"
>
{schedule.shellType}
Cron: {schedule.cronExpression}
</Badge>
{schedule.scheduleType !== "server" &&
schedule.scheduleType !== "dokploy-server" && (
<>
<span className="text-xs text-muted-foreground/50">
•
</span>
<Badge
variant="outline"
className="font-mono text-[10px] bg-transparent"
>
{schedule.shellType}
</Badge>
</>
)}
</div>
<div className="flex items-center gap-2">
<Terminal className="size-3.5 text-muted-foreground/70" />
<code className="font-mono text-[10px] text-muted-foreground/70">
{schedule.command}
</code>
</div>
{schedule.command && (
<div className="flex items-center gap-2">
<Terminal className="size-3.5 text-muted-foreground/70" />
<code className="font-mono text-[10px] text-muted-foreground/70">
{schedule.command}
</code>
</div>
)}
</div>
</div>
<div className="flex items-center gap-1.5">
<ShowSchedulesLogs
deployments={deployments || []}
serverId={application.serverId || undefined}
serverId={serverId}
>
<Button variant="ghost" size="icon">
<ClipboardList className="size-4 transition-colors " />
@@ -146,15 +167,10 @@ export const ShowSchedules = ({ applicationId }: Props) => {
})
.then(() => {
toast.success("Schedule run successfully");
utils.schedule.list.invalidate({
applicationId,
});
refetchSchedules();
})
.catch((error) => {
console.log(error);
toast.error(
`Error running schedule: ${error}`,
);
.catch(() => {
toast.error("Error running schedule:");
});
}}
>
@@ -167,7 +183,8 @@ export const ShowSchedules = ({ applicationId }: Props) => {
<HandleSchedules
scheduleId={schedule.scheduleId}
applicationId={applicationId}
id={id}
scheduleType={scheduleType}
/>
<DialogAction
@@ -180,7 +197,8 @@ export const ShowSchedules = ({ applicationId }: Props) => {
})
.then(() => {
utils.schedule.list.invalidate({
applicationId,
id,
scheduleType,
});
toast.success("Schedule deleted successfully");
})
@@ -204,7 +222,7 @@ export const ShowSchedules = ({ applicationId }: Props) => {
})}
</div>
) : (
<div className="flex flex-col gap-2 items-center justify-center py-12 border rounded-lg">
<div className="flex flex-col gap-2 items-center justify-center py-12 rounded-lg">
<Clock className="size-8 mb-4 text-muted-foreground" />
<p className="text-lg font-medium text-muted-foreground">
No scheduled tasks
@@ -212,7 +230,7 @@ export const ShowSchedules = ({ applicationId }: Props) => {
<p className="text-sm text-muted-foreground mt-1">
Create your first scheduled task to automate your workflows
</p>
<HandleSchedules applicationId={applicationId} />
<HandleSchedules id={id} scheduleType={scheduleType} />
</div>
)}
</CardContent>
@@ -0,0 +1,28 @@
import { useState } from "react";
import { Dialog, DialogContent, DialogTrigger } from "@/components/ui/dialog";
import { DropdownMenuItem } from "@/components/ui/dropdown-menu";
import { ShowSchedules } from "@/components/dashboard/application/schedules/show-schedules";
interface Props {
serverId: string;
}
export const ShowSchedulesModal = ({ serverId }: Props) => {
const [isOpen, setIsOpen] = useState(false);
return (
<Dialog open={isOpen} onOpenChange={setIsOpen}>
<DialogTrigger asChild>
<DropdownMenuItem
className="w-full cursor-pointer "
onSelect={(e) => e.preventDefault()}
>
Show Schedules
</DropdownMenuItem>
</DialogTrigger>
<DialogContent className="sm:max-w-5xl overflow-y-auto max-h-screen ">
<ShowSchedules id={serverId} scheduleType="server" />
</DialogContent>
</Dialog>
);
};
@@ -43,6 +43,7 @@ import { ShowMonitoringModal } from "./show-monitoring-modal";
import { ShowSwarmOverviewModal } from "./show-swarm-overview-modal";
import { ShowTraefikFileSystemModal } from "./show-traefik-file-system-modal";
import { WelcomeSuscription } from "./welcome-stripe/welcome-suscription";
import { ShowSchedulesModal } from "./show-schedules-modal";
export const ShowServers = () => {
const { t } = useTranslation("settings");
@@ -332,6 +333,10 @@ export const ShowServers = () => {
<ShowNodesModal
serverId={server.serverId}
/>
<ShowSchedulesModal
serverId={server.serverId}
/>
</>
)}
</DropdownMenuContent>
+9
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",
@@ -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,
"tag": "0092_safe_scarlet_witch",
"breakpoints": true
},
{
"idx": 93,
"version": "7",
"when": 1746228754403,
"tag": "0093_abnormal_machine_man",
"breakpoints": true
},
{
"idx": 94,
"version": "7",
"when": 1746228771046,
"tag": "0094_easy_butterfly",
"breakpoints": true
},
{
"idx": 95,
"version": "7",
"when": 1746232483345,
"tag": "0095_friendly_cobalt_man",
"breakpoints": true
}
]
}
@@ -312,7 +312,10 @@ const Service = (
</TabsContent>
<TabsContent value="schedules">
<div className="flex flex-col gap-4 pt-2.5">
<ShowSchedules applicationId={applicationId} />
<ShowSchedules
id={applicationId}
scheduleType="application"
/>
</div>
</TabsContent>
<TabsContent value="deployments" className="w-full">
@@ -1,6 +1,7 @@
import { ShowImport } from "@/components/dashboard/application/advanced/import/show-import";
import { ShowVolumes } from "@/components/dashboard/application/advanced/volumes/show-volumes";
import { ShowEnvironment } from "@/components/dashboard/application/environment/show-enviroment";
import { ShowSchedules } from "@/components/dashboard/application/schedules/show-schedules";
import { AddCommandCompose } from "@/components/dashboard/compose/advanced/add-command";
import { DeleteService } from "@/components/dashboard/compose/delete-service";
import { ShowDeploymentsCompose } from "@/components/dashboard/compose/deployments/show-deployments-compose";
@@ -217,10 +218,10 @@ const Service = (
className={cn(
"lg:grid lg:w-fit max-md:overflow-y-scroll justify-start",
isCloud && data?.serverId
? "lg:grid-cols-7"
? "lg:grid-cols-8"
: data?.serverId
? "lg:grid-cols-6"
: "lg:grid-cols-7",
? "lg:grid-cols-7"
: "lg:grid-cols-8",
)}
>
<TabsTrigger value="general">General</TabsTrigger>
@@ -228,6 +229,7 @@ const Service = (
<TabsTrigger value="domains">Domains</TabsTrigger>
<TabsTrigger value="deployments">Deployments</TabsTrigger>
<TabsTrigger value="logs">Logs</TabsTrigger>
<TabsTrigger value="schedules">Schedules</TabsTrigger>
{((data?.serverId && isCloud) || !data?.server) && (
<TabsTrigger value="monitoring">Monitoring</TabsTrigger>
)}
@@ -246,6 +248,12 @@ const Service = (
</div>
</TabsContent>
<TabsContent value="schedules">
<div className="flex flex-col gap-4 pt-2.5">
<ShowSchedules id={composeId} scheduleType="compose" />
</div>
</TabsContent>
<TabsContent value="monitoring">
<div className="pt-2.5">
<div className="flex flex-col border rounded-lg ">
@@ -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,
} 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
+2 -1
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, {
+40 -11
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),
+2 -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, {
+2
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, {
+11 -8
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),
+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 { 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);
}
};
+65
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;
}
};
+101 -44
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");
};