diff --git a/apps/dokploy/components/dashboard/application/schedules/handle-schedules.tsx b/apps/dokploy/components/dashboard/application/schedules/handle-schedules.tsx index 8273d0e2be..967dc8fff0 100644 --- a/apps/dokploy/components/dashboard/application/schedules/handle-schedules.tsx +++ b/apps/dokploy/components/dashboard/application/schedules/handle-schedules.tsx @@ -60,6 +60,23 @@ export const commonCronExpressions = [ { label: "Custom", value: "custom" }, ]; +const commonTimezones = [ + { label: "UTC (Default)", value: "__UTC__" }, + { label: "America/Los_Angeles (PST/PDT)", value: "America/Los_Angeles" }, + { label: "America/New_York (EST/EDT)", value: "America/New_York" }, + { label: "America/Chicago (CST/CDT)", value: "America/Chicago" }, + { label: "America/Denver (MST/MDT)", value: "America/Denver" }, + { label: "Europe/London (GMT/BST)", value: "Europe/London" }, + { label: "Europe/Paris (CET/CEST)", value: "Europe/Paris" }, + { label: "Europe/Berlin (CET/CEST)", value: "Europe/Berlin" }, + { label: "Asia/Tokyo (JST)", value: "Asia/Tokyo" }, + { label: "Asia/Shanghai (CST)", value: "Asia/Shanghai" }, + { label: "Asia/Kolkata (IST)", value: "Asia/Kolkata" }, + { label: "Asia/Dubai (GST)", value: "Asia/Dubai" }, + { label: "Australia/Sydney (AEST/AEDT)", value: "Australia/Sydney" }, + { label: "Australia/Melbourne (AEST/AEDT)", value: "Australia/Melbourne" }, +]; + const formSchema = z .object({ name: z.string().min(1, "Name is required"), @@ -75,6 +92,7 @@ const formSchema = z "dokploy-server", ]), script: z.string(), + timezone: z.string().nullable().optional(), }) .superRefine((data, ctx) => { if (data.scheduleType === "compose" && !data.serviceName) { @@ -213,6 +231,7 @@ export const HandleSchedules = ({ id, scheduleId, scheduleType }: Props) => { serviceName: "", scheduleType: scheduleType || "application", script: "", + timezone: null, }, }); @@ -251,6 +270,7 @@ export const HandleSchedules = ({ id, scheduleId, scheduleType }: Props) => { serviceName: schedule.serviceName || "", scheduleType: schedule.scheduleType, script: schedule.script || "", + timezone: schedule.timezone || null, }); } }, [form, schedule, scheduleId]); @@ -464,6 +484,58 @@ export const HandleSchedules = ({ id, scheduleId, scheduleType }: Props) => { formControl={form.control} /> + ( + + + Timezone + + + + + + +

+ Select the timezone for this schedule. If not + specified, UTC will be used. +

+

+ Example: Setting "9:00 AM" with + "America/Los_Angeles" will run at 9 AM Pacific Time. +

+
+
+
+
+ + + The timezone for the cron schedule. Leave as UTC if unsure. + + +
+ )} + /> + {(scheduleTypeForm === "application" || scheduleTypeForm === "compose") && ( <> diff --git a/apps/dokploy/components/dashboard/application/schedules/show-schedules.tsx b/apps/dokploy/components/dashboard/application/schedules/show-schedules.tsx index 26bfa94219..898e3ec848 100644 --- a/apps/dokploy/components/dashboard/application/schedules/show-schedules.tsx +++ b/apps/dokploy/components/dashboard/application/schedules/show-schedules.tsx @@ -132,6 +132,19 @@ export const ShowSchedules = ({ id, scheduleType = "application" }: Props) => { > Cron: {schedule.cronExpression} + {schedule.timezone && ( + <> + + • + + + TZ: {schedule.timezone} + + + )} {schedule.scheduleType !== "server" && schedule.scheduleType !== "dokploy-server" && ( <> diff --git a/apps/dokploy/drizzle/0114_add_timezone_to_schedule.sql b/apps/dokploy/drizzle/0114_add_timezone_to_schedule.sql new file mode 100644 index 0000000000..9613b07b87 --- /dev/null +++ b/apps/dokploy/drizzle/0114_add_timezone_to_schedule.sql @@ -0,0 +1,2 @@ +ALTER TABLE "schedule" ADD COLUMN "timezone" text; + diff --git a/apps/dokploy/server/api/routers/schedule.ts b/apps/dokploy/server/api/routers/schedule.ts index e5c9c2c129..3bf284b763 100644 --- a/apps/dokploy/server/api/routers/schedule.ts +++ b/apps/dokploy/server/api/routers/schedule.ts @@ -30,6 +30,7 @@ export const scheduleRouter = createTRPCRouter({ scheduleId: newSchedule.scheduleId, type: "schedule", cronSchedule: newSchedule.cronExpression, + timezone: newSchedule.timezone, }); } else { scheduleJob(newSchedule); @@ -49,6 +50,7 @@ export const scheduleRouter = createTRPCRouter({ scheduleId: updatedSchedule.scheduleId, type: "schedule", cronSchedule: updatedSchedule.cronExpression, + timezone: updatedSchedule.timezone, }); } else { await removeJob({ diff --git a/apps/dokploy/server/utils/backup.ts b/apps/dokploy/server/utils/backup.ts index 9263ecba8c..588f6636b8 100644 --- a/apps/dokploy/server/utils/backup.ts +++ b/apps/dokploy/server/utils/backup.ts @@ -19,6 +19,7 @@ type QueueJob = type: "schedule"; cronSchedule: string; scheduleId: string; + timezone?: string | null; } | { type: "volume-backup"; diff --git a/apps/schedules/src/queue.ts b/apps/schedules/src/queue.ts index ebc9fa32ae..3c9fa092fd 100644 --- a/apps/schedules/src/queue.ts +++ b/apps/schedules/src/queue.ts @@ -40,6 +40,7 @@ export const scheduleJob = (job: QueueJob) => { jobQueue.add(job.scheduleId, job, { repeat: { pattern: job.cronSchedule, + tz: job.timezone || "UTC", }, }); } else if (job.type === "volume-backup") { diff --git a/apps/schedules/src/schema.ts b/apps/schedules/src/schema.ts index ac01e50dee..cbdd32d417 100644 --- a/apps/schedules/src/schema.ts +++ b/apps/schedules/src/schema.ts @@ -15,6 +15,7 @@ export const jobQueueSchema = z.discriminatedUnion("type", [ cronSchedule: z.string(), type: z.literal("schedule"), scheduleId: z.string(), + timezone: z.string().nullable().optional(), }), z.object({ cronSchedule: z.string(), diff --git a/apps/schedules/src/utils.ts b/apps/schedules/src/utils.ts index 4d255a2b78..32c5c134d4 100644 --- a/apps/schedules/src/utils.ts +++ b/apps/schedules/src/utils.ts @@ -191,6 +191,7 @@ export const initializeJobs = async () => { scheduleId: schedule.scheduleId, type: "schedule", cronSchedule: schedule.cronExpression, + timezone: schedule.timezone, }); } logger.info( diff --git a/packages/server/package.json b/packages/server/package.json index 4d0f2e8045..9c1618bfee 100644 --- a/packages/server/package.json +++ b/packages/server/package.json @@ -1,21 +1,32 @@ { "name": "@dokploy/server", "version": "1.0.0", - "main": "./src/index.ts", + "main": "./dist/index.js", "type": "module", "exports": { - ".": "./src/index.ts", + ".": { + "import": "./dist/index.js", + "require": "./dist/index.cjs.js" + }, "./db": { - "import": "./src/db/index.ts", + "import": "./dist/db/index.js", "require": "./dist/db/index.cjs.js" }, - "./setup/*": { - "import": "./src/setup/*.ts", - "require": "./dist/setup/index.cjs.js" + "./*": { + "import": "./dist/*", + "require": "./dist/*.cjs" + }, + "./dist": { + "import": "./dist/index.js", + "require": "./dist/index.cjs.js" + }, + "./dist/db": { + "import": "./dist/db/index.js", + "require": "./dist/db/index.cjs.js" }, - "./constants": { - "import": "./src/constants/index.ts", - "require": "./dist/constants.cjs.js" + "./dist/db/schema": { + "import": "./dist/db/schema/index.js", + "require": "./dist/db/schema/index.cjs.js" } }, "scripts": { diff --git a/packages/server/src/db/schema/schedule.ts b/packages/server/src/db/schema/schedule.ts index 0f078ae790..e27baf257b 100644 --- a/packages/server/src/db/schema/schedule.ts +++ b/packages/server/src/db/schema/schedule.ts @@ -49,6 +49,7 @@ export const schedules = pgTable("schedule", { onDelete: "cascade", }), enabled: boolean("enabled").notNull().default(true), + timezone: text("timezone"), createdAt: text("createdAt") .notNull() .$defaultFn(() => new Date().toISOString()), @@ -76,7 +77,28 @@ export const schedulesRelations = relations(schedules, ({ one, many }) => ({ deployments: many(deployments), })); -export const createScheduleSchema = createInsertSchema(schedules); +export const createScheduleSchema = createInsertSchema(schedules).extend({ + timezone: z + .string() + .nullable() + .optional() + .refine( + (val) => { + if (!val || val === "") return true; + try { + // Validate IANA timezone string + Intl.DateTimeFormat(undefined, { timeZone: val }); + return true; + } catch { + return false; + } + }, + { + message: + "Invalid timezone. Must be a valid IANA timezone identifier (e.g., America/Los_Angeles, Europe/London)", + }, + ), +}); export const updateScheduleSchema = createScheduleSchema.extend({ scheduleId: z.string().min(1), diff --git a/packages/server/src/utils/schedules/utils.ts b/packages/server/src/utils/schedules/utils.ts index 6ccf796c61..f0be586751 100644 --- a/packages/server/src/utils/schedules/utils.ts +++ b/packages/server/src/utils/schedules/utils.ts @@ -8,17 +8,199 @@ import { updateDeploymentStatus, } from "@dokploy/server/services/deployment"; import { findScheduleById } from "@dokploy/server/services/schedule"; -import { scheduledJobs, scheduleJob as scheduleJobNode } from "node-schedule"; +import { + RecurrenceRule, + scheduledJobs, + scheduleJob as scheduleJobNode, +} from "node-schedule"; import { getComposeContainer, getServiceContainer } from "../docker/utils"; import { execAsyncRemote } from "../process/execAsync"; import { spawnAsync } from "../process/spawnAsync"; +/** + * Parse cron expression to RecurrenceRule + * Handles common patterns: specific values, intervals (e.g., star-slash-5), ranges, and lists + */ +const parseCronToRule = (cronExpression: string): RecurrenceRule => { + const rule = new RecurrenceRule(); + const parts = cronExpression.trim().split(/\s+/); + + if (parts.length < 5) { + throw new Error(`Invalid cron expression: ${cronExpression}`); + } + + // Extract parts with type safety + const minutePart = parts[0]; + const hourPart = parts[1]; + const datePart = parts[2]; + const monthPart = parts[3]; + const dayOfWeekPart = parts[4]; + + // Parse minute (0-59) + if (minutePart && minutePart !== "*") { + if (minutePart.includes("*/")) { + const splitResult = minutePart.split("*/"); + const interval = splitResult[1] ? Number.parseInt(splitResult[1], 10) : 1; + rule.minute = Array.from( + { length: Math.floor(60 / interval) }, + (_, i) => i * interval, + ); + } else if (minutePart.includes(",")) { + rule.minute = minutePart + .split(",") + .map((v) => Number.parseInt(v.trim(), 10)); + } else if (minutePart.includes("-")) { + const range = minutePart + .split("-") + .map((v) => Number.parseInt(v.trim(), 10)); + const start = range[0] ?? 0; + const end = range[1] ?? 59; + rule.minute = Array.from( + { length: end - start + 1 }, + (_, i) => start + i, + ); + } else { + rule.minute = Number.parseInt(minutePart, 10); + } + } + + // Parse hour (0-23) + if (hourPart && hourPart !== "*") { + if (hourPart.includes("*/")) { + const splitResult = hourPart.split("*/"); + const interval = splitResult[1] ? Number.parseInt(splitResult[1], 10) : 1; + rule.hour = Array.from( + { length: Math.floor(24 / interval) }, + (_, i) => i * interval, + ); + } else if (hourPart.includes(",")) { + rule.hour = hourPart.split(",").map((v) => Number.parseInt(v.trim(), 10)); + } else if (hourPart.includes("-")) { + const range = hourPart + .split("-") + .map((v) => Number.parseInt(v.trim(), 10)); + const start = range[0] ?? 0; + const end = range[1] ?? 23; + rule.hour = Array.from({ length: end - start + 1 }, (_, i) => start + i); + } else { + rule.hour = Number.parseInt(hourPart, 10); + } + } + + // Parse day of month (1-31) + if (datePart && datePart !== "*") { + if (datePart.includes("*/")) { + const splitResult = datePart.split("*/"); + const interval = splitResult[1] ? Number.parseInt(splitResult[1], 10) : 1; + rule.date = Array.from( + { length: Math.floor(31 / interval) }, + (_, i) => (i + 1) * interval, + ); + } else if (datePart.includes(",")) { + rule.date = datePart.split(",").map((v) => Number.parseInt(v.trim(), 10)); + } else if (datePart.includes("-")) { + const range = datePart + .split("-") + .map((v) => Number.parseInt(v.trim(), 10)); + const start = range[0] ?? 1; + const end = range[1] ?? 31; + rule.date = Array.from({ length: end - start + 1 }, (_, i) => start + i); + } else { + rule.date = Number.parseInt(datePart, 10); + } + } + + // Parse month (1-12, node-schedule uses 0-11) + if (monthPart && monthPart !== "*") { + if (monthPart.includes("*/")) { + const splitResult = monthPart.split("*/"); + const interval = splitResult[1] ? Number.parseInt(splitResult[1], 10) : 1; + rule.month = Array.from( + { length: Math.floor(12 / interval) }, + (_, i) => i * interval, + ); + } else if (monthPart.includes(",")) { + rule.month = monthPart + .split(",") + .map((v) => Number.parseInt(v.trim(), 10) - 1); + } else if (monthPart.includes("-")) { + const range = monthPart + .split("-") + .map((v) => Number.parseInt(v.trim(), 10)); + const start = range[0] ?? 1; + const end = range[1] ?? 12; + rule.month = Array.from( + { length: end - start + 1 }, + (_, i) => start + i - 1, + ); + } else { + rule.month = Number.parseInt(monthPart, 10) - 1; + } + } + + // Parse day of week (0-6, where 0 = Sunday) + if (dayOfWeekPart && dayOfWeekPart !== "*") { + if (dayOfWeekPart.includes("*/")) { + const splitResult = dayOfWeekPart.split("*/"); + const interval = splitResult[1] ? Number.parseInt(splitResult[1], 10) : 1; + rule.dayOfWeek = Array.from( + { length: Math.floor(7 / interval) }, + (_, i) => i * interval, + ); + } else if (dayOfWeekPart.includes(",")) { + rule.dayOfWeek = dayOfWeekPart + .split(",") + .map((v) => Number.parseInt(v.trim(), 10)); + } else if (dayOfWeekPart.includes("-")) { + const range = dayOfWeekPart + .split("-") + .map((v) => Number.parseInt(v.trim(), 10)); + const start = range[0] ?? 0; + const end = range[1] ?? 6; + rule.dayOfWeek = Array.from( + { length: end - start + 1 }, + (_, i) => start + i, + ); + } else { + rule.dayOfWeek = Number.parseInt(dayOfWeekPart, 10); + } + } + + return rule; +}; + export const scheduleJob = (schedule: Schedule) => { - const { cronExpression, scheduleId } = schedule; + const { cronExpression, scheduleId, timezone } = schedule; - scheduleJobNode(scheduleId, cronExpression, async () => { - await runCommand(scheduleId); - }); + console.log( + `[Schedule] Scheduling job: ${scheduleId}, cron: ${cronExpression}, timezone: ${timezone || "UTC (default)"}`, + ); + + if (timezone) { + // Use RecurrenceRule for timezone support + try { + const rule = parseCronToRule(cronExpression); + rule.tz = timezone; + + scheduleJobNode(scheduleId, rule, async () => { + await runCommand(scheduleId); + }); + } catch (error) { + console.error( + `[Schedule] Failed to parse cron expression with timezone, falling back to UTC: ${cronExpression}`, + error, + ); + // Fallback to UTC if parsing fails + scheduleJobNode(scheduleId, cronExpression, async () => { + await runCommand(scheduleId); + }); + } + } else { + // No timezone - use string cron (UTC default) + scheduleJobNode(scheduleId, cronExpression, async () => { + await runCommand(scheduleId); + }); + } }; export const removeScheduleJob = (scheduleId: string) => {