1
0
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:
qwerty287
2026-05-17 20:26:37 +02:00
committed by GitHub
parent 63a623cdca
commit c64fa98b6e
11 changed files with 103 additions and 26 deletions
+6
View File
@@ -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": {
+1 -1
View File
@@ -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
+2 -2
View File
@@ -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
View File
@@ -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
View File
@@ -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
}
+12 -2
View File
@@ -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())
}
+9 -1
View File
@@ -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"`
+5 -2
View File
@@ -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"
+2 -1
View File
@@ -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,
});
}
+1
View File
@@ -3,6 +3,7 @@ export interface Cron {
name: string;
branch: string;
schedule: string;
timezone: string;
enabled: boolean;
next_exec: number;
variables: Record<string, string>;
+42 -9
View File
@@ -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;