Add custom cron expression support for backup scheduling

Frontend changes:
- Add advanced mode toggle switch for cron expressions
- Show cron expression input with helpful examples when enabled
- Display format hints: "minute hour day month weekday"
- Provide common examples (daily, weekly, every 6 hours, etc.)
- Conditionally render simple or advanced scheduling UI
- Support switching between simple and advanced modes

Backend changes:
- Add cron_expression to schedule settings (SETTING_KEYS, DEFAULTS)
- Update get_schedule_settings to include cron_expression
- Update update_schedule_settings to handle cron_expression
- Extend _sync_periodic_task to parse and use cron expressions
- Parse 5-part cron format: minute hour day_of_month month_of_year day_of_week
- Create CrontabSchedule from cron expression or simple frequency
- Add validation and error handling for invalid cron expressions

This addresses maintainer feedback for "custom scheduler (cron style) for more control".
Users can now schedule backups with full cron flexibility beyond daily/weekly.
This commit is contained in:
Jim McBride 2025-12-09 07:55:47 -06:00
parent d718e5a142
commit 5fbcaa91e0
No known key found for this signature in database
GPG key ID: E02BFB7AB3C895D7
2 changed files with 161 additions and 64 deletions

View file

@ -15,6 +15,7 @@ SETTING_KEYS = {
"time": "backup_schedule_time",
"day_of_week": "backup_schedule_day_of_week",
"retention_count": "backup_retention_count",
"cron_expression": "backup_schedule_cron_expression",
}
DEFAULTS = {
@ -23,6 +24,7 @@ DEFAULTS = {
"time": "03:00",
"day_of_week": 0, # Sunday
"retention_count": 0,
"cron_expression": "",
}
@ -60,6 +62,7 @@ def get_schedule_settings() -> dict:
"time": _get_setting("time"),
"day_of_week": _get_setting("day_of_week"),
"retention_count": _get_setting("retention_count"),
"cron_expression": _get_setting("cron_expression"),
}
@ -88,7 +91,7 @@ def update_schedule_settings(data: dict) -> dict:
raise ValueError("retention_count must be >= 0")
# Update settings
for key in ("enabled", "frequency", "time", "day_of_week", "retention_count"):
for key in ("enabled", "frequency", "time", "day_of_week", "retention_count", "cron_expression"):
if key in data:
_set_setting(key, data[key])
@ -108,26 +111,48 @@ def _sync_periodic_task() -> None:
logger.info("Backup schedule disabled, removed periodic task")
return
# Parse time
hour, minute = settings["time"].split(":")
# Check if using cron expression (advanced mode)
if settings["cron_expression"]:
# Parse cron expression: "minute hour day month weekday"
try:
parts = settings["cron_expression"].split()
if len(parts) != 5:
raise ValueError("Cron expression must have 5 parts: minute hour day month weekday")
# Build crontab based on frequency
if settings["frequency"] == "daily":
crontab, _ = CrontabSchedule.objects.get_or_create(
minute=minute,
hour=hour,
day_of_week="*",
day_of_month="*",
month_of_year="*",
)
else: # weekly
crontab, _ = CrontabSchedule.objects.get_or_create(
minute=minute,
hour=hour,
day_of_week=str(settings["day_of_week"]),
day_of_month="*",
month_of_year="*",
)
minute, hour, day_of_month, month_of_year, day_of_week = parts
crontab, _ = CrontabSchedule.objects.get_or_create(
minute=minute,
hour=hour,
day_of_week=day_of_week,
day_of_month=day_of_month,
month_of_year=month_of_year,
)
except Exception as e:
logger.error(f"Invalid cron expression '{settings['cron_expression']}': {e}")
raise ValueError(f"Invalid cron expression: {e}")
else:
# Use simple frequency-based scheduling
# Parse time
hour, minute = settings["time"].split(":")
# Build crontab based on frequency
if settings["frequency"] == "daily":
crontab, _ = CrontabSchedule.objects.get_or_create(
minute=minute,
hour=hour,
day_of_week="*",
day_of_month="*",
month_of_year="*",
)
else: # weekly
crontab, _ = CrontabSchedule.objects.get_or_create(
minute=minute,
hour=hour,
day_of_week=str(settings["day_of_week"]),
day_of_month="*",
month_of_year="*",
)
# Create or update the periodic task
task, created = PeriodicTask.objects.update_or_create(

View file

@ -188,10 +188,12 @@ export default function BackupManager() {
time: '03:00',
day_of_week: 0,
retention_count: 0,
cron_expression: '',
});
const [scheduleLoading, setScheduleLoading] = useState(false);
const [scheduleSaving, setScheduleSaving] = useState(false);
const [scheduleChanged, setScheduleChanged] = useState(false);
const [advancedMode, setAdvancedMode] = useState(false);
// For 12-hour display mode
const [displayTime, setDisplayTime] = useState('3:00');
@ -299,17 +301,24 @@ export default function BackupManager() {
try {
const settings = await API.getBackupSchedule();
// Convert UTC time from backend to local time
const localTime = utcToLocal(settings.time);
// Check if using cron expression (advanced mode)
if (settings.cron_expression) {
setAdvancedMode(true);
setSchedule(settings);
} else {
// Convert UTC time from backend to local time
const localTime = utcToLocal(settings.time);
// Store with local time for display
setSchedule({ ...settings, time: localTime });
// Initialize 12-hour display values from the local time
const { time, period } = to12Hour(localTime);
setDisplayTime(time);
setTimePeriod(period);
}
// Store with local time for display
setSchedule({ ...settings, time: localTime });
setScheduleChanged(false);
// Initialize 12-hour display values from the local time
const { time, period } = to12Hour(localTime);
setDisplayTime(time);
setTimePeriod(period);
} catch (error) {
// Ignore errors on initial load - settings may not exist yet
} finally {
@ -350,15 +359,27 @@ export default function BackupManager() {
const handleSaveSchedule = async () => {
setScheduleSaving(true);
try {
// Convert local time to UTC before sending to backend
const utcTime = localToUtc(schedule.time);
const scheduleToSave = { ...schedule, time: utcTime };
let scheduleToSave;
if (advancedMode) {
// In advanced mode, send cron expression as-is
scheduleToSave = schedule;
} else {
// Convert local time to UTC before sending to backend
const utcTime = localToUtc(schedule.time);
scheduleToSave = { ...schedule, time: utcTime, cron_expression: '' };
}
const updated = await API.updateBackupSchedule(scheduleToSave);
// Convert UTC time from backend response back to local time
const localTime = utcToLocal(updated.time);
setSchedule({ ...updated, time: localTime });
if (advancedMode) {
setSchedule(updated);
} else {
// Convert UTC time from backend response back to local time
const localTime = utcToLocal(updated.time);
setSchedule({ ...updated, time: localTime });
}
setScheduleChanged(false);
notifications.show({
@ -509,17 +530,65 @@ export default function BackupManager() {
<Loader size="sm" />
) : (
<>
<Group grow align="flex-end">
<Select
label="Frequency"
value={schedule.frequency}
onChange={(value) => handleScheduleChange('frequency', value)}
data={[
{ value: 'daily', label: 'Daily' },
{ value: 'weekly', label: 'Weekly' },
]}
disabled={!schedule.enabled}
/>
<Switch
checked={advancedMode}
onChange={(e) => setAdvancedMode(e.currentTarget.checked)}
label="Advanced (Cron Expression)"
disabled={!schedule.enabled}
size="sm"
mb="xs"
/>
{advancedMode ? (
<>
<Stack gap="sm">
<TextInput
label="Cron Expression"
value={schedule.cron_expression}
onChange={(e) => handleScheduleChange('cron_expression', e.currentTarget.value)}
placeholder="0 3 * * *"
description="Format: minute hour day month weekday (e.g., '0 3 * * *' = 3:00 AM daily)"
disabled={!schedule.enabled}
/>
<Text size="xs" c="dimmed">
Examples: <br />
<code>0 3 * * *</code> - Every day at 3:00 AM<br />
<code>0 2 * * 0</code> - Every Sunday at 2:00 AM<br />
<code>0 */6 * * *</code> - Every 6 hours<br />
<code>30 14 1 * *</code> - 1st of every month at 2:30 PM
</Text>
</Stack>
<Group grow align="flex-end">
<NumberInput
label="Retention"
description="0 = keep all"
value={schedule.retention_count}
onChange={(value) => handleScheduleChange('retention_count', value || 0)}
min={0}
disabled={!schedule.enabled}
/>
<Button
onClick={handleSaveSchedule}
loading={scheduleSaving}
disabled={!scheduleChanged}
variant="default"
>
Save
</Button>
</Group>
</>
) : (
<Group grow align="flex-end">
<Select
label="Frequency"
value={schedule.frequency}
onChange={(value) => handleScheduleChange('frequency', value)}
data={[
{ value: 'daily', label: 'Daily' },
{ value: 'weekly', label: 'Weekly' },
]}
disabled={!schedule.enabled}
/>
{schedule.frequency === 'weekly' && (
<Select
label="Day"
@ -557,24 +626,27 @@ export default function BackupManager() {
disabled={!schedule.enabled}
/>
)}
<NumberInput
label="Retention"
description="0 = keep all"
value={schedule.retention_count}
onChange={(value) => handleScheduleChange('retention_count', value || 0)}
min={0}
disabled={!schedule.enabled}
/>
<Button
onClick={handleSaveSchedule}
loading={scheduleSaving}
disabled={!scheduleChanged}
variant="default"
>
Save
</Button>
</Group>
{schedule.enabled && schedule.time && (
<NumberInput
label="Retention"
description="0 = keep all"
value={schedule.retention_count}
onChange={(value) => handleScheduleChange('retention_count', value || 0)}
min={0}
disabled={!schedule.enabled}
/>
<Button
onClick={handleSaveSchedule}
loading={scheduleSaving}
disabled={!scheduleChanged}
variant="default"
>
Save
</Button>
</Group>
)}
{/* Timezone info - only show in simple mode */}
{!advancedMode && schedule.enabled && schedule.time && (
<Text size="xs" c="dimmed" mt="xs">
Timezone: {userTimezone} Backup will run at {schedule.time}
</Text>