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:
@ -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);
|
||||||
|
}
|
||||||
|
|||||||
@ -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',
|
||||||
)
|
)
|
||||||
})
|
})
|
||||||
|
|||||||
@ -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')
|
||||||
})
|
})
|
||||||
|
|||||||
@ -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 }) => {
|
||||||
|
|||||||
@ -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', () => {
|
||||||
|
|||||||
@ -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 class="block text-sm font-medium">How often?</label>
|
||||||
<label for="ab-preset" class="block text-sm font-medium">How often?</label>
|
<SchedulePicker v-model="schedule" />
|
||||||
<select id="ab-preset" v-model="preset" name="preset" :class="input" @change="applyPreset">
|
|
||||||
<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">
|
||||||
|
|||||||
45
frontend/src/components/SchedulePicker.test.ts
Normal file
45
frontend/src/components/SchedulePicker.test.ts
Normal 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 * * *'])
|
||||||
|
})
|
||||||
|
})
|
||||||
157
frontend/src/components/SchedulePicker.vue
Normal file
157
frontend/src/components/SchedulePicker.vue
Normal 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>
|
||||||
@ -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!')
|
||||||
|
|||||||
@ -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>
|
||||||
|
|||||||
84
frontend/src/lib/schedule.test.ts
Normal file
84
frontend/src/lib/schedule.test.ts
Normal 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 * * * *')
|
||||||
|
})
|
||||||
|
})
|
||||||
120
frontend/src/lib/schedule.ts
Normal file
120
frontend/src/lib/schedule.ts
Normal 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}`
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -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">
|
||||||
|
|||||||
@ -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">{{
|
||||||
|
|||||||
@ -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"
|
||||||
|
:name="`enabled-${s.kind}`"
|
||||||
|
:checked="cronInputs[s.kind] !== ''"
|
||||||
|
:disabled="!auth.isAdmin"
|
||||||
|
@change="toggleSchedule(s, ($event.target as HTMLInputElement).checked)"
|
||||||
|
/>
|
||||||
|
enabled
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
<SchedulePicker
|
||||||
|
v-if="cronInputs[s.kind]"
|
||||||
v-model="cronInputs[s.kind]"
|
v-model="cronInputs[s.kind]"
|
||||||
:placeholder="s.default_cron ?? ''"
|
|
||||||
:disabled="!auth.isAdmin"
|
:disabled="!auth.isAdmin"
|
||||||
class="mt-1 w-full rounded-md border border-gray-300 px-3 py-2 font-mono"
|
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
|
||||||
|
|||||||
Reference in New Issue
Block a user