Files
windmill/frontend/src/lib/components/CronInput.svelte
2025-10-27 15:49:27 +00:00

403 lines
11 KiB
Svelte

<script lang="ts">
import { ScheduleService } from '$lib/gen'
import { emptyString, formatCron, sendUserToast } from '$lib/utils'
import Badge from './Badge.svelte'
import { Button } from './common'
import timezones from './timezones'
import CronBuilder from './CronBuilder.svelte'
import Label from './Label.svelte'
import CronGen from './copilot/CronGen.svelte'
import Select from './select/Select.svelte'
import MultiSelect from './select/MultiSelect.svelte'
import { safeSelectItems } from './select/utils.svelte'
import { untrack } from 'svelte'
import TextInput from './text_input/TextInput.svelte'
interface Props {
schedule: string
// export let offset: number = -60 * Math.floor(new Date().getTimezoneOffset() / 60)
timezone: string // = Intl.DateTimeFormat().resolvedOptions().timeZone
disabled?: boolean
validCRON?: boolean
cronVersion?: string
}
let {
schedule = $bindable(),
timezone = $bindable(),
disabled = false,
validCRON = $bindable(true),
cronVersion = $bindable('v2')
}: Props = $props()
let preview: string[] = $state([])
// If the user has already entered a cron string, switching to the basic tab will override it.
let executeEvery: 'second' | 'minute' | 'hour' | 'day-month' | 'month' | 'day-week' =
$state('minute')
let seconds = $state(30)
let minutes = $state(30)
let hours = $state(1)
const daysOfMonthOptions: number[] = Array.from(Array(31).keys()).map((i) => i + 1)
let daysOfMonth: number[] = $state([])
// let lastDayOfMonth = false
const monthsOfYearOptions: string[] = [
'January',
'February',
'March',
'April',
'May',
'June',
'July',
'August',
'September',
'October',
'November',
'December'
]
let monthsOfYear: string[] = $state([])
const daysOfWeekOptions: string[] = [
'Sunday',
'Monday',
'Tuesday',
'Wednesday',
'Thursday',
'Friday',
'Saturday'
]
let daysOfWeek: string[] = $state([])
let UTCTime: string = $state('')
async function handleScheduleInput(input: string, timezone: string): Promise<void> {
try {
preview = await ScheduleService.previewSchedule({
requestBody: { schedule: formatCron(input), timezone, cron_version: cronVersion }
})
validCRON = true
} catch (err) {
if (err.status == 400 && err.body.includes('cron')) {
validCRON = false
} else {
sendUserToast(err.body, true)
validCRON = false
}
}
}
let nschedule = $state('')
function formatDate(timezone) {
try {
return new Intl.DateTimeFormat('en-GB', {
weekday: 'short',
day: '2-digit',
month: 'short',
year: 'numeric',
hour: 'numeric',
minute: 'numeric',
second: 'numeric',
timeZone: timezone,
timeZoneName: 'short'
}).format
} catch (ee) {
sendUserToast(
`Invalid timezone: ${timezone}. Update your browser's timezone preference`,
true
)
return new Intl.DateTimeFormat('en-GB', {
weekday: 'short',
day: '2-digit',
month: 'short',
year: 'numeric',
hour: 'numeric',
minute: 'numeric',
second: 'numeric',
timeZone: 'Europe/Paris',
timeZoneName: 'short'
}).format
}
}
const items = Object.keys(timezones)
.map((key) => {
return Object.keys(timezones[key])
.map((subKey) => {
return {
value: subKey,
label: subKey,
group: timezones[key][subKey][1] as string
}
})
.flat()
})
.flat()
$effect(() => {
schedule
untrack(() => {
!emptyString(schedule) && handleScheduleInput(schedule, timezone)
})
})
$effect(() => {
// CRON string format
// sec min hour day of month month day of week year
// 0 30 9,12,15 1,15 May-Aug Mon,Wed,Fri 2018/2
let s_daysOfMonth = ''
// if (lastDayOfMonth) {
// s_daysOfMonth = 'L'
// } else
if (daysOfMonth.length > 0) {
s_daysOfMonth = daysOfMonth.join(',')
} else {
s_daysOfMonth = '*'
}
let s_months = ''
if (monthsOfYear.length > 0) {
s_months = monthsOfYear.map((m) => m.slice(0, 3).toLowerCase()).join(',')
} else {
s_months = '*'
}
let s_daysOfWeek = ''
if (daysOfWeek.length > 0) {
s_daysOfWeek = daysOfWeek.map((d) => d.slice(0, 3).toLowerCase()).join(',')
} else {
s_daysOfWeek = '*'
}
const s_AtUTCHours = parseInt(UTCTime.split(':')[0]) || '0'
const s_AtUTCMinutes = parseInt(UTCTime.split(':')[1]) || '0'
// If using the basic editor, set the cron string based on the selected options
if (executeEvery === 'second') {
if (seconds > 0) {
nschedule = `*/${seconds} * * * * *`
} else {
nschedule = `* * * * * *`
}
} else if (executeEvery === 'minute') {
if (minutes > 0) {
nschedule = `0 */${minutes} * * * *`
} else {
nschedule = `* * * * * *`
}
} else if (executeEvery === 'hour') {
if (hours > 0) {
nschedule = `0 0 */${hours} * * *`
} else {
nschedule = `* * * * * *`
}
} else if (executeEvery === 'day-month') {
nschedule = `0 ${s_AtUTCMinutes} ${s_AtUTCHours} ${s_daysOfMonth} * *`
} else if (executeEvery === 'month') {
nschedule = `0 ${s_AtUTCMinutes} ${s_AtUTCHours} ${s_daysOfMonth} ${s_months} *`
} else if (executeEvery === 'day-week') {
nschedule = `0 ${s_AtUTCMinutes} ${s_AtUTCHours} * * ${s_daysOfWeek}`
}
})
let dateFormatter = $derived(formatDate(timezone))
</script>
<div class="w-full flex space-x-8">
<div class="w-full flex flex-col gap-4">
<div class="flex flex-col gap-1">
<label for="cron-schedule" class="text-xs font-semibold text-emphasis">Cron</label>
<div class="flex flex-row gap-2 items-center">
<TextInput
inputProps={{
type: 'text',
id: 'cron-schedule',
name: 'cron-schedule',
placeholder: '0 0 */1 * * *',
disabled: disabled
}}
bind:value={schedule}
error={!validCRON}
/>
{#if !disabled}
<div class="flex flex-row gap-2 shrink-0">
{@render cronBuilder()}
<CronGen bind:schedule bind:cronVersion />
</div>
{/if}
</div>
{#if !validCRON}
<div class="text-red-600 text-2xs"> Invalid cron syntax </div>
{/if}
<div class="flex flex-row-reverse text-2xs text-secondary hover:underline">
<a
class="text-primary"
href="https://www.windmill.dev/docs/core_concepts/scheduling#cron-syntax"
target="_blank">Croner</a
>
</div>
</div>
<Label label="Timezone">
{#if disabled}
<div>
<Badge><span class="text-primary dark:text-primary-inverse">{timezone}</span></Badge>
</div>
{:else}
<Select
{items}
class="w-full"
bind:value={timezone}
groupBy={(item) => item.group}
sortBy={(a, b) => {
if (a.group[0] === '+' && b.group[0] === '-') return -1
if (a.group[0] === '-' && b.group[0] === '+') return 1
return a.group.localeCompare(b.group)
}}
/>
{/if}
</Label>
</div>
<div class="w-full flex flex-col space-y-2">
<div class="text-xs font-semibold text-emphasis leading-none"
>Estimated upcoming events ({timezone})</div
>
<div class="flex flex-col space-y-2">
<div class="flex flex-col rounded-md p-4 border text-primary gap-0.5">
{#each preview as date}
<span class="text-xs">{dateFormatter(new Date(date))}</span>
{/each}
</div>
</div>
</div>
</div>
{#snippet cronBuilder()}
<CronBuilder>
{#snippet children({ close })}
<div class="w-full flex flex-col gap-6">
<div class="w-full flex flex-col gap-1">
<div class="text-emphasis font-semibold text-xs leading-none">Execute schedule every</div>
<div class="w-full flex gap-4">
<div class="w-full flex flex-col gap-1 mb-2">
<select {disabled} name="execute_every" id="execute_every" bind:value={executeEvery}>
<option value="second">Second(s)</option>
<option value="minute">Minute(s)</option>
<option value="hour">Hour(s)</option>
<option value="day-month">Day of the month</option>
<option value="month">Month(s)</option>
<option value="day-week">Day of the week</option>
</select>
</div>
<div class="w-full flex flex-col gap-1 justify-center">
{#if executeEvery == 'second'}
<input {disabled} type="number" min="0" max="59" bind:value={seconds} />
<small class="text-secondary text-2xs">Valid range 0-59</small>
{:else if executeEvery == 'minute'}
<input {disabled} type="number" min="0" max="59" bind:value={minutes} />
<small class="text-secondary text-2xs">Valid range 0-59</small>
{:else if executeEvery == 'hour'}
<input {disabled} type="number" min="0" max="23" bind:value={hours} />
<small class="text-secondary text-2xs">Valid range 0-23</small>
{:else if executeEvery == 'day-month'}
<!-- <div class="w-full flex">
<label for="lastDayOfMonth" class="w-full flex items-center gap-2">
<div class="flex">
<input type="checkbox" id="lastDayOfMonth" bind:checked={lastDayOfMonth} />
</div>
<small> Last day of the month </small>
</label>
</div> -->
{/if}
</div>
</div>
</div>
{#if executeEvery == 'month'}
<div class="w-full flex flex-col">
<MultiSelect
disablePortal
{disabled}
bind:value={monthsOfYear}
items={safeSelectItems(monthsOfYearOptions)}
placeholder="Every month"
/>
</div>
{/if}
{#if executeEvery == 'day-week'}
<div class="w-full flex flex-col">
<MultiSelect
disablePortal
{disabled}
bind:value={daysOfWeek}
items={safeSelectItems(daysOfWeekOptions)}
placeholder="Every day"
/>
</div>
{/if}
{#if executeEvery == 'day-month' || executeEvery == 'month'}
<div class="w-full flex flex-col gap-1">
{#if executeEvery == 'month'}
<small class="text-secondary text-2xs font-normal">On day of the month</small>
{/if}
<div class="w-full flex gap-4">
<div class="w-full flex">
<MultiSelect
disablePortal
{disabled}
bind:value={daysOfMonth}
items={safeSelectItems(daysOfMonthOptions)}
placeholder="Every day"
/>
</div>
<!-- {#if executeEvery == 'month'}
<div class="w-full flex">
<label for="lastDayOfMonth" class="w-full flex items-center gap-2">
<div class="flex">
<input type="checkbox" id="lastDayOfMonth" bind:checked={lastDayOfMonth} />
</div>
<small> Last day of the month </small>
</label>
</div>
{/if} -->
</div>
<small class="text-secondary text-2xs font-normal"
>Schedule will only execute on valid calendar days</small
>
</div>
{/if}
{#if executeEvery == 'day-month' || executeEvery == 'month' || executeEvery == 'day-week'}
<div class="w-full flex flex-col gap-1">
<small class="font-semibold text-emphasis text-xs">At time</small>
<input {disabled} type="time" name="atUTCTime" id="atUTCTime" bind:value={UTCTime} />
</div>
{/if}
<div class="w-full flex flex-col gap-1">
<div class="text-emphasis font-semibold text-xs">Preview New Cron</div>
<div class="flex p-2 px-4 rounded-md bg-surface-secondary text-xs text-primary">
<span>{nschedule}</span>
</div>
</div>
</div>
<div class="mt-8">
<Button
variant="accent"
size="xs"
on:click={() => {
schedule = nschedule
close()
}}
>
Set cron schedule
</Button>
</div>
{/snippet}
</CronBuilder>
{/snippet}