diff --git a/cmd/gateway.go b/cmd/gateway.go index eb29f780..b38e1bd1 100644 --- a/cmd/gateway.go +++ b/cmd/gateway.go @@ -295,11 +295,14 @@ func runGateway() { redisClient := initRedisClient(cfg) defer shutdownRedis(redisClient) - // Wire cron retry config from config.json + // Wire cron config from config.json cronRetryCfg := cfg.Cron.ToRetryConfig() // Apply retry config via type assertion on the concrete cron store. pgStores.Cron.SetOnJob(nil) // ensure initialized; actual handler set below _ = cronRetryCfg // config available; pg cron store reads it internally + if cfg.Cron.DefaultTimezone != "" { + pgStores.Cron.SetDefaultTimezone(cfg.Cron.DefaultTimezone) + } // Load secrets from config_secrets table before env overrides. // Precedence: config.json → DB secrets → env vars (highest). @@ -843,6 +846,18 @@ func runGateway() { }) } + // Reload cron default timezone on config changes via pub/sub. + msgBus.Subscribe("cron-config-reload", func(evt bus.Event) { + if evt.Name != bus.TopicConfigChanged { + return + } + updatedCfg, ok := evt.Payload.(*config.Config) + if !ok { + return + } + pgStores.Cron.SetDefaultTimezone(updatedCfg.Cron.DefaultTimezone) + }) + // Reload web_fetch domain policy on config changes via pub/sub. msgBus.Subscribe("webfetch-config-reload", func(evt bus.Event) { if evt.Name != bus.TopicConfigChanged { diff --git a/internal/config/config.go b/internal/config/config.go index bff5b475..74be61d0 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -310,9 +310,10 @@ type TelemetryConfig struct { // CronConfig configures the cron job system. type CronConfig struct { - MaxRetries int `json:"max_retries,omitempty"` // max retry attempts on failure (default 3, 0 = no retry) - RetryBaseDelay string `json:"retry_base_delay,omitempty"` // initial backoff delay (default "2s", Go duration) - RetryMaxDelay string `json:"retry_max_delay,omitempty"` // maximum backoff delay (default "30s", Go duration) + MaxRetries int `json:"max_retries,omitempty"` // max retry attempts on failure (default 3, 0 = no retry) + RetryBaseDelay string `json:"retry_base_delay,omitempty"` // initial backoff delay (default "2s", Go duration) + RetryMaxDelay string `json:"retry_max_delay,omitempty"` // maximum backoff delay (default "30s", Go duration) + DefaultTimezone string `json:"default_timezone,omitempty"` // IANA timezone for cron expressions when not set per-job (e.g. "Asia/Ho_Chi_Minh") } // ToRetryConfig converts CronConfig to cron.RetryConfig with defaults applied. diff --git a/internal/store/cron_store.go b/internal/store/cron_store.go index 29a3abda..9f53fcc4 100644 --- a/internal/store/cron_store.go +++ b/internal/store/cron_store.go @@ -105,6 +105,7 @@ type CronStore interface { SetOnJob(handler func(job *CronJob) (*CronJobResult, error)) SetOnEvent(handler func(event CronEvent)) RunJob(jobID string, force bool) (ran bool, reason string, err error) + SetDefaultTimezone(tz string) // Due job detection (for scheduler) GetDueJobs(now time.Time) []CronJob diff --git a/internal/store/pg/cron.go b/internal/store/pg/cron.go index e3f9bc48..882e9ece 100644 --- a/internal/store/pg/cron.go +++ b/internal/store/pg/cron.go @@ -28,7 +28,8 @@ type PGCronStore struct { cacheTime time.Time cacheTTL time.Duration - retryCfg cron.RetryConfig + retryCfg cron.RetryConfig + defaultTZ string // fallback IANA timezone for cron jobs without explicit TZ } func NewPGCronStore(db *sql.DB) *PGCronStore { @@ -42,6 +43,14 @@ func (s *PGCronStore) SetRetryConfig(cfg cron.RetryConfig) { s.retryCfg = cfg } +// SetDefaultTimezone sets the fallback IANA timezone for cron expressions +// when a job does not specify its own timezone. +func (s *PGCronStore) SetDefaultTimezone(tz string) { + s.mu.Lock() + defer s.mu.Unlock() + s.defaultTZ = tz +} + func (s *PGCronStore) Start() error { s.mu.Lock() defer s.mu.Unlock() diff --git a/internal/store/pg/cron_crud.go b/internal/store/pg/cron_crud.go index 34a44336..4e695ab7 100644 --- a/internal/store/pg/cron_crud.go +++ b/internal/store/pg/cron_crud.go @@ -11,6 +11,10 @@ import ( ) func (s *PGCronStore) AddJob(name string, schedule store.CronSchedule, message string, deliver bool, channel, to, agentID, userID string) (*store.CronJob, error) { + // Apply default timezone for cron expressions when not set per-job. + if schedule.TZ == "" && schedule.Kind == "cron" && s.defaultTZ != "" { + schedule.TZ = s.defaultTZ + } if schedule.TZ != "" { if _, err := time.LoadLocation(schedule.TZ); err != nil { return nil, fmt.Errorf("invalid timezone: %s", schedule.TZ) @@ -58,7 +62,7 @@ func (s *PGCronStore) AddJob(name string, schedule store.CronSchedule, message s intervalMS = schedule.EveryMS } - nextRun := computeNextRun(&schedule, now) + nextRun := computeNextRun(&schedule, now, s.defaultTZ) _, err := s.db.Exec( `INSERT INTO cron_jobs (id, agent_id, user_id, name, enabled, schedule_kind, cron_expression, run_at, timezone, diff --git a/internal/store/pg/cron_scan.go b/internal/store/pg/cron_scan.go index 1cd36cdb..8d9d7ff0 100644 --- a/internal/store/pg/cron_scan.go +++ b/internal/store/pg/cron_scan.go @@ -103,7 +103,10 @@ func scanCronSingleRow(row *sql.Row) (*store.CronJob, error) { // --- Helpers --- -func computeNextRun(schedule *store.CronSchedule, now time.Time) *time.Time { +// computeNextRun calculates the next run time for a schedule. +// defaultTZ is the gateway-level fallback IANA timezone used when the +// schedule itself does not specify a timezone (existing jobs with TZ = NULL). +func computeNextRun(schedule *store.CronSchedule, now time.Time, defaultTZ string) *time.Time { switch schedule.Kind { case "at": if schedule.AtMS != nil { @@ -123,9 +126,13 @@ func computeNextRun(schedule *store.CronSchedule, now time.Time) *time.Time { if schedule.Expr == "" { return nil } + tz := schedule.TZ + if tz == "" { + tz = defaultTZ + } evalTime := now - if schedule.TZ != "" { - if loc, err := time.LoadLocation(schedule.TZ); err == nil { + if tz != "" { + if loc, err := time.LoadLocation(tz); err == nil { evalTime = now.In(loc) } } diff --git a/internal/store/pg/cron_scheduler.go b/internal/store/pg/cron_scheduler.go index 5663aa0c..442a530c 100644 --- a/internal/store/pg/cron_scheduler.go +++ b/internal/store/pg/cron_scheduler.go @@ -102,7 +102,7 @@ func (s *PGCronStore) recomputeStaleJobs() { schedule.TZ = *tz } - next := computeNextRun(&schedule, now) + next := computeNextRun(&schedule, now, s.defaultTZ) if next == nil { if scheduleKind == "at" { s.db.Exec("UPDATE cron_jobs SET enabled = false, updated_at = $1 WHERE id = $2", now, id) @@ -236,7 +236,7 @@ func (s *PGCronStore) executeOneJob(job store.CronJob, handler func(job *store.C } } else if id, parseErr := uuid.Parse(job.ID); parseErr == nil { schedule := job.Schedule - next := computeNextRun(&schedule, now) + next := computeNextRun(&schedule, now, s.defaultTZ) s.db.Exec( "UPDATE cron_jobs SET last_run_at = $1, last_status = $2, last_error = $3, next_run_at = $4, updated_at = $5 WHERE id = $6", now, status, lastError, next, now, id, diff --git a/internal/store/pg/cron_update.go b/internal/store/pg/cron_update.go index 525f765d..150857be 100644 --- a/internal/store/pg/cron_update.go +++ b/internal/store/pg/cron_update.go @@ -131,7 +131,7 @@ func (s *PGCronStore) UpdateJob(jobID string, patch store.CronJobPatch) (*store. } } - next := computeNextRun(&merged, time.Now()) + next := computeNextRun(&merged, time.Now(), s.defaultTZ) updates["next_run_at"] = next } if patch.DeleteAfterRun != nil { diff --git a/internal/tools/cron.go b/internal/tools/cron.go index 3d84d99a..adbd65f1 100644 --- a/internal/tools/cron.go +++ b/internal/tools/cron.go @@ -57,7 +57,7 @@ SCHEDULE TYPES (schedule.kind): - "every": Recurring interval { "kind": "every", "everyMs": } - "cron": Cron expression - { "kind": "cron", "expr": "<5-field cron expression>", "tz": "" } + { "kind": "cron", "expr": "<5-field cron expression>", "tz": "" } CRITICAL CONSTRAINTS: - name must be a valid slug (lowercase letters, numbers, hyphens only) diff --git a/ui/web/src/i18n/locales/en/config.json b/ui/web/src/i18n/locales/en/config.json index 7c58cf9f..fa02b08c 100644 --- a/ui/web/src/i18n/locales/en/config.json +++ b/ui/web/src/i18n/locales/en/config.json @@ -232,6 +232,9 @@ "cron.baseDelayTip": "Initial backoff delay between retries (e.g. 2s, 500ms).", "cron.maxDelay": "Max Delay", "cron.maxDelayTip": "Maximum backoff delay between retries (e.g. 30s, 5m).", + "cron.defaultTimezone": "Default Timezone", + "cron.defaultTimezoneTip": "IANA timezone for cron expressions when not set per-job (e.g. Asia/Ho_Chi_Minh). Leave empty to use server system timezone.", + "cron.defaultTimezonePlaceholder": "System timezone", "telemetry.title": "Telemetry (OpenTelemetry)", "telemetry.description": "Export traces and metrics to an OpenTelemetry-compatible backend", diff --git a/ui/web/src/i18n/locales/vi/config.json b/ui/web/src/i18n/locales/vi/config.json index 6e044dde..cc9a8d37 100644 --- a/ui/web/src/i18n/locales/vi/config.json +++ b/ui/web/src/i18n/locales/vi/config.json @@ -232,6 +232,9 @@ "cron.baseDelayTip": "Độ trễ backoff ban đầu giữa các lần thử lại (vd: 2s, 500ms).", "cron.maxDelay": "Độ trễ tối đa", "cron.maxDelayTip": "Độ trễ backoff tối đa giữa các lần thử lại (vd: 30s, 5m).", + "cron.defaultTimezone": "Múi giờ mặc định", + "cron.defaultTimezoneTip": "Múi giờ IANA cho biểu thức cron khi không được đặt cho từng job (vd: Asia/Ho_Chi_Minh). Để trống sẽ dùng múi giờ hệ thống.", + "cron.defaultTimezonePlaceholder": "Múi giờ hệ thống", "telemetry.title": "Telemetry (OpenTelemetry)", "telemetry.description": "Xuất trace và metric sang backend tương thích OpenTelemetry", diff --git a/ui/web/src/i18n/locales/zh/config.json b/ui/web/src/i18n/locales/zh/config.json index dd7efa3a..04c878ba 100644 --- a/ui/web/src/i18n/locales/zh/config.json +++ b/ui/web/src/i18n/locales/zh/config.json @@ -232,6 +232,9 @@ "cron.baseDelayTip": "重试之间的初始退避延迟(例如 2s、500ms)。", "cron.maxDelay": "最大延迟", "cron.maxDelayTip": "重试之间的最大退避延迟(例如 30s、5m)。", + "cron.defaultTimezone": "默认时区", + "cron.defaultTimezoneTip": "未为单个任务设置时区时,cron 表达式使用的 IANA 时区(例如 Asia/Shanghai)。留空则使用服务器系统时区。", + "cron.defaultTimezonePlaceholder": "系统时区", "telemetry.title": "遥测(OpenTelemetry)", "telemetry.description": "将追踪和指标导出到兼容 OpenTelemetry 的后端", diff --git a/ui/web/src/pages/config/sections/cron-section.tsx b/ui/web/src/pages/config/sections/cron-section.tsx index 628ea152..e2df1aff 100644 --- a/ui/web/src/pages/config/sections/cron-section.tsx +++ b/ui/web/src/pages/config/sections/cron-section.tsx @@ -3,13 +3,38 @@ import { Save } from "lucide-react"; import { useTranslation } from "react-i18next"; import { Button } from "@/components/ui/button"; import { Input } from "@/components/ui/input"; +import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select"; import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card"; import { InfoLabel } from "@/components/shared/info-label"; +const TIMEZONES = [ + { value: "UTC", label: "UTC" }, + { value: "Asia/Ho_Chi_Minh", label: "Asia/Ho_Chi_Minh (UTC+7)" }, + { value: "Asia/Bangkok", label: "Asia/Bangkok (UTC+7)" }, + { value: "Asia/Shanghai", label: "Asia/Shanghai (UTC+8)" }, + { value: "Asia/Tokyo", label: "Asia/Tokyo (UTC+9)" }, + { value: "Asia/Seoul", label: "Asia/Seoul (UTC+9)" }, + { value: "Asia/Singapore", label: "Asia/Singapore (UTC+8)" }, + { value: "Asia/Kolkata", label: "Asia/Kolkata (UTC+5:30)" }, + { value: "Asia/Dubai", label: "Asia/Dubai (UTC+4)" }, + { value: "Europe/London", label: "Europe/London (UTC+0)" }, + { value: "Europe/Paris", label: "Europe/Paris (UTC+1)" }, + { value: "Europe/Berlin", label: "Europe/Berlin (UTC+1)" }, + { value: "Europe/Moscow", label: "Europe/Moscow (UTC+3)" }, + { value: "America/New_York", label: "America/New_York (UTC-5)" }, + { value: "America/Chicago", label: "America/Chicago (UTC-6)" }, + { value: "America/Denver", label: "America/Denver (UTC-7)" }, + { value: "America/Los_Angeles", label: "America/Los_Angeles (UTC-8)" }, + { value: "America/Sao_Paulo", label: "America/Sao_Paulo (UTC-3)" }, + { value: "Australia/Sydney", label: "Australia/Sydney (UTC+11)" }, + { value: "Pacific/Auckland", label: "Pacific/Auckland (UTC+13)" }, +]; + interface CronData { max_retries?: number; retry_base_delay?: string; retry_max_delay?: string; + default_timezone?: string; } const DEFAULT: CronData = {}; @@ -44,6 +69,24 @@ export function CronSection({ data, onSave, saving }: Props) { {t("cron.description")} +
+ {t("cron.defaultTimezone")} + +
+
{t("cron.maxRetries")}