Pick and show schedules as plain time instead of cron

Schedules were entered and displayed as six field cron expressions. A
picker now offers a frequency, a weekday or day of the month and a time,
and turns that into the expression the backend stores; tables and the
dashboard show wording like "Every day at 03:00". An expression the
picker cannot express stays editable as cron.

This also fixes a broken preset: the backend's cron dialect counts Sunday
as 1 and rejects 0, so the "every Sunday" choice in the application
backup form produced an invalid expression. The picker emits weekday
names, and a test pins the dialect down.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
Dennis Nemec
2026-09-03 21:47:56 +02:00
parent 1339ae864e
commit 6796634906
15 changed files with 513 additions and 62 deletions

View File

@ -31,3 +31,50 @@ fn due_when_a_fire_time_lies_between_last_run_and_now() {
assert!(!is_due(hourly, None, now + Duration::minutes(10), 300)); assert!(!is_due(hourly, None, now + Duration::minutes(10), 300));
assert!(!is_due("bad", None, now, 300)); assert!(!is_due("bad", None, now, 300));
} }
/// The UI offers simple choices ("every Sunday at 3:00") and turns them into these
/// expressions, so the dialect they have to match is pinned down here.
#[test]
fn documents_the_cron_dialect_the_ui_generates() {
let fires_at = |expr: &str, after: chrono::DateTime<Utc>| next_fire(expr, after).unwrap();
let monday = Utc.with_ymd_and_hms(2026, 9, 7, 0, 0, 0).unwrap(); // a Monday
// every day at 3:00
assert_eq!(
fires_at("0 0 3 * * *", monday),
Utc.with_ymd_and_hms(2026, 9, 7, 3, 0, 0).unwrap()
);
// every six hours, on the hour
assert_eq!(
fires_at("0 0 */6 * * *", monday),
Utc.with_ymd_and_hms(2026, 9, 7, 6, 0, 0).unwrap()
);
// every hour at half past
assert_eq!(
fires_at("0 30 * * * *", monday),
Utc.with_ymd_and_hms(2026, 9, 7, 0, 30, 0).unwrap()
);
// on the first day of the month at 3:30
assert_eq!(
fires_at("0 30 3 1 * *", monday),
Utc.with_ymd_and_hms(2026, 10, 1, 3, 30, 0).unwrap()
);
// Weekdays are 1-7 with Sunday as 1, and names work; a zero is not a valid day.
let sunday = Utc.with_ymd_and_hms(2026, 9, 13, 3, 0, 0).unwrap();
assert_eq!(fires_at("0 0 3 * * SUN", monday), sunday);
assert_eq!(
fires_at("0 0 3 * * 1", monday),
sunday,
"1 is Sunday, not Monday"
);
assert_eq!(
fires_at("0 0 3 * * MON", monday),
Utc.with_ymd_and_hms(2026, 9, 7, 3, 0, 0).unwrap()
);
assert!(
validate_cron("0 0 3 * * 0").is_err(),
"a zero weekday is rejected"
);
assert_eq!(next_fire("0 0 3 * * 0", monday), None);
}

View File

@ -28,12 +28,14 @@ test('an admin backs up Gitea data and database in one flow', async ({ page }) =
// Gitea → select → schedule → target → save // Gitea → select → schedule → target → save
await gitea.getByRole('button', { name: 'Back up' }).click() await gitea.getByRole('button', { name: 'Back up' }).click()
await expect(page.getByRole('dialog')).toContainText('gitea-shared-storage') await expect(page.getByRole('dialog')).toContainText('gitea-shared-storage')
// the schedule reads as time, not as cron
await expect(page.getByRole('dialog')).toContainText('Every day at 03:00')
await page.getByLabel('Target', { exact: true }).selectOption({ label: name }) await page.getByLabel('Target', { exact: true }).selectOption({ label: name })
await page.getByRole('button', { name: 'Create backup plan' }).click() await page.getByRole('button', { name: 'Create backup plan' }).click()
await expect(page.getByText(/2 backup strategies created/)).toBeVisible() await expect(page.getByText(/2 backup strategies created/)).toBeVisible()
await expect(page.getByRole('row', { name: /Gitea – Gitea volume/ })).toBeVisible() await expect(page.getByRole('row', { name: /Gitea – Gitea volume/ })).toBeVisible()
await expect(page.getByRole('row', { name: /Gitea – PostgreSQL dump/ })).toContainText( await expect(page.getByRole('row', { name: /Gitea – PostgreSQL dump/ })).toContainText(
'0 0 3 * * *', 'Every day at 03:00',
) )
}) })

View File

@ -26,7 +26,7 @@ test('admin creates a target and a strategy, runs it and sees the backup', async
await page.getByLabel('Source type', { exact: true }).selectOption('postgres_dump') await page.getByLabel('Source type', { exact: true }).selectOption('postgres_dump')
await page.getByLabel('Namespace', { exact: true }).fill('gitea') await page.getByLabel('Namespace', { exact: true }).fill('gitea')
await page.getByLabel('Pod', { exact: true }).fill('gitea-postgresql-0') await page.getByLabel('Pod', { exact: true }).fill('gitea-postgresql-0')
await page.getByLabel('Schedule', { exact: true }).fill('0 0 2 * * *') await page.getByLabel('At', { exact: true }).fill('02:00')
await page.getByLabel('Target', { exact: true }).selectOption({ label: name }) await page.getByLabel('Target', { exact: true }).selectOption({ label: name })
await page.getByLabel('Keep last', { exact: true }).fill('3') await page.getByLabel('Keep last', { exact: true }).fill('3')
await page.getByRole('button', { name: 'Save strategy' }).click() await page.getByRole('button', { name: 'Save strategy' }).click()
@ -36,4 +36,5 @@ test('admin creates a target and a strategy, runs it and sees the backup', async
await expect(row).toContainText(/gitea-database_.*\.sql\.gz/, { timeout: 20_000 }) await expect(row).toContainText(/gitea-database_.*\.sql\.gz/, { timeout: 20_000 })
await row.getByRole('button', { name: 'History' }).click() await row.getByRole('button', { name: 'History' }).click()
await expect(page.getByTestId('backup-records')).toContainText('sql.gz') await expect(page.getByTestId('backup-records')).toContainText('sql.gz')
await expect(row).toContainText('Every day at 02:00')
}) })

View File

@ -23,10 +23,14 @@ test('admin saves SMTP settings and a schedule', async ({ page }) => {
await expect(page.getByLabel('SMTP host')).toHaveValue('mail.example.com') await expect(page.getByLabel('SMTP host')).toHaveValue('mail.example.com')
await expect(page.getByText('leave empty to keep')).toBeVisible() await expect(page.getByText('leave empty to keep')).toBeVisible()
const cron = page.getByLabel('Package refresh schedule') await expect(page.getByTestId('schedule-summary').first()).toHaveText('Every hour')
await cron.fill('0 */30 * * * *') await page.getByLabel('Repeat').first().selectOption('daily')
await expect(page.getByTestId('schedule-summary').first()).toContainText('Every day at')
await page.getByLabel('At', { exact: true }).first().fill('04:30')
await page.getByRole('button', { name: 'Save schedules' }).click() await page.getByRole('button', { name: 'Save schedules' }).click()
await expect(page.getByRole('status')).toContainText('Schedules saved') await expect(page.getByText(/Schedules saved/)).toBeVisible()
await page.reload()
await expect(page.getByLabel('At', { exact: true }).first()).toHaveValue('04:30')
}) })
test('admin runs a job manually and sees the log', async ({ page }) => { test('admin runs a job manually and sees the log', async ({ page }) => {

View File

@ -53,7 +53,9 @@ describe('ApplicationBackupForm', () => {
it('defaults to a nightly backup at three and submits the selection', async () => { it('defaults to a nightly backup at three and submits the selection', async () => {
const w = mount(ApplicationBackupForm, { props }) const w = mount(ApplicationBackupForm, { props })
expect((w.find('input[name=schedule]').element as HTMLInputElement).value).toBe('0 0 3 * * *') // the schedule is offered as plain time, not as a cron expression
expect(w.text()).toContain('Every day at 03:00')
expect((w.find('input[name=time]').element as HTMLInputElement).value).toBe('03:00')
await w.find('select[name=target_id]').setValue('t2') await w.find('select[name=target_id]').setValue('t2')
await w.find('form').trigger('submit') await w.find('form').trigger('submit')
expect(w.emitted('submit')![0][0]).toEqual({ expect(w.emitted('submit')![0][0]).toEqual({
@ -66,7 +68,7 @@ describe('ApplicationBackupForm', () => {
}) })
}) })
it('refuses an empty selection and offers a daily preset', async () => { it('refuses an empty selection and lets the time be changed', async () => {
const w = mount(ApplicationBackupForm, { props }) const w = mount(ApplicationBackupForm, { props })
for (const b of w.findAll('input[type=checkbox]')) await b.setValue(false) for (const b of w.findAll('input[type=checkbox]')) await b.setValue(false)
await w.find('form').trigger('submit') await w.find('form').trigger('submit')
@ -74,8 +76,12 @@ describe('ApplicationBackupForm', () => {
expect(w.text()).toContain('Select at least one') expect(w.text()).toContain('Select at least one')
await w.findAll('input[type=checkbox]')[0].setValue(true) await w.findAll('input[type=checkbox]')[0].setValue(true)
await w.find('select[name=preset]').setValue('0 0 */6 * * *') await w.find('select[name=frequency]').setValue('hours')
expect((w.find('input[name=schedule]').element as HTMLInputElement).value).toBe('0 0 */6 * * *') await w.find('select[name=interval]').setValue('12')
await w.find('form').trigger('submit')
expect((w.emitted('submit')!.at(-1)![0] as { schedule: string }).schedule).toBe(
'0 0 */12 * * *',
)
}) })
it('says when no target exists yet', () => { it('says when no target exists yet', () => {

View File

@ -1,6 +1,7 @@
<script setup lang="ts"> <script setup lang="ts">
import { computed, ref } from 'vue' import { computed, ref } from 'vue'
import type { Application, ApplicationBackupPayload } from '../api/types' import type { Application, ApplicationBackupPayload } from '../api/types'
import SchedulePicker from './SchedulePicker.vue'
const props = defineProps<{ const props = defineProps<{
application: Application application: Application
@ -13,19 +14,11 @@ const selected = ref<Set<string>>(
new Set(props.application.components.filter((c) => c.recommended).map((c) => c.id)), new Set(props.application.components.filter((c) => c.recommended).map((c) => c.id)),
) )
const schedule = ref('0 0 3 * * *') const schedule = ref('0 0 3 * * *')
const preset = ref('0 0 3 * * *')
const targetId = ref(props.targets[0]?.id ?? '') const targetId = ref(props.targets[0]?.id ?? '')
const retention = ref('7') const retention = ref('7')
const passphrase = ref('') const passphrase = ref('')
const error = ref('') const error = ref('')
const presets = [
{ cron: '0 0 3 * * *', label: 'Every day at 3:00' },
{ cron: '0 0 */6 * * *', label: 'Every six hours' },
{ cron: '0 0 3 * * 0', label: 'Every Sunday at 3:00' },
{ cron: '0 30 3 1 * *', label: 'Monthly on the 1st at 3:30' },
]
/** Short summary of what is selected, e.g. "Data + DB". */ /** Short summary of what is selected, e.g. "Data + DB". */
const summary = computed(() => { const summary = computed(() => {
const kinds = props.application.components const kinds = props.application.components
@ -43,10 +36,6 @@ function toggle(id: string, on: boolean) {
else selected.value.delete(id) else selected.value.delete(id)
} }
function applyPreset() {
schedule.value = preset.value
}
function submit() { function submit() {
error.value = '' error.value = ''
if (selected.value.size === 0) { if (selected.value.size === 0) {
@ -95,23 +84,9 @@ const input = 'mt-1 w-full rounded-md border border-gray-300 px-3 py-2'
</label> </label>
</div> </div>
<div class="grid grid-cols-2 gap-4">
<div> <div>
<label for="ab-preset" class="block text-sm font-medium">How often?</label> <label class="block text-sm font-medium">How often?</label>
<select id="ab-preset" v-model="preset" name="preset" :class="input" @change="applyPreset"> <SchedulePicker v-model="schedule" />
<option v-for="p in presets" :key="p.cron" :value="p.cron">{{ p.label }}</option>
</select>
</div>
<div>
<label for="ab-schedule" class="block text-sm font-medium">Schedule</label>
<input
id="ab-schedule"
v-model="schedule"
name="schedule"
required
class="mt-1 w-full rounded-md border border-gray-300 px-3 py-2 font-mono"
/>
</div>
</div> </div>
<div class="grid grid-cols-2 gap-4"> <div class="grid grid-cols-2 gap-4">

View File

@ -0,0 +1,45 @@
import { mount } from '@vue/test-utils'
import SchedulePicker from './SchedulePicker.vue'
describe('SchedulePicker', () => {
it('shows a daily schedule as a time, not as cron', () => {
const w = mount(SchedulePicker, { props: { modelValue: '0 0 3 * * *' } })
expect((w.find('select[name=frequency]').element as HTMLSelectElement).value).toBe('daily')
expect((w.find('input[name=time]').element as HTMLInputElement).value).toBe('03:00')
expect(w.get('[data-testid=schedule-summary]').text()).toBe('Every day at 03:00')
expect(w.text()).not.toContain('* * *')
})
it('emits the cron expression for the chosen time', async () => {
const w = mount(SchedulePicker, { props: { modelValue: '0 0 3 * * *' } })
await w.find('input[name=time]').setValue('22:15')
expect(w.emitted('update:modelValue')!.at(-1)).toEqual(['0 15 22 * * *'])
await w.find('select[name=frequency]').setValue('weekly')
await w.find('select[name=weekday]').setValue('FRI')
expect(w.emitted('update:modelValue')!.at(-1)).toEqual(['0 15 22 * * FRI'])
await w.find('select[name=frequency]').setValue('monthly')
await w.find('select[name=day]').setValue('15')
expect(w.emitted('update:modelValue')!.at(-1)).toEqual(['0 15 22 15 * *'])
})
it('offers an interval in hours and hides the fields that do not apply', async () => {
const w = mount(SchedulePicker, { props: { modelValue: '0 0 3 * * *' } })
expect(w.find('select[name=weekday]').exists()).toBe(false)
await w.find('select[name=frequency]').setValue('hours')
expect(w.find('input[name=time]').exists()).toBe(false)
await w.find('select[name=interval]').setValue('12')
expect(w.emitted('update:modelValue')!.at(-1)).toEqual(['0 0 */12 * * *'])
expect(w.get('[data-testid=schedule-summary]').text()).toBe('Every 12 hours')
})
it('keeps an expression it cannot express and lets it be edited directly', async () => {
const w = mount(SchedulePicker, { props: { modelValue: '0 0 3,15 * * *' } })
const raw = w.find('input[name=cron]')
expect(raw.exists()).toBe(true)
expect((raw.element as HTMLInputElement).value).toBe('0 0 3,15 * * *')
await raw.setValue('0 0 4,16 * * *')
expect(w.emitted('update:modelValue')!.at(-1)).toEqual(['0 0 4,16 * * *'])
})
})

View File

@ -0,0 +1,157 @@
<script setup lang="ts">
import { computed, ref, watch } from 'vue'
import {
DEFAULT_SCHEDULE,
describeSchedule,
HOUR_INTERVALS,
parseSchedule,
toCron,
WEEKDAYS,
type Frequency,
type SimpleSchedule,
} from '../lib/schedule'
const props = defineProps<{ modelValue: string; disabled?: boolean }>()
const emit = defineEmits<{ 'update:modelValue': [cron: string] }>()
/** Null while the expression is one the picker cannot express. */
const simple = ref<SimpleSchedule | null>(
parseSchedule(props.modelValue) ?? { ...DEFAULT_SCHEDULE },
)
const raw = ref(props.modelValue)
const custom = ref(parseSchedule(props.modelValue) === null && props.modelValue !== '')
watch(
() => props.modelValue,
(v) => {
raw.value = v
const parsed = parseSchedule(v)
if (parsed) simple.value = parsed
custom.value = parsed === null && v !== ''
},
)
const summary = computed(() => describeSchedule(raw.value))
function update<K extends keyof SimpleSchedule>(key: K, value: SimpleSchedule[K]) {
const next = { ...(simple.value ?? DEFAULT_SCHEDULE), [key]: value }
simple.value = next
raw.value = toCron(next)
emit('update:modelValue', raw.value)
}
function updateRaw(value: string) {
raw.value = value
emit('update:modelValue', value)
}
const frequencies: { value: Frequency; label: string }[] = [
{ value: 'hourly', label: 'Every hour' },
{ value: 'hours', label: 'Every few hours' },
{ value: 'daily', label: 'Every day' },
{ value: 'weekly', label: 'Every week' },
{ value: 'monthly', label: 'Every month' },
]
const field = 'mt-1 w-full rounded-md border border-gray-300 px-3 py-2'
</script>
<template>
<div>
<div v-if="!custom" class="flex flex-wrap gap-3">
<div>
<label :for="`sp-freq`" class="block text-sm font-medium">Repeat</label>
<select
id="sp-freq"
name="frequency"
:value="simple?.frequency"
:disabled="disabled"
:class="field"
@change="update('frequency', ($event.target as HTMLSelectElement).value as Frequency)"
>
<option v-for="f in frequencies" :key="f.value" :value="f.value">{{ f.label }}</option>
</select>
</div>
<div v-if="simple?.frequency === 'hours'">
<label for="sp-interval" class="block text-sm font-medium">Interval</label>
<select
id="sp-interval"
name="interval"
:value="simple?.interval"
:disabled="disabled"
:class="field"
@change="update('interval', Number(($event.target as HTMLSelectElement).value))"
>
<option v-for="i in HOUR_INTERVALS" :key="i" :value="i">every {{ i }} hours</option>
</select>
</div>
<div v-if="simple?.frequency === 'weekly'">
<label for="sp-weekday" class="block text-sm font-medium">Weekday</label>
<select
id="sp-weekday"
name="weekday"
:value="simple?.weekday"
:disabled="disabled"
:class="field"
@change="update('weekday', ($event.target as HTMLSelectElement).value)"
>
<option v-for="d in WEEKDAYS" :key="d.value" :value="d.value">{{ d.label }}</option>
</select>
</div>
<div v-if="simple?.frequency === 'monthly'">
<label for="sp-day" class="block text-sm font-medium">Day</label>
<select
id="sp-day"
name="day"
:value="simple?.day"
:disabled="disabled"
:class="field"
@change="update('day', Number(($event.target as HTMLSelectElement).value))"
>
<option v-for="d in 28" :key="d" :value="d">{{ d }}.</option>
</select>
</div>
<div v-if="simple && simple.frequency !== 'hours'">
<label for="sp-time" class="block text-sm font-medium">
{{ simple.frequency === 'hourly' ? 'At minute' : 'At' }}
</label>
<input
id="sp-time"
name="time"
type="time"
:value="simple.time"
:disabled="disabled"
:class="field"
@change="update('time', ($event.target as HTMLInputElement).value)"
/>
</div>
</div>
<div v-else>
<label for="sp-cron" class="block text-sm font-medium">Schedule (cron)</label>
<input
id="sp-cron"
name="cron"
:value="raw"
:disabled="disabled"
class="mt-1 w-full rounded-md border border-gray-300 px-3 py-2 font-mono"
@input="updateRaw(($event.target as HTMLInputElement).value)"
/>
</div>
<p class="mt-1 text-xs text-gray-500">
<span data-testid="schedule-summary">{{ summary }}</span>
<button
v-if="!disabled"
type="button"
class="ml-2 text-blue-600 hover:underline"
@click="custom = !custom"
>
{{ custom ? 'use the simple picker' : 'enter a cron expression' }}
</button>
</p>
</div>
</template>

View File

@ -13,7 +13,7 @@ describe('StrategyForm', () => {
await w.find('select[name=source_type]').setValue('postgres_dump') await w.find('select[name=source_type]').setValue('postgres_dump')
await w.find('input[name=namespace]').setValue('gitea') await w.find('input[name=namespace]').setValue('gitea')
await w.find('input[name=pod]').setValue('gitea-postgresql-0') await w.find('input[name=pod]').setValue('gitea-postgresql-0')
await w.find('input[name=schedule]').setValue('0 0 2 * * *') await w.find('input[name=time]').setValue('02:00')
await w.find('select[name=target_id]').setValue('t2') await w.find('select[name=target_id]').setValue('t2')
await w.find('input[name=retention]').setValue('7') await w.find('input[name=retention]').setValue('7')
await w.find('input[name=passphrase]').setValue('a-long-passphrase!') await w.find('input[name=passphrase]').setValue('a-long-passphrase!')

View File

@ -1,6 +1,7 @@
<script setup lang="ts"> <script setup lang="ts">
import { ref } from 'vue' import { ref } from 'vue'
import type { BackupSource, BackupStrategyPayload, BackupStrategyView } from '../api/types' import type { BackupSource, BackupStrategyPayload, BackupStrategyView } from '../api/types'
import SchedulePicker from './SchedulePicker.vue'
const props = defineProps<{ const props = defineProps<{
targets: { id: string; name: string }[] targets: { id: string; name: string }[]
@ -106,17 +107,10 @@ const input = 'mt-1 w-full rounded-md border border-gray-300 px-3 py-2'
:class="input" :class="input"
/> />
</div> </div>
<div class="grid grid-cols-3 gap-4"> <div class="grid grid-cols-2 gap-4">
<div> <div class="col-span-2">
<label for="s-cron" class="block text-sm font-medium">Schedule</label> <label class="block text-sm font-medium">Schedule</label>
<input <SchedulePicker v-model="schedule" />
id="s-cron"
v-model="schedule"
name="schedule"
required
class="mt-1 w-full rounded-md border border-gray-300 px-3 py-2 font-mono"
/>
<p class="mt-1 text-xs text-gray-500">sec min hour day month weekday</p>
</div> </div>
<div> <div>
<label for="s-target" class="block text-sm font-medium">Target</label> <label for="s-target" class="block text-sm font-medium">Target</label>

View File

@ -0,0 +1,84 @@
import { describeSchedule, parseSchedule, toCron } from './schedule'
describe('describeSchedule', () => {
it('reads common schedules as plain time', () => {
expect(describeSchedule('0 0 3 * * *')).toBe('Every day at 03:00')
expect(describeSchedule('0 30 3 * * *')).toBe('Every day at 03:30')
expect(describeSchedule('0 0 0 * * *')).toBe('Every day at 00:00')
expect(describeSchedule('0 0 3 * * SUN')).toBe('Every Sunday at 03:00')
expect(describeSchedule('0 0 3 * * MON')).toBe('Every Monday at 03:00')
expect(describeSchedule('0 30 3 1 * *')).toBe('Monthly on the 1st at 03:30')
expect(describeSchedule('0 0 3 15 * *')).toBe('Monthly on the 15th at 03:00')
expect(describeSchedule('0 0 */6 * * *')).toBe('Every 6 hours')
expect(describeSchedule('0 0 * * * *')).toBe('Every hour')
expect(describeSchedule('0 30 * * * *')).toBe('Every hour at minute 30')
})
it('falls back to the expression when it is not a simple pattern', () => {
expect(describeSchedule('0 0 3,15 * * *')).toBe('0 0 3,15 * * *')
expect(describeSchedule('')).toBe('')
})
})
describe('parseSchedule and toCron', () => {
it('round-trips the schedules the picker can express', () => {
const cases = [
'0 0 3 * * *',
'0 15 7 * * *',
'0 0 3 * * SUN',
'0 0 3 * * SAT',
'0 30 3 1 * *',
'0 0 */6 * * *',
'0 0 * * * *',
'0 45 * * * *',
]
for (const cron of cases) {
const parsed = parseSchedule(cron)
expect(parsed, cron).not.toBeNull()
expect(toCron(parsed!), cron).toBe(cron)
}
})
it('reads the parts of a schedule', () => {
expect(parseSchedule('0 30 3 * * SUN')).toEqual({
frequency: 'weekly',
time: '03:30',
weekday: 'SUN',
day: 1,
interval: 6,
})
expect(parseSchedule('0 0 5 12 * *')).toEqual({
frequency: 'monthly',
time: '05:00',
weekday: 'SUN',
day: 12,
interval: 6,
})
expect(parseSchedule('0 0 */4 * * *')).toEqual({
frequency: 'hours',
time: '03:00',
weekday: 'SUN',
day: 1,
interval: 4,
})
expect(parseSchedule('0 0 3,15 * * *')).toBeNull()
})
it('builds expressions the backend accepts, counting Sunday as SUN not 0', () => {
expect(toCron({ frequency: 'daily', time: '03:00', weekday: 'SUN', day: 1, interval: 6 })).toBe(
'0 0 3 * * *',
)
expect(
toCron({ frequency: 'weekly', time: '23:05', weekday: 'FRI', day: 1, interval: 6 }),
).toBe('0 5 23 * * FRI')
expect(
toCron({ frequency: 'monthly', time: '00:00', weekday: 'SUN', day: 28, interval: 6 }),
).toBe('0 0 0 28 * *')
expect(
toCron({ frequency: 'hours', time: '03:00', weekday: 'SUN', day: 1, interval: 12 }),
).toBe('0 0 */12 * * *')
expect(
toCron({ frequency: 'hourly', time: '03:20', weekday: 'SUN', day: 1, interval: 6 }),
).toBe('0 20 * * * *')
})
})

View File

@ -0,0 +1,120 @@
/**
* Schedules are shown and picked as plain time ("Every day at 03:00") and stored as the
* six field cron expression the backend uses: `sec min hour day month weekday`.
*
* Weekdays are names (SUN … SAT). The backend's cron dialect counts Sunday as 1 and
* rejects a zero, so names avoid the off-by-one entirely.
*/
export type Frequency = 'hourly' | 'hours' | 'daily' | 'weekly' | 'monthly'
export const WEEKDAYS = [
{ value: 'MON', label: 'Monday' },
{ value: 'TUE', label: 'Tuesday' },
{ value: 'WED', label: 'Wednesday' },
{ value: 'THU', label: 'Thursday' },
{ value: 'FRI', label: 'Friday' },
{ value: 'SAT', label: 'Saturday' },
{ value: 'SUN', label: 'Sunday' },
] as const
export const HOUR_INTERVALS = [2, 3, 4, 6, 8, 12] as const
export interface SimpleSchedule {
frequency: Frequency
/** `HH:MM`, used by every frequency except `hours`. */
time: string
weekday: string
/** Day of the month, 1-28. */
day: number
/** Interval in hours for `hours`. */
interval: number
}
export const DEFAULT_SCHEDULE: SimpleSchedule = {
frequency: 'daily',
time: '03:00',
weekday: 'SUN',
day: 1,
interval: 6,
}
const pad = (n: number) => String(n).padStart(2, '0')
const isNumber = (s: string) => /^\d+$/.test(s)
/** Read a cron expression the picker can express, or null for anything else. */
export function parseSchedule(cron: string): SimpleSchedule | null {
const parts = cron.trim().split(/\s+/)
if (parts.length !== 6) return null
const [sec, min, hour, day, month, weekday] = parts
if (sec !== '0' || month !== '*') return null
const everyNHours = /^\*\/(\d+)$/.exec(hour)
if (everyNHours && min === '0' && day === '*' && weekday === '*') {
return { ...DEFAULT_SCHEDULE, frequency: 'hours', interval: Number(everyNHours[1]) }
}
if (hour === '*' && isNumber(min) && day === '*' && weekday === '*') {
return { ...DEFAULT_SCHEDULE, frequency: 'hourly', time: `03:${pad(Number(min))}` }
}
if (!isNumber(hour) || !isNumber(min)) return null
const time = `${pad(Number(hour))}:${pad(Number(min))}`
if (day === '*' && weekday === '*') return { ...DEFAULT_SCHEDULE, frequency: 'daily', time }
if (day === '*' && WEEKDAYS.some((w) => w.value === weekday)) {
return { ...DEFAULT_SCHEDULE, frequency: 'weekly', time, weekday }
}
if (isNumber(day) && weekday === '*') {
return { ...DEFAULT_SCHEDULE, frequency: 'monthly', time, day: Number(day) }
}
return null
}
/** Build the cron expression for a picked schedule. */
export function toCron(s: SimpleSchedule): string {
const [hour, minute] = s.time.split(':').map(Number)
switch (s.frequency) {
case 'hours':
return `0 0 */${s.interval} * * *`
case 'hourly':
return `0 ${minute} * * * *`
case 'daily':
return `0 ${minute} ${hour} * * *`
case 'weekly':
return `0 ${minute} ${hour} * * ${s.weekday}`
case 'monthly':
return `0 ${minute} ${hour} ${s.day} * *`
}
}
const ordinal = (n: number) => {
const suffix =
n % 10 === 1 && n !== 11
? 'st'
: n % 10 === 2 && n !== 12
? 'nd'
: n % 10 === 3 && n !== 13
? 'rd'
: 'th'
return `${n}${suffix}`
}
/** Human wording for a schedule; the expression itself when it is not a simple pattern. */
export function describeSchedule(cron: string): string {
const s = parseSchedule(cron)
if (!s) return cron
switch (s.frequency) {
case 'hours':
return `Every ${s.interval} hours`
case 'hourly': {
const minute = Number(s.time.split(':')[1])
return minute === 0 ? 'Every hour' : `Every hour at minute ${minute}`
}
case 'daily':
return `Every day at ${s.time}`
case 'weekly': {
const day = WEEKDAYS.find((w) => w.value === s.weekday)?.label ?? s.weekday
return `Every ${day} at ${s.time}`
}
case 'monthly':
return `Monthly on the ${ordinal(s.day)} at ${s.time}`
}
}

View File

@ -16,6 +16,7 @@ import { useToastStore } from '../stores/toast'
import TargetForm from '../components/TargetForm.vue' import TargetForm from '../components/TargetForm.vue'
import StrategyForm from '../components/StrategyForm.vue' import StrategyForm from '../components/StrategyForm.vue'
import ApplicationBackupForm from '../components/ApplicationBackupForm.vue' import ApplicationBackupForm from '../components/ApplicationBackupForm.vue'
import { describeSchedule } from '../lib/schedule'
const auth = useAuthStore() const auth = useAuthStore()
const toast = useToastStore() const toast = useToastStore()
@ -343,7 +344,7 @@ const fmt = (d: string) => new Date(d).toLocaleString()
<span v-if="!s.enabled" class="ml-1 rounded bg-gray-100 px-1 text-xs">disabled</span> <span v-if="!s.enabled" class="ml-1 rounded bg-gray-100 px-1 text-xs">disabled</span>
</td> </td>
<td class="font-mono text-xs">{{ sourceLabel(s) }}</td> <td class="font-mono text-xs">{{ sourceLabel(s) }}</td>
<td class="font-mono text-xs">{{ s.schedule }}</td> <td class="text-xs">{{ describeSchedule(s.schedule) }}</td>
<td>{{ s.target_name }}</td> <td>{{ s.target_name }}</td>
<td>{{ s.retention }}</td> <td>{{ s.retention }}</td>
<td class="text-xs"> <td class="text-xs">

View File

@ -3,6 +3,7 @@ import { onMounted, ref } from 'vue'
import { api, ApiError } from '../api/client' import { api, ApiError } from '../api/client'
import type { Dashboard } from '../api/types' import type { Dashboard } from '../api/types'
import { useToastStore } from '../stores/toast' import { useToastStore } from '../stores/toast'
import { describeSchedule } from '../lib/schedule'
const toast = useToastStore() const toast = useToastStore()
const d = ref<Dashboard | null>(null) const d = ref<Dashboard | null>(null)
@ -108,7 +109,7 @@ const tile = 'block rounded-lg border border-gray-200 bg-white p-4 hover:border-
:class="b.enabled ? '' : 'text-gray-400'" :class="b.enabled ? '' : 'text-gray-400'"
> >
<td class="py-1.5 font-medium">{{ b.name }}</td> <td class="py-1.5 font-medium">{{ b.name }}</td>
<td class="font-mono text-xs">{{ b.schedule }}</td> <td class="text-xs">{{ describeSchedule(b.schedule) }}</td>
<td class="text-xs"> <td class="text-xs">
<template v-if="b.last_backup" <template v-if="b.last_backup"
>{{ b.last_backup.filename }}<br /><span class="text-gray-500">{{ >{{ b.last_backup.filename }}<br /><span class="text-gray-500">{{

View File

@ -5,6 +5,7 @@ import type { ScheduleView, Severity, SmtpPayload, SmtpView } from '../api/types
import { useAuthStore } from '../stores/auth' import { useAuthStore } from '../stores/auth'
import { useToastStore } from '../stores/toast' import { useToastStore } from '../stores/toast'
import SmtpForm from '../components/SmtpForm.vue' import SmtpForm from '../components/SmtpForm.vue'
import SchedulePicker from '../components/SchedulePicker.vue'
const auth = useAuthStore() const auth = useAuthStore()
const toast = useToastStore() const toast = useToastStore()
@ -74,6 +75,11 @@ async function saveNotifications() {
} }
} }
/** Switching a job off stores an empty schedule; switching it on restores the default. */
function toggleSchedule(s: ScheduleView, on: boolean) {
cronInputs.value[s.kind] = on ? (s.cron ?? s.default_cron ?? '0 0 3 * * *') : ''
}
async function saveSchedules() { async function saveSchedules() {
busy.value = true busy.value = true
try { try {
@ -163,21 +169,29 @@ async function saveSchedules() {
<section class="mt-10"> <section class="mt-10">
<h2 class="text-lg font-medium">Schedules</h2> <h2 class="text-lg font-medium">Schedules</h2>
<p class="mb-4 text-sm text-gray-600"> <p class="mb-4 text-sm text-gray-600">Pick when each job should run, or switch it off.</p>
Cron expressions with seconds (sec min hour day month weekday). Empty disables the job.
</p>
<form class="grid max-w-xl gap-4" @submit.prevent="saveSchedules"> <form class="grid max-w-xl gap-4" @submit.prevent="saveSchedules">
<div v-for="s in schedules" :key="s.kind"> <div v-for="s in schedules" :key="s.kind">
<label :for="`cron-${s.kind}`" class="block text-sm font-medium" <div class="flex items-center gap-2">
>{{ labels[s.kind] ?? s.kind }} schedule</label <label class="block text-sm font-medium">{{ labels[s.kind] ?? s.kind }}</label>
> <label class="flex items-center gap-1 text-xs text-gray-600">
<input <input
:id="`cron-${s.kind}`" type="checkbox"
v-model="cronInputs[s.kind]" :name="`enabled-${s.kind}`"
:placeholder="s.default_cron ?? ''" :checked="cronInputs[s.kind] !== ''"
:disabled="!auth.isAdmin" :disabled="!auth.isAdmin"
class="mt-1 w-full rounded-md border border-gray-300 px-3 py-2 font-mono" @change="toggleSchedule(s, ($event.target as HTMLInputElement).checked)"
/> />
enabled
</label>
</div>
<SchedulePicker
v-if="cronInputs[s.kind]"
v-model="cronInputs[s.kind]"
:disabled="!auth.isAdmin"
class="mt-1"
/>
<p v-else class="mt-1 text-xs text-gray-500">This job only runs when started by hand.</p>
</div> </div>
<div v-if="auth.isAdmin"> <div v-if="auth.isAdmin">
<button <button