feat(schedule-form): allow custom cron expression (#214)

* feat(schedule-form): allow custom cron expression

* refactor: guard against potential undefined cron expression
This commit is contained in:
Nico
2025-12-22 19:50:26 +01:00
committed by Nicolas Meienberger
parent 59e0dc0401
commit 4e0acb5856
6 changed files with 211 additions and 16 deletions

View File

@@ -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 (
<FormItem className="md:col-span-2">
<FormLabel>Cron expression</FormLabel>
<FormControl>
<div className="relative">
<Input
placeholder="* * * * *"
value={value}
onChange={(e) => onChange(e.target.value)}
className={cn("font-mono", { "border-destructive": error || (value && !isValid) })}
/>
<div className="absolute right-3 top-1/2 -translate-y-1/2">
{value && (
<div>
{isValid ? (
<CheckCircle2 className="h-4 w-4 text-green-500" />
) : (
<AlertCircle className="h-4 w-4 text-destructive" />
)}
</div>
)}
</div>
</div>
</FormControl>
<FormDescription>
Standard cron format: <code className="bg-muted px-1 rounded">minute hour day month day-of-week</code>.
</FormDescription>
{value && !isValid && parseError && <p className="text-xs text-destructive mt-1">{parseError}</p>}
{isValid && nextRuns.length > 0 && (
<div className="mt-2 p-3 rounded-md bg-muted/50 border border-border">
<p className="text-xs font-medium mb-2 text-muted-foreground uppercase tracking-wider">Next 5 executions:</p>
<ul className="space-y-1">
{nextRuns.map((date, i) => (
<li key={date.toISOString()} className="text-xs font-mono flex items-center gap-2">
<span className="text-muted-foreground w-4">{i + 1}.</span>
{format(date, "PPP p")}
</li>
))}
</ul>
</div>
)}
<FormMessage />
</FormItem>
);
}

View File

@@ -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 }:
<SelectItem value="daily">Daily</SelectItem>
<SelectItem value="weekly">Weekly</SelectItem>
<SelectItem value="monthly">Specific days</SelectItem>
<SelectItem value="cron">Custom (Cron)</SelectItem>
</SelectContent>
</Select>
</FormControl>
@@ -264,7 +257,17 @@ export const CreateScheduleForm = ({ initialValues, formId, onSubmit, volume }:
)}
/>
{frequency !== "hourly" && (
{frequency === "cron" && (
<FormField
control={form.control}
name="cronExpression"
render={({ field, fieldState }) => (
<CronInput value={field.value || ""} onChange={field.onChange} error={fieldState.error?.message} />
)}
/>
)}
{frequency !== "hourly" && frequency !== "cron" && (
<FormField
control={form.control}
name="dailyTime"

View File

@@ -0,0 +1,96 @@
export type CronFormValues = {
frequency: string;
dailyTime?: string;
weeklyDay?: string;
monthlyDays?: string[];
cronExpression?: string;
};
const isSimpleNumber = (s: string) => /^\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,
};
};

View File

@@ -146,6 +146,7 @@ export default function ScheduleDetailsPage({ params, loaderData }: Route.Compon
formValues.dailyTime,
formValues.weeklyDay,
formValues.monthlyDays,
formValues.cronExpression,
);
const retentionPolicy: Record<string, number> = {};

View File

@@ -76,6 +76,7 @@ export default function CreateBackup({ loaderData }: Route.ComponentProps) {
formValues.dailyTime,
formValues.weeklyDay,
formValues.monthlyDays,
formValues.cronExpression,
);
const retentionPolicy: Record<string, number> = {};

View File

@@ -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 * * * *";
}