You've already forked woodpecker
mirror of
https://github.com/woodpecker-ci/woodpecker.git
synced 2026-06-03 16:35:37 +02:00
Add timezone support for crons (#6597)
This commit is contained in:
@@ -4739,6 +4739,9 @@ const docTemplate = `{
|
||||
"description": "@weekly,\t3min, ...",
|
||||
"type": "string"
|
||||
},
|
||||
"timezone": {
|
||||
"type": "string"
|
||||
},
|
||||
"variables": {
|
||||
"type": "object",
|
||||
"additionalProperties": {
|
||||
@@ -4762,6 +4765,9 @@ const docTemplate = `{
|
||||
"schedule": {
|
||||
"type": "string"
|
||||
},
|
||||
"timezone": {
|
||||
"type": "string"
|
||||
},
|
||||
"variables": {
|
||||
"type": "object",
|
||||
"additionalProperties": {
|
||||
|
||||
@@ -16,7 +16,7 @@
|
||||
// Code generated by protoc-gen-go. DO NOT EDIT.
|
||||
// versions:
|
||||
// protoc-gen-go v1.36.11
|
||||
// protoc v6.33.1
|
||||
// protoc v7.34.1
|
||||
// source: woodpecker.proto
|
||||
|
||||
package proto
|
||||
|
||||
@@ -15,8 +15,8 @@
|
||||
|
||||
// Code generated by protoc-gen-go-grpc. DO NOT EDIT.
|
||||
// versions:
|
||||
// - protoc-gen-go-grpc v1.6.1
|
||||
// - protoc v6.33.1
|
||||
// - protoc-gen-go-grpc v1.6.2
|
||||
// - protoc v7.34.1
|
||||
// source: woodpecker.proto
|
||||
|
||||
package proto
|
||||
|
||||
+16
-3
@@ -129,16 +129,20 @@ func PostCron(c *gin.Context) {
|
||||
Name: in.Name,
|
||||
CreatorID: user.ID,
|
||||
Schedule: in.Schedule,
|
||||
Timezone: in.Timezone,
|
||||
Branch: in.Branch,
|
||||
Variables: in.Variables,
|
||||
Enabled: in.Enabled,
|
||||
}
|
||||
if cron.Timezone == "" {
|
||||
cron.Timezone = "UTC"
|
||||
}
|
||||
if err := cron.Validate(); err != nil {
|
||||
c.String(http.StatusUnprocessableEntity, "Error inserting cron. validate failed: %s", err)
|
||||
return
|
||||
}
|
||||
|
||||
nextExec, err := cron_scheduler.CalcNewNext(in.Schedule, time.Now())
|
||||
nextExec, err := cron_scheduler.CalcNewNext(in.Schedule, in.Timezone, time.Now())
|
||||
if err != nil {
|
||||
c.String(http.StatusBadRequest, "Error inserting cron. schedule could not parsed: %s", err)
|
||||
return
|
||||
@@ -214,9 +218,18 @@ func PatchCron(c *gin.Context) {
|
||||
}
|
||||
cron.Branch = *in.Branch
|
||||
}
|
||||
if in.Timezone != nil && *in.Timezone != "" {
|
||||
cron.Timezone = *in.Timezone
|
||||
nextExec, err := cron_scheduler.CalcNewNext(cron.Schedule, cron.Timezone, time.Now())
|
||||
if err != nil {
|
||||
c.String(http.StatusBadRequest, "Error inserting cron. schedule could not parsed: %s", err)
|
||||
return
|
||||
}
|
||||
cron.NextExec = nextExec.Unix()
|
||||
}
|
||||
if in.Schedule != nil && *in.Schedule != "" {
|
||||
cron.Schedule = *in.Schedule
|
||||
nextExec, err := cron_scheduler.CalcNewNext(*in.Schedule, time.Now())
|
||||
nextExec, err := cron_scheduler.CalcNewNext(cron.Schedule, cron.Timezone, time.Now())
|
||||
if err != nil {
|
||||
c.String(http.StatusBadRequest, "Error inserting cron. schedule could not parsed: %s", err)
|
||||
return
|
||||
@@ -230,7 +243,7 @@ func PatchCron(c *gin.Context) {
|
||||
cron.Enabled = *in.Enabled
|
||||
// if we re-enable a cron we have to calc NextExec because it was not while disabled
|
||||
if cron.Enabled {
|
||||
nextExec, err := cron_scheduler.CalcNewNext(*in.Schedule, time.Now())
|
||||
nextExec, err := cron_scheduler.CalcNewNext(cron.Schedule, cron.Timezone, time.Now())
|
||||
if err != nil {
|
||||
c.String(http.StatusInternalServerError, "Cron schedule could not parsed: %s", err)
|
||||
return
|
||||
|
||||
+7
-5
@@ -65,11 +65,13 @@ func Run(ctx context.Context, store store.Store) error {
|
||||
}
|
||||
|
||||
// CalcNewNext parses a cron string and calculates the next exec time based on it.
|
||||
func CalcNewNext(schedule string, now time.Time) (time.Time, error) {
|
||||
// remove local timezone
|
||||
now = now.UTC()
|
||||
func CalcNewNext(schedule, tzLoc string, now time.Time) (time.Time, error) {
|
||||
zone, err := time.LoadLocation(tzLoc)
|
||||
if err != nil {
|
||||
return time.Time{}, err
|
||||
}
|
||||
|
||||
// TODO: allow the users / the admin to set a specific timezone
|
||||
now = now.In(zone)
|
||||
|
||||
parser, err := cron.NewDefaultParser(cron.StandardOptions)
|
||||
if err != nil {
|
||||
@@ -86,7 +88,7 @@ func CalcNewNext(schedule string, now time.Time) (time.Time, error) {
|
||||
func runCron(ctx context.Context, store store.Store, cron *model.Cron, now time.Time) error {
|
||||
log.Trace().Msgf("cron: run id[%d]", cron.ID)
|
||||
|
||||
newNext, err := CalcNewNext(cron.Schedule, now)
|
||||
newNext, err := CalcNewNext(cron.Schedule, cron.Timezone, now)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
@@ -73,10 +73,20 @@ func TestCreatePipeline(t *testing.T) {
|
||||
|
||||
func TestCalcNewNext(t *testing.T) {
|
||||
now := time.Unix(1661962369, 0)
|
||||
_, err := CalcNewNext("", now)
|
||||
_, err := CalcNewNext("", "UTC", now)
|
||||
assert.Error(t, err)
|
||||
|
||||
schedule, err := CalcNewNext("@every 5m", now)
|
||||
schedule, err := CalcNewNext("@every 5m", "UTC", now)
|
||||
assert.NoError(t, err)
|
||||
assert.EqualValues(t, 1661962669, schedule.Unix())
|
||||
|
||||
// test some timezoning
|
||||
schedule, err = CalcNewNext("@midnight", "UTC", now)
|
||||
assert.NoError(t, err)
|
||||
assert.EqualValues(t, 1661990400, schedule.Unix())
|
||||
|
||||
// test some timezoning
|
||||
schedule, err = CalcNewNext("@midnight", "Europe/Bucharest", now)
|
||||
assert.NoError(t, err)
|
||||
assert.EqualValues(t, 1661979600, schedule.Unix())
|
||||
}
|
||||
|
||||
@@ -16,6 +16,7 @@ package model
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"time"
|
||||
|
||||
"github.com/gdgvda/cron"
|
||||
)
|
||||
@@ -27,10 +28,11 @@ type Cron struct {
|
||||
CreatorID int64 `json:"creator_id" xorm:"creator_id INDEX"` // TODO: drop with next major version
|
||||
NextExec int64 `json:"next_exec" xorm:"next_exec"`
|
||||
Schedule string `json:"schedule" xorm:"schedule NOT NULL"` // @weekly, 3min, ...
|
||||
Timezone string `json:"timezone" xorm:"timezone NOT NULL DEFAULT 'UTC'"`
|
||||
Created int64 `json:"created" xorm:"created NOT NULL DEFAULT 0"`
|
||||
Branch string `json:"branch" xorm:"branch"`
|
||||
Enabled bool `json:"enabled" xorm:"enabled NOT NULL DEFAULT TRUE"`
|
||||
Variables map[string]string `json:"variables" xorm:"json 'variables'"`
|
||||
Variables map[string]string `json:"variables" xorm:"json 'variables'"`
|
||||
} // @name Cron
|
||||
|
||||
// TableName returns the database table name for xorm.
|
||||
@@ -58,12 +60,18 @@ func (c *Cron) Validate() error {
|
||||
return fmt.Errorf("can't parse schedule: %w", err)
|
||||
}
|
||||
|
||||
_, err = time.LoadLocation(c.Timezone)
|
||||
if err != nil {
|
||||
return fmt.Errorf("can't parse timezone: %w", err)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
type CronPatch struct {
|
||||
Name *string `json:"name"`
|
||||
Schedule *string `json:"schedule"`
|
||||
Timezone *string `json:"timezone"`
|
||||
Branch *string `json:"branch"`
|
||||
Enabled *bool `json:"enabled"`
|
||||
Variables map[string]string `json:"variables"`
|
||||
|
||||
@@ -157,7 +157,8 @@
|
||||
"created": "Cron created",
|
||||
"saved": "Cron saved",
|
||||
"deleted": "Cron deleted",
|
||||
"next_exec": "Next execution",
|
||||
"next_exec_local": "Next execution: {local}",
|
||||
"next_exec_both": "Next execution: {local} ({zoned} {timezone})",
|
||||
"not_executed_yet": "Not executed yet",
|
||||
"run": "Run now",
|
||||
"branch": {
|
||||
@@ -169,9 +170,11 @@
|
||||
"placeholder": "Name of the cron job"
|
||||
},
|
||||
"schedule": {
|
||||
"title": "Schedule (based on UTC)",
|
||||
"title": "Schedule",
|
||||
"placeholder": "Schedule"
|
||||
},
|
||||
"timezone": "Timezone",
|
||||
"your_timezone": "In your browser's timezone.",
|
||||
"edit": "Edit cron",
|
||||
"delete": "Delete cron",
|
||||
"enabled": "Enabled"
|
||||
|
||||
@@ -21,10 +21,11 @@ function splitDuration(durationMs: number) {
|
||||
};
|
||||
}
|
||||
|
||||
function toLocaleString(date: Date) {
|
||||
function toLocaleString(date: Date, tz?: string) {
|
||||
return date.toLocaleString(currentLocale, {
|
||||
dateStyle: 'short',
|
||||
timeStyle: 'short',
|
||||
timeZone: tz,
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@@ -3,6 +3,7 @@ export interface Cron {
|
||||
name: string;
|
||||
branch: string;
|
||||
schedule: string;
|
||||
timezone: string;
|
||||
enabled: boolean;
|
||||
next_exec: number;
|
||||
variables: Record<string, string>;
|
||||
|
||||
@@ -11,7 +11,12 @@
|
||||
:text="$t('repo.settings.crons.show')"
|
||||
@click="selectedCron = undefined"
|
||||
/>
|
||||
<Button v-else start-icon="plus" :text="$t('repo.settings.crons.add')" @click="selectedCron = {}" />
|
||||
<Button
|
||||
v-else
|
||||
start-icon="plus"
|
||||
:text="$t('repo.settings.crons.add')"
|
||||
@click="selectedCron = { timezone: 'UTC' }"
|
||||
/>
|
||||
</template>
|
||||
|
||||
<div v-if="!selectedCron" class="text-wp-text-100 space-y-4">
|
||||
@@ -22,13 +27,19 @@
|
||||
>
|
||||
<span class="grid w-full grid-cols-3">
|
||||
<span>{{ cron.name }}</span>
|
||||
<span v-if="cron.next_exec && cron.next_exec > 0" class="md:display-unset col-span-2 hidden">
|
||||
<!-- eslint-disable-next-line @intlify/vue-i18n/no-raw-text -->
|
||||
{{ $t('repo.settings.crons.next_exec') }}: {{ date.toLocaleString(new Date(cron.next_exec * 1000)) }}
|
||||
<span
|
||||
v-if="cron.enabled && cron.next_exec && cron.next_exec > 0"
|
||||
:title="$t('repo.settings.crons.your_timezone')"
|
||||
class="md:display-unset col-span-2 hidden"
|
||||
>
|
||||
{{
|
||||
$t('repo.settings.crons.next_exec_local', { local: date.toLocaleString(new Date(cron.next_exec * 1000)) })
|
||||
}}
|
||||
</span>
|
||||
<span v-else class="md:display-unset col-span-2 hidden">{{
|
||||
<span v-else-if="cron.enabled" class="md:display-unset col-span-2 hidden">{{
|
||||
$t('repo.settings.crons.not_executed_yet')
|
||||
}}</span>
|
||||
<span v-else class="md:display-unset col-span-2 hidden">{{ $t('disabled') }}</span>
|
||||
</span>
|
||||
<div class="flex items-center gap-2">
|
||||
<IconButton
|
||||
@@ -80,6 +91,10 @@
|
||||
/>
|
||||
</InputField>
|
||||
|
||||
<InputField v-slot="{ id }" :label="$t('repo.settings.crons.timezone')">
|
||||
<SelectField :id="id" v-model="selectedCronTimezone" :options="timezones" />
|
||||
</InputField>
|
||||
|
||||
<InputField
|
||||
v-slot="{ id }"
|
||||
:label="$t('repo.settings.crons.schedule.title')"
|
||||
@@ -93,11 +108,15 @@
|
||||
/>
|
||||
</InputField>
|
||||
|
||||
<div v-if="isEditingCron" class="mb-4 ml-auto">
|
||||
<div v-if="isEditingCron && selectedCronEnabled" class="mb-4 ml-auto">
|
||||
<span v-if="selectedCron.next_exec && selectedCron.next_exec > 0" class="text-wp-text-100">
|
||||
<!-- eslint-disable-next-line @intlify/vue-i18n/no-raw-text -->
|
||||
{{ $t('repo.settings.crons.next_exec') }}:
|
||||
{{ date.toLocaleString(new Date(selectedCron.next_exec * 1000)) }}
|
||||
{{
|
||||
$t('repo.settings.crons.next_exec_both', {
|
||||
local: date.toLocaleString(new Date(selectedCron.next_exec * 1000)),
|
||||
zoned: date.toLocaleString(new Date(selectedCron.next_exec * 1000), selectedCron.timezone),
|
||||
timezone: selectedCron.timezone,
|
||||
})
|
||||
}}
|
||||
</span>
|
||||
<span v-else class="text-wp-text-100">{{ $t('repo.settings.crons.not_executed_yet') }}</span>
|
||||
</div>
|
||||
@@ -140,6 +159,7 @@ import ListItem from '~/components/atomic/ListItem.vue';
|
||||
import Checkbox from '~/components/form/Checkbox.vue';
|
||||
import InputField from '~/components/form/InputField.vue';
|
||||
import KeyValueEditor from '~/components/form/KeyValueEditor.vue';
|
||||
import SelectField from '~/components/form/SelectField.vue';
|
||||
import TextField from '~/components/form/TextField.vue';
|
||||
import Settings from '~/components/layout/Settings.vue';
|
||||
import useApiClient from '~/compositions/useApiClient';
|
||||
@@ -161,6 +181,19 @@ const selectedCron = ref<Partial<Cron>>();
|
||||
const isEditingCron = computed(() => !!selectedCron.value?.id);
|
||||
const date = useDate();
|
||||
|
||||
const timezones = Intl.supportedValuesOf('timeZone').map((tz) => ({
|
||||
value: tz,
|
||||
text: tz,
|
||||
}));
|
||||
|
||||
const selectedCronTimezone = computed<string>({
|
||||
async set(tz) {
|
||||
selectedCron.value!.timezone = tz;
|
||||
},
|
||||
get() {
|
||||
return selectedCron.value!.timezone ?? 'UTC';
|
||||
},
|
||||
});
|
||||
const selectedCronVariables = computed<Record<string, string>>({
|
||||
async set(_vars) {
|
||||
selectedCron.value!.variables = _vars;
|
||||
|
||||
Reference in New Issue
Block a user