Update schedule management with shell type support and version upgrades

- Updated the `drizzle-zod` package to version 0.7.1 across the project for enhanced schema validation.
- Introduced a new `shellType` column in the `schedule` schema to specify the shell used for executing commands, defaulting to 'bash'.
- Enhanced the `HandleSchedules` component to include the new `shellType` field, allowing users to select the shell type when creating or updating schedules.
- Updated the `runCommand` utility to utilize the selected shell type when executing commands, improving flexibility in command execution.
- Refactored the API to accommodate the new `shellType` in schedule creation and updates, ensuring proper handling of the new field.
This commit is contained in:
Mauricio Siu 2025-05-02 15:56:40 -06:00
parent f2bb01c800
commit 2c90103823
11 changed files with 5766 additions and 239 deletions

View File

@ -13,7 +13,7 @@ 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 } from "lucide-react";
import { Clock, Terminal, Info, PlusCircle, PenBoxIcon } from "lucide-react";
import {
Select,
SelectContent,
@ -28,6 +28,15 @@ import {
TooltipTrigger,
} from "@/components/ui/tooltip";
import { Switch } from "@/components/ui/switch";
import { useEffect, useState } from "react";
import {
Dialog,
DialogContent,
DialogHeader,
DialogTitle,
DialogTrigger,
} from "@/components/ui/dialog";
import { toast } from "sonner";
const commonCronExpressions = [
{ label: "Every minute", value: "* * * * *" },
@ -47,26 +56,16 @@ const formSchema = z.object({
});
interface Props {
applicationId: string;
onSuccess?: () => void;
defaultValues?: {
name: string;
cronExpression: string;
command: string;
};
applicationId?: string;
scheduleId?: string;
}
export const HandleSchedules = ({
applicationId,
onSuccess,
defaultValues,
scheduleId,
}: Props) => {
const utils = api.useContext();
export const HandleSchedules = ({ applicationId, scheduleId }: Props) => {
const [isOpen, setIsOpen] = useState(false);
const utils = api.useUtils();
const form = useForm<z.infer<typeof formSchema>>({
resolver: zodResolver(formSchema),
defaultValues: defaultValues || {
defaultValues: {
name: "",
cronExpression: "",
command: "",
@ -74,166 +73,198 @@ export const HandleSchedules = ({
},
});
const { mutate: createSchedule, isLoading: isCreating } =
api.schedule.create.useMutation({
onSuccess: () => {
utils.schedule.list.invalidate({ applicationId });
form.reset();
onSuccess?.();
},
});
const { data: schedule } = api.schedule.one.useQuery(
{ scheduleId: scheduleId || "" },
{ enabled: !!scheduleId },
);
const { mutate: updateSchedule, isLoading: isUpdating } =
api.schedule.update.useMutation({
onSuccess: () => {
utils.schedule.list.invalidate({ applicationId });
onSuccess?.();
},
});
const isLoading = isCreating || isUpdating;
const onSubmit = (values: z.infer<typeof formSchema>) => {
useEffect(() => {
if (scheduleId) {
updateSchedule({
...values,
scheduleId,
applicationId,
});
} else {
createSchedule({
...values,
applicationId,
form.reset({
name: schedule?.name,
cronExpression: schedule?.cronExpression,
command: schedule?.command,
enabled: schedule?.enabled,
});
}
}, [form, form.reset, schedule]);
const { mutateAsync, isLoading } = scheduleId
? api.schedule.update.useMutation()
: api.schedule.create.useMutation();
const onSubmit = async (values: z.infer<typeof formSchema>) => {
await mutateAsync({
...values,
...(scheduleId && { scheduleId }),
...(applicationId && { applicationId }),
})
.then(() => {
toast.success(
`Schedule ${scheduleId ? "updated" : "created"} successfully`,
);
utils.schedule.list.invalidate({ applicationId });
setIsOpen(false);
})
.catch((error) => {
toast.error(
error instanceof Error ? error.message : "An unknown error occurred",
);
});
};
return (
<Form {...form}>
<form onSubmit={form.handleSubmit(onSubmit)} className="space-y-6">
<FormField
control={form.control}
name="name"
render={({ field }) => (
<FormItem>
<FormLabel className="flex items-center gap-2">
<Clock className="w-4 h-4" />
Task Name
</FormLabel>
<FormControl>
<Input placeholder="Daily Database Backup" {...field} />
</FormControl>
<FormDescription>
A descriptive name for your scheduled task
</FormDescription>
<FormMessage />
</FormItem>
)}
/>
<Dialog open={isOpen} onOpenChange={setIsOpen}>
<DialogTrigger asChild>
{scheduleId ? (
<Button
variant="ghost"
size="icon"
className="group hover:bg-blue-500/10 "
>
<PenBoxIcon className="size-3.5 text-primary group-hover:text-blue-500" />
</Button>
) : (
<Button>
<PlusCircle className="w-4 h-4" />
Add Schedule
</Button>
)}
</DialogTrigger>
<DialogContent>
{scheduleId}
<DialogHeader>
<DialogTitle>{scheduleId ? "Edit" : "Create"} Schedule</DialogTitle>
</DialogHeader>
<Form {...form}>
<form onSubmit={form.handleSubmit(onSubmit)} className="space-y-6">
<FormField
control={form.control}
name="name"
render={({ field }) => (
<FormItem>
<FormLabel className="flex items-center gap-2">
<Clock className="w-4 h-4" />
Task Name
</FormLabel>
<FormControl>
<Input placeholder="Daily Database Backup" {...field} />
</FormControl>
<FormDescription>
A descriptive name for your scheduled task
</FormDescription>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="cronExpression"
render={({ field }) => (
<FormItem>
<FormLabel className="flex items-center gap-2">
<Clock className="w-4 h-4" />
Schedule
<TooltipProvider>
<Tooltip>
<TooltipTrigger asChild>
<Info className="w-4 h-4 text-muted-foreground cursor-help" />
</TooltipTrigger>
<TooltipContent>
<p>
Cron expression format: minute hour day month weekday
</p>
<p>Example: 0 0 * * * (daily at midnight)</p>
</TooltipContent>
</Tooltip>
</TooltipProvider>
</FormLabel>
<Select
onValueChange={(value) => field.onChange(value)}
value={field.value}
>
<FormControl>
<SelectTrigger>
<SelectValue placeholder="Select or type a cron expression" />
</SelectTrigger>
</FormControl>
<SelectContent>
{commonCronExpressions.map((expr) => (
<SelectItem key={expr.value} value={expr.value}>
{expr.label} ({expr.value})
</SelectItem>
))}
</SelectContent>
</Select>
<FormControl>
<Input
placeholder="Custom cron expression (e.g., 0 0 * * *)"
{...field}
className="mt-2"
/>
</FormControl>
<FormDescription>
Choose a predefined schedule or enter a custom cron expression
</FormDescription>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="cronExpression"
render={({ field }) => (
<FormItem>
<FormLabel className="flex items-center gap-2">
<Clock className="w-4 h-4" />
Schedule
<TooltipProvider>
<Tooltip>
<TooltipTrigger asChild>
<Info className="w-4 h-4 text-muted-foreground cursor-help" />
</TooltipTrigger>
<TooltipContent>
<p>
Cron expression format: minute hour day month
weekday
</p>
<p>Example: 0 0 * * * (daily at midnight)</p>
</TooltipContent>
</Tooltip>
</TooltipProvider>
</FormLabel>
<Select
onValueChange={(value) => field.onChange(value)}
value={field.value}
>
<FormControl>
<SelectTrigger>
<SelectValue placeholder="Select or type a cron expression" />
</SelectTrigger>
</FormControl>
<SelectContent>
{commonCronExpressions.map((expr) => (
<SelectItem key={expr.value} value={expr.value}>
{expr.label} ({expr.value})
</SelectItem>
))}
</SelectContent>
</Select>
<FormControl>
<Input
placeholder="Custom cron expression (e.g., 0 0 * * *)"
{...field}
className="mt-2"
/>
</FormControl>
<FormDescription>
Choose a predefined schedule or enter a custom cron
expression
</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>
)}
/>
<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>
)}
/>
<FormField
control={form.control}
name="enabled"
render={({ field }) => (
<FormItem>
<FormLabel className="flex items-center gap-2">
<Switch
checked={field.value}
onCheckedChange={field.onChange}
/>
Enabled
</FormLabel>
</FormItem>
)}
/>
<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>
</form>
</Form>
<FormField
control={form.control}
name="enabled"
render={({ field }) => (
<FormItem>
<FormLabel className="flex items-center gap-2">
<Switch
checked={field.value}
onCheckedChange={field.onChange}
/>
Enabled
</FormLabel>
</FormItem>
)}
/>
<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>
</form>
</Form>
</DialogContent>
</Dialog>
);
};

View File

@ -1,11 +1,4 @@
import { Button } from "@/components/ui/button";
import {
Dialog,
DialogContent,
DialogHeader,
DialogTitle,
DialogTrigger,
} from "@/components/ui/dialog";
import {
Table,
TableBody,
@ -15,9 +8,8 @@ import {
TableRow,
} from "@/components/ui/table";
import { api } from "@/utils/api";
import { useState } from "react";
import { HandleSchedules } from "./handle-schedules";
import { PlusCircle, Clock, Terminal, Trash2, Edit2 } from "lucide-react";
import { Clock, Terminal, Trash2 } from "lucide-react";
import {
Card,
CardContent,
@ -34,14 +26,6 @@ interface Props {
}
export const ShowSchedules = ({ applicationId }: Props) => {
const [isOpen, setIsOpen] = useState(false);
const [editingSchedule, setEditingSchedule] = useState<{
scheduleId: string;
name: string;
cronExpression: string;
command: string;
} | null>(null);
const { data: schedules } = api.schedule.list.useQuery({
applicationId,
});
@ -60,11 +44,6 @@ export const ShowSchedules = ({ applicationId }: Props) => {
const utils = api.useContext();
const onClose = () => {
setIsOpen(false);
setEditingSchedule(null);
};
return (
<Card className="border px-4 shadow-none bg-transparent">
<CardHeader className="px-0">
@ -78,27 +57,7 @@ export const ShowSchedules = ({ applicationId }: Props) => {
</CardDescription>
</div>
<Dialog open={isOpen} onOpenChange={setIsOpen}>
<DialogTrigger asChild>
<Button className="gap-2">
<PlusCircle className="w-4 h-4" />
New Schedule
</Button>
</DialogTrigger>
<DialogContent>
<DialogHeader>
<DialogTitle>
{editingSchedule ? "Edit" : "Create"} Schedule
</DialogTitle>
</DialogHeader>
<HandleSchedules
applicationId={applicationId}
onSuccess={onClose}
defaultValues={editingSchedule || undefined}
scheduleId={editingSchedule?.scheduleId}
/>
</DialogContent>
</Dialog>
<HandleSchedules applicationId={applicationId} />
</div>
</CardHeader>
<CardContent className="px-0">
@ -146,7 +105,7 @@ export const ShowSchedules = ({ applicationId }: Props) => {
<TableCell className="text-right">
<div className="flex justify-end gap-2">
<ShowSchedulesLogs
deployments={deployments}
deployments={deployments || []}
serverId={application.serverId || undefined}
/>
<Button
@ -170,15 +129,12 @@ export const ShowSchedules = ({ applicationId }: Props) => {
>
Run Manual Schedule
</Button>
<Button
onClick={() => {
setEditingSchedule(schedule);
setIsOpen(true);
}}
>
<Edit2 className="w-4 h-4" />
<span className="sr-only">Edit</span>
</Button>
<HandleSchedules
scheduleId={schedule.scheduleId}
applicationId={applicationId}
/>
<Button
variant="ghost"
size="sm"

View File

@ -0,0 +1,2 @@
CREATE TYPE "public"."shellType" AS ENUM('bash', 'sh');--> statement-breakpoint
ALTER TABLE "schedule" ADD COLUMN "shellType" "shellType" DEFAULT 'bash' NOT NULL;

File diff suppressed because it is too large Load Diff

View File

@ -645,6 +645,13 @@
"when": 1746180131377,
"tag": "0091_amused_warlock",
"breakpoints": true
},
{
"idx": 92,
"version": "7",
"when": 1746221961240,
"tag": "0092_safe_scarlet_witch",
"breakpoints": true
}
]
}

View File

@ -104,7 +104,7 @@
"dockerode": "4.0.2",
"dotenv": "16.4.5",
"drizzle-orm": "^0.39.1",
"drizzle-zod": "0.5.1",
"drizzle-zod": "0.7.1",
"fancy-ansi": "^0.1.3",
"hi-base32": "^0.5.1",
"i18next": "^23.16.4",

View File

@ -3,6 +3,7 @@ import { z } from "zod";
import {
createScheduleSchema,
schedules,
updateScheduleSchema,
} from "@dokploy/server/db/schema/schedule";
import { desc, eq } from "drizzle-orm";
import { createTRPCRouter, protectedProcedure } from "../trpc";
@ -13,15 +14,16 @@ export const scheduleRouter = createTRPCRouter({
create: protectedProcedure
.input(createScheduleSchema)
.mutation(async ({ ctx, input }) => {
const { scheduleId, ...rest } = input;
const [schedule] = await ctx.db
.insert(schedules)
.values(input)
.values(rest)
.returning();
return schedule;
}),
update: protectedProcedure
.input(createScheduleSchema.extend({ scheduleId: z.string() }))
.input(updateScheduleSchema)
.mutation(async ({ ctx, input }) => {
const { scheduleId, ...rest } = input;
const [schedule] = await ctx.db

View File

@ -54,7 +54,7 @@
"dockerode": "4.0.2",
"dotenv": "16.4.5",
"drizzle-orm": "^0.39.1",
"drizzle-zod": "0.5.1",
"drizzle-zod": "0.7.1",
"hi-base32": "^0.5.1",
"js-yaml": "4.1.0",
"lodash": "4.17.21",

View File

@ -1,12 +1,14 @@
import { relations } from "drizzle-orm";
import { boolean, pgTable, text } from "drizzle-orm/pg-core";
import { createInsertSchema } from "drizzle-zod";
import { boolean, pgEnum, pgTable, text } from "drizzle-orm/pg-core";
import { createInsertSchema, createUpdateSchema } from "drizzle-zod";
import { nanoid } from "nanoid";
import { z } from "zod";
import { applications } from "./application";
import { deployments } from "./deployment";
import { generateAppName } from "./utils";
export const shellTypes = pgEnum("shellType", ["bash", "sh"]);
export const schedules = pgTable("schedule", {
scheduleId: text("scheduleId")
.notNull()
@ -17,6 +19,7 @@ export const schedules = pgTable("schedule", {
appName: text("appName")
.notNull()
.$defaultFn(() => generateAppName("schedule")),
shellType: shellTypes("shellType").notNull().default("bash"),
command: text("command").notNull(),
applicationId: text("applicationId")
.notNull()
@ -45,3 +48,7 @@ export const createScheduleSchema = createInsertSchema(schedules, {
command: z.string().min(1),
applicationId: z.string().min(1),
});
export const updateScheduleSchema = createUpdateSchema(schedules).extend({
scheduleId: z.string().min(1),
});

View File

@ -19,7 +19,8 @@ export const scheduleJob = (schedule: Schedule) => {
};
export const runCommand = async (scheduleId: string) => {
const { application, command } = await findScheduleById(scheduleId);
const { application, command, shellType } =
await findScheduleById(scheduleId);
const isServer = !!application.serverId;
@ -42,7 +43,8 @@ export const runCommand = async (scheduleId: string) => {
application.serverId,
`
set -e
docker exec ${containerId} sh -c "${command}" || {
echo "Running command: docker exec ${containerId} ${shellType} -c \"${command}\"" >> ${deployment.logPath};
docker exec ${containerId} ${shellType} -c "${command}" || {
echo "❌ Command failed" >> ${deployment.logPath};
exit 1;
}
@ -56,10 +58,12 @@ export const runCommand = async (scheduleId: string) => {
const writeStream = createWriteStream(deployment.logPath, { flags: "a" });
try {
writeStream.write(`${command}\n`);
writeStream.write(
`docker exec ${containerId} ${shellType} -c "${command}"\n`,
);
await spawnAsync(
"docker",
["exec", containerId, "sh", "-c", command],
["exec", containerId, shellType, "-c", command],
(data) => {
if (writeStream.writable) {
writeStream.write(data);

View File

@ -302,8 +302,8 @@ importers:
specifier: ^0.39.1
version: 0.39.1(@opentelemetry/api@1.9.0)(@types/react@18.3.5)(kysely@0.27.6)(postgres@3.4.4)(react@18.2.0)(sqlite3@5.1.7)
drizzle-zod:
specifier: 0.5.1
version: 0.5.1(drizzle-orm@0.39.1(@opentelemetry/api@1.9.0)(@types/react@18.3.5)(kysely@0.27.6)(postgres@3.4.4)(react@18.2.0)(sqlite3@5.1.7))(zod@3.23.8)
specifier: 0.7.1
version: 0.7.1(drizzle-orm@0.39.1(@opentelemetry/api@1.9.0)(@types/react@18.3.5)(kysely@0.27.6)(postgres@3.4.4)(react@18.2.0)(sqlite3@5.1.7))(zod@3.23.8)
fancy-ansi:
specifier: ^0.1.3
version: 0.1.3
@ -664,8 +664,8 @@ importers:
specifier: ^0.39.1
version: 0.39.1(@opentelemetry/api@1.9.0)(@types/react@18.3.5)(kysely@0.27.6)(postgres@3.4.4)(react@18.2.0)(sqlite3@5.1.7)
drizzle-zod:
specifier: 0.5.1
version: 0.5.1(drizzle-orm@0.39.1(@opentelemetry/api@1.9.0)(@types/react@18.3.5)(kysely@0.27.6)(postgres@3.4.4)(react@18.2.0)(sqlite3@5.1.7))(zod@3.23.8)
specifier: 0.7.1
version: 0.7.1(drizzle-orm@0.39.1(@opentelemetry/api@1.9.0)(@types/react@18.3.5)(kysely@0.27.6)(postgres@3.4.4)(react@18.2.0)(sqlite3@5.1.7))(zod@3.23.8)
hi-base32:
specifier: ^0.5.1
version: 0.5.1
@ -4549,11 +4549,11 @@ packages:
sqlite3:
optional: true
drizzle-zod@0.5.1:
resolution: {integrity: sha512-C/8bvzUH/zSnVfwdSibOgFjLhtDtbKYmkbPbUCq46QZyZCH6kODIMSOgZ8R7rVjoI+tCj3k06MRJMDqsIeoS4A==}
drizzle-zod@0.7.1:
resolution: {integrity: sha512-nZzALOdz44/AL2U005UlmMqaQ1qe5JfanvLujiTHiiT8+vZJTBFhj3pY4Vk+L6UWyKFfNmLhk602Hn4kCTynKQ==}
peerDependencies:
drizzle-orm: '>=0.23.13'
zod: '*'
drizzle-orm: '>=0.36.0'
zod: '>=3.0.0'
eastasianwidth@0.2.0:
resolution: {integrity: sha512-I88TYZWc9XiYHRQ4/3c5rjjfgkjhLyW2luGIheGERbNQ6OY7yTybanSpDXZa8y7VUP9YmDcYa+eyq4ca7iLqWA==}
@ -11191,7 +11191,7 @@ snapshots:
react: 18.2.0
sqlite3: 5.1.7
drizzle-zod@0.5.1(drizzle-orm@0.39.1(@opentelemetry/api@1.9.0)(@types/react@18.3.5)(kysely@0.27.6)(postgres@3.4.4)(react@18.2.0)(sqlite3@5.1.7))(zod@3.23.8):
drizzle-zod@0.7.1(drizzle-orm@0.39.1(@opentelemetry/api@1.9.0)(@types/react@18.3.5)(kysely@0.27.6)(postgres@3.4.4)(react@18.2.0)(sqlite3@5.1.7))(zod@3.23.8):
dependencies:
drizzle-orm: 0.39.1(@opentelemetry/api@1.9.0)(@types/react@18.3.5)(kysely@0.27.6)(postgres@3.4.4)(react@18.2.0)(sqlite3@5.1.7)
zod: 3.23.8