diff --git a/api/projects/schedules.go b/api/projects/schedules.go index f0e3a6711..549d41b7e 100644 --- a/api/projects/schedules.go +++ b/api/projects/schedules.go @@ -8,6 +8,7 @@ import ( "github.com/semaphoreui/semaphore/api/helpers" "github.com/semaphoreui/semaphore/db" "github.com/semaphoreui/semaphore/services/schedules" + "github.com/semaphoreui/semaphore/util" ) // SchedulesMiddleware ensures a template exists and loads it to the context @@ -126,6 +127,57 @@ func ValidateScheduleCronFormat(w http.ResponseWriter, r *http.Request) { _ = validateCronFormat(schedule.CronFormat, w) } +func GetNextRunTime(w http.ResponseWriter, r *http.Request) { + var req struct { + CronFormat string `json:"cron_format"` + } + if !helpers.Bind(w, r, &req) { + return + } + + if req.CronFormat == "" { + helpers.WriteJSON(w, http.StatusBadRequest, map[string]string{ + "error": "cron_format is required", + }) + return + } + + schedule, err := schedules.ParseCronAndSemantics(req.CronFormat) + if err != nil { + helpers.WriteJSON(w, http.StatusBadRequest, map[string]string{ + "error": "Invalid cron format: " + err.Error(), + }) + return + } + + timezone := util.Config.Schedule.Timezone + if timezone == "" { + timezone = "UTC" + } + + loc, err := time.LoadLocation(timezone) + if err != nil { + helpers.WriteJSON(w, http.StatusBadRequest, map[string]string{ + "error": "Invalid timezone: " + err.Error(), + }) + return + } + + now := time.Now().In(loc) + nextRun := schedule.Next(now) + + if nextRun.IsZero() { + helpers.WriteJSON(w, http.StatusOK, map[string]string{ + "next_run_time": "", + }) + return + } + + helpers.WriteJSON(w, http.StatusOK, map[string]string{ + "next_run_time": nextRun.UTC().Format(time.RFC3339), + }) +} + // AddSchedule adds a template to the database func AddSchedule(w http.ResponseWriter, r *http.Request) { project := helpers.GetFromContext(r, "project").(db.Project) diff --git a/api/router.go b/api/router.go index a26763d3a..df7e51e70 100644 --- a/api/router.go +++ b/api/router.go @@ -320,6 +320,7 @@ func Route( projectUserAPI.Path("/schedules").HandlerFunc(projects.GetProjectSchedules).Methods("GET", "HEAD") projectUserAPI.Path("/schedules").HandlerFunc(projects.AddSchedule).Methods("POST") projectUserAPI.Path("/schedules/validate").HandlerFunc(projects.ValidateScheduleCronFormat).Methods("POST") + projectUserAPI.Path("/schedules/next").HandlerFunc(projects.GetNextRunTime).Methods("POST") projectUserAPI.Path("/views").HandlerFunc(projects.GetViews).Methods("GET", "HEAD") projectUserAPI.Path("/views").HandlerFunc(projects.AddView).Methods("POST") diff --git a/services/schedules/SchedulePool.go b/services/schedules/SchedulePool.go index 4dacad1fc..35c9c63f5 100644 --- a/services/schedules/SchedulePool.go +++ b/services/schedules/SchedulePool.go @@ -350,12 +350,12 @@ func (p *SchedulePool) Refresh() { } func (p *SchedulePool) addRunner(runner ScheduleRunner, cronFormat string) (int, error) { - id, err := p.cron.AddJob(cronFormat, runner) - + schedule, err := ParseCronAndSemantics(cronFormat) if err != nil { return 0, err } + id := p.cron.Schedule(schedule, runner) return int(id), nil } @@ -405,3 +405,56 @@ func ValidateCronFormat(cronFormat string) error { _, err := cron.ParseStandard(cronFormat) return err } + +// andSchedule wraps a cron.SpecSchedule so that day-of-month and day-of-week +// are combined with AND instead of the POSIX OR that robfig/cron implements. +// For example "0 6 25-31 * 6" fires only on Saturdays within the 25-31 range +// (the last Saturday of each month), not on every Saturday OR every 25th-31st. +// +// When either field is * (starBit set), robfig/cron already uses AND, so the +// wrapper delegates directly. +type andSchedule struct { + spec *cron.SpecSchedule +} + +const cronStarBit = 1 << 63 + +func (s *andSchedule) Next(t time.Time) time.Time { + if s.spec.Dom&cronStarBit != 0 || s.spec.Dow&cronStarBit != 0 { + return s.spec.Next(t) + } + + limit := t.AddDate(4, 0, 0) + candidate := t + for candidate.Before(limit) { + next := s.spec.Next(candidate) + if next.IsZero() || next.After(limit) { + break + } + + domMatch := s.spec.Dom&(1<