From 4e0acb5856adff6095da5ae37d562a6a60c18d71 Mon Sep 17 00:00:00 2001 From: Nico <47644445+nicotsx@users.noreply.github.com> Date: Mon, 22 Dec 2025 19:50:26 +0100 Subject: [PATCH] feat(schedule-form): allow custom cron expression (#214) * feat(schedule-form): allow custom cron expression * refactor: guard against potential undefined cron expression --- app/client/components/cron-input.tsx | 89 +++++++++++++++++ .../components/create-schedule-form.tsx | 35 +++---- app/client/modules/backups/lib/cron-utils.ts | 96 +++++++++++++++++++ .../modules/backups/routes/backup-details.tsx | 1 + .../modules/backups/routes/create-backup.tsx | 1 + app/utils/utils.ts | 5 + 6 files changed, 211 insertions(+), 16 deletions(-) create mode 100644 app/client/components/cron-input.tsx create mode 100644 app/client/modules/backups/lib/cron-utils.ts diff --git a/app/client/components/cron-input.tsx b/app/client/components/cron-input.tsx new file mode 100644 index 0000000..eb268fd --- /dev/null +++ b/app/client/components/cron-input.tsx @@ -0,0 +1,89 @@ +import { CronExpressionParser } from "cron-parser"; +import { format } from "date-fns"; +import { AlertCircle, CheckCircle2 } from "lucide-react"; +import { useMemo } from "react"; +import { FormControl, FormDescription, FormItem, FormLabel, FormMessage } from "~/client/components/ui/form"; +import { Input } from "~/client/components/ui/input"; +import { cn } from "~/client/lib/utils"; + +interface CronInputProps { + value: string; + onChange: (value: string) => void; + error?: string; +} + +export function CronInput({ value, onChange, error }: CronInputProps) { + const { isValid, nextRuns, parseError } = useMemo(() => { + if (!value) { + return { isValid: false, nextRuns: [], parseError: null }; + } + + const parts = value.trim().split(/\s+/); + + if (parts.length !== 5) { + return { + isValid: false, + nextRuns: [], + parseError: "Expression must have exactly 5 fields (minute, hour, day, month, day-of-week)", + }; + } + + try { + const interval = CronExpressionParser.parse(value); + const runs: Date[] = []; + + for (let i = 0; i < 5; i++) { + runs.push(interval.next().toDate()); + } + + return { isValid: true, nextRuns: runs, parseError: null }; + } catch (e) { + return { isValid: false, nextRuns: [], parseError: (e as Error).message }; + } + }, [value]); + + return ( + + Cron expression + +
+ onChange(e.target.value)} + className={cn("font-mono", { "border-destructive": error || (value && !isValid) })} + /> +
+ {value && ( +
+ {isValid ? ( + + ) : ( + + )} +
+ )} +
+
+
+ + Standard cron format: minute hour day month day-of-week. + + {value && !isValid && parseError &&

{parseError}

} + {isValid && nextRuns.length > 0 && ( +
+

Next 5 executions:

+ +
+ )} + +
+ ); +} diff --git a/app/client/modules/backups/components/create-schedule-form.tsx b/app/client/modules/backups/components/create-schedule-form.tsx index adfaf82..1d94ff1 100644 --- a/app/client/modules/backups/components/create-schedule-form.tsx +++ b/app/client/modules/backups/components/create-schedule-form.tsx @@ -22,6 +22,8 @@ import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "~ import { Button } from "~/client/components/ui/button"; import { Textarea } from "~/client/components/ui/textarea"; import { VolumeFileBrowser } from "~/client/components/volume-file-browser"; +import { CronInput } from "~/client/components/cron-input"; +import { cronToFormValues } from "../lib/cron-utils"; import type { BackupSchedule, Volume } from "~/client/lib/types"; import { deepClean } from "~/utils/object"; @@ -36,6 +38,7 @@ const internalFormSchema = type({ dailyTime: "string?", weeklyDay: "string?", monthlyDays: "string[]?", + cronExpression: "string?", keepLast: "number?", keepHourly: "number?", keepDaily: "number?", @@ -80,17 +83,7 @@ const backupScheduleToFormValues = (schedule?: BackupSchedule): InternalFormValu return undefined; } - const parts = schedule.cronExpression.split(" "); - const [minutePart, hourPart, dayOfMonthPart, , dayOfWeekPart] = parts; - - const isHourly = hourPart === "*"; - const isMonthly = !isHourly && dayOfMonthPart !== "*" && dayOfWeekPart === "*"; - const isDaily = !isHourly && dayOfMonthPart === "*" && dayOfWeekPart === "*"; - - const frequency = isHourly ? "hourly" : isMonthly ? "monthly" : isDaily ? "daily" : "weekly"; - const dailyTime = isHourly ? undefined : `${hourPart.padStart(2, "0")}:${minutePart.padStart(2, "0")}`; - const weeklyDay = frequency === "weekly" ? dayOfWeekPart : undefined; - const monthlyDays = isMonthly ? dayOfMonthPart.split(",") : undefined; + const cronValues = cronToFormValues(schedule.cronExpression ?? "0 * * * *"); const patterns = schedule.includePatterns || []; const isGlobPattern = (p: string) => /[*?[\]]/.test(p); @@ -100,15 +93,12 @@ const backupScheduleToFormValues = (schedule?: BackupSchedule): InternalFormValu return { name: schedule.name, repositoryId: schedule.repositoryId, - frequency, - monthlyDays, - dailyTime, - weeklyDay, includePatterns: fileBrowserPaths.length > 0 ? fileBrowserPaths : undefined, includePatternsText: textPatterns.length > 0 ? textPatterns.join("\n") : undefined, excludePatternsText: schedule.excludePatterns?.join("\n") || undefined, excludeIfPresentText: schedule.excludeIfPresent?.join("\n") || undefined, oneFileSystem: schedule.oneFileSystem ?? false, + ...cronValues, ...schedule.retentionPolicy, }; }; @@ -126,6 +116,7 @@ export const CreateScheduleForm = ({ initialValues, formId, onSubmit, volume }: excludeIfPresentText, includePatternsText, includePatterns: fileBrowserPatterns, + cronExpression, ...rest } = data; const excludePatterns = excludePatternsText @@ -152,6 +143,7 @@ export const CreateScheduleForm = ({ initialValues, formId, onSubmit, volume }: onSubmit({ ...rest, + cronExpression, includePatterns: includePatterns.length > 0 ? includePatterns : [], excludePatterns, excludeIfPresent, @@ -255,6 +247,7 @@ export const CreateScheduleForm = ({ initialValues, formId, onSubmit, volume }: Daily Weekly Specific days + Custom (Cron) @@ -264,7 +257,17 @@ export const CreateScheduleForm = ({ initialValues, formId, onSubmit, volume }: )} /> - {frequency !== "hourly" && ( + {frequency === "cron" && ( + ( + + )} + /> + )} + + {frequency !== "hourly" && frequency !== "cron" && ( /^\d+$/.test(s); +const isWildcard = (s: string) => s === "*"; + +type Matcher = (parts: string[]) => CronFormValues | null; + +const matchers: Matcher[] = [ + // Hourly: 0 * * * * + (parts) => { + if (parts.length === 5 && parts[0] === "0" && parts.slice(1).every(isWildcard)) { + return { frequency: "hourly" }; + } + return null; + }, + + // Daily: mm hh * * * + (parts) => { + if ( + parts.length === 5 && + isSimpleNumber(parts[0]) && + isSimpleNumber(parts[1]) && + parts.slice(2).every(isWildcard) + ) { + return { + frequency: "daily", + dailyTime: `${parts[1].padStart(2, "0")}:${parts[0].padStart(2, "0")}`, + }; + } + return null; + }, + + // Weekly: mm hh * * d + (parts) => { + if ( + parts.length === 5 && + isSimpleNumber(parts[0]) && + isSimpleNumber(parts[1]) && + isWildcard(parts[2]) && + isWildcard(parts[3]) && + isSimpleNumber(parts[4]) + ) { + return { + frequency: "weekly", + dailyTime: `${parts[1].padStart(2, "0")}:${parts[0].padStart(2, "0")}`, + weeklyDay: parts[4], + }; + } + return null; + }, + + // Monthly: mm hh dd * * + (parts) => { + if ( + parts.length === 5 && + isSimpleNumber(parts[0]) && + isSimpleNumber(parts[1]) && + parts[2] !== "*" && + parts[2].split(",").every(isSimpleNumber) && + isWildcard(parts[3]) && + isWildcard(parts[4]) + ) { + return { + frequency: "monthly", + dailyTime: `${parts[1].padStart(2, "0")}:${parts[0].padStart(2, "0")}`, + monthlyDays: parts[2].split(","), + }; + } + return null; + }, +]; + +export const cronToFormValues = (cronExpression: string): CronFormValues => { + if (!cronExpression) { + return { frequency: "hourly" }; + } + + const normalized = cronExpression.trim().replace(/\s+/g, " "); + const parts = normalized.split(" "); + + for (const matcher of matchers) { + const result = matcher(parts); + if (result) return result; + } + + return { + frequency: "cron", + cronExpression: normalized, + }; +}; diff --git a/app/client/modules/backups/routes/backup-details.tsx b/app/client/modules/backups/routes/backup-details.tsx index 6081a7a..a475300 100644 --- a/app/client/modules/backups/routes/backup-details.tsx +++ b/app/client/modules/backups/routes/backup-details.tsx @@ -146,6 +146,7 @@ export default function ScheduleDetailsPage({ params, loaderData }: Route.Compon formValues.dailyTime, formValues.weeklyDay, formValues.monthlyDays, + formValues.cronExpression, ); const retentionPolicy: Record = {}; diff --git a/app/client/modules/backups/routes/create-backup.tsx b/app/client/modules/backups/routes/create-backup.tsx index 7b6484f..a7a12e1 100644 --- a/app/client/modules/backups/routes/create-backup.tsx +++ b/app/client/modules/backups/routes/create-backup.tsx @@ -76,6 +76,7 @@ export default function CreateBackup({ loaderData }: Route.ComponentProps) { formValues.dailyTime, formValues.weeklyDay, formValues.monthlyDays, + formValues.cronExpression, ); const retentionPolicy: Record = {}; diff --git a/app/utils/utils.ts b/app/utils/utils.ts index 9225e30..8c025c4 100644 --- a/app/utils/utils.ts +++ b/app/utils/utils.ts @@ -5,7 +5,12 @@ export const getCronExpression = ( dailyTime?: string, weeklyDay?: string, monthlyDays?: string[], + cronExpression?: string, ): string => { + if (frequency === "cron" && cronExpression) { + return cronExpression; + } + if (frequency === "hourly") { return "0 * * * *"; }