mirror of
https://github.com/nicotsx/zerobyte.git
synced 2025-12-23 21:47:47 -05:00
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:
89
app/client/components/cron-input.tsx
Normal file
89
app/client/components/cron-input.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@@ -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"
|
||||
|
||||
96
app/client/modules/backups/lib/cron-utils.ts
Normal file
96
app/client/modules/backups/lib/cron-utils.ts
Normal 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,
|
||||
};
|
||||
};
|
||||
@@ -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> = {};
|
||||
|
||||
@@ -76,6 +76,7 @@ export default function CreateBackup({ loaderData }: Route.ComponentProps) {
|
||||
formValues.dailyTime,
|
||||
formValues.weeklyDay,
|
||||
formValues.monthlyDays,
|
||||
formValues.cronExpression,
|
||||
);
|
||||
|
||||
const retentionPolicy: Record<string, number> = {};
|
||||
|
||||
@@ -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 * * * *";
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user