diff --git a/cmd/server/openapi/docs.go b/cmd/server/openapi/docs.go index 9d5119c14..9fec75570 100644 --- a/cmd/server/openapi/docs.go +++ b/cmd/server/openapi/docs.go @@ -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": { diff --git a/rpc/proto/woodpecker.pb.go b/rpc/proto/woodpecker.pb.go index 5150d3064..b81001ccb 100644 --- a/rpc/proto/woodpecker.pb.go +++ b/rpc/proto/woodpecker.pb.go @@ -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 diff --git a/rpc/proto/woodpecker_grpc.pb.go b/rpc/proto/woodpecker_grpc.pb.go index 4dfd78a0e..0000eb7fc 100644 --- a/rpc/proto/woodpecker_grpc.pb.go +++ b/rpc/proto/woodpecker_grpc.pb.go @@ -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 diff --git a/server/api/cron.go b/server/api/cron.go index 8acf82952..ccb4f6969 100644 --- a/server/api/cron.go +++ b/server/api/cron.go @@ -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 diff --git a/server/cron/cron.go b/server/cron/cron.go index 48e944035..777be65c9 100644 --- a/server/cron/cron.go +++ b/server/cron/cron.go @@ -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 } diff --git a/server/cron/cron_test.go b/server/cron/cron_test.go index 72525cda5..61ffe2622 100644 --- a/server/cron/cron_test.go +++ b/server/cron/cron_test.go @@ -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()) } diff --git a/server/model/cron.go b/server/model/cron.go index 77c8ddfcc..67130d335 100644 --- a/server/model/cron.go +++ b/server/model/cron.go @@ -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"` diff --git a/web/src/assets/locales/en.json b/web/src/assets/locales/en.json index 20b08aa3b..4847a29ca 100644 --- a/web/src/assets/locales/en.json +++ b/web/src/assets/locales/en.json @@ -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" diff --git a/web/src/compositions/useDate.ts b/web/src/compositions/useDate.ts index 6d47f753e..6f81a9ebb 100644 --- a/web/src/compositions/useDate.ts +++ b/web/src/compositions/useDate.ts @@ -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, }); } diff --git a/web/src/lib/api/types/cron.ts b/web/src/lib/api/types/cron.ts index 1d5c13ced..82ca1ace3 100644 --- a/web/src/lib/api/types/cron.ts +++ b/web/src/lib/api/types/cron.ts @@ -3,6 +3,7 @@ export interface Cron { name: string; branch: string; schedule: string; + timezone: string; enabled: boolean; next_exec: number; variables: Record; diff --git a/web/src/views/repo/settings/Crons.vue b/web/src/views/repo/settings/Crons.vue index f474355bc..d5c42b83a 100644 --- a/web/src/views/repo/settings/Crons.vue +++ b/web/src/views/repo/settings/Crons.vue @@ -11,7 +11,12 @@ :text="$t('repo.settings.crons.show')" @click="selectedCron = undefined" /> -