mirror of
https://github.com/tiennm99/goclaw.git
synced 2026-10-15 10:14:51 +00:00
feat(cron): configurable default timezone for cron expressions (#117)
* feat(cron): configurable default timezone for cron expressions Cron expressions (e.g. "0 8 * * *") are evaluated relative to a timezone. Without an explicit per-job timezone, they default to the server's system timezone, which may not match the user's local time — especially in Docker containers (default UTC) or multi-region deployments. This adds a `default_timezone` setting to `CronConfig` (IANA format, e.g. "Asia/Ho_Chi_Minh") that is applied as fallback when a cron job has no explicit `schedule.tz`. The setting is configurable via the UI config page (Integrations → Cron Scheduler) and hot-reloads on config changes. Backend: - Add `DefaultTimezone` field to `CronConfig` - Add `SetDefaultTimezone()` to `CronStore` interface + PG implementation - Apply default TZ in `AddJob()` when `schedule.TZ` is empty - Wire at startup + subscribe to config change events for hot reload - Update cron tool description so LLM knows about gateway default Frontend: - Add timezone dropdown (20 common IANA timezones) to Cron config section - Add i18n keys for en, vi, zh * fix(cron): apply default timezone to existing jobs via computeNextRun Pass defaultTZ as fallback to computeNextRun so existing cron jobs (with timezone = NULL in DB) also use the gateway's configured default timezone when computing next_run_at. This ensures old jobs benefit from the timezone setting without needing a DB migration or backfill. --------- Co-authored-by: Luvu182 <208665161+Luvu182@users.noreply.github.com>
This commit is contained in:
1 parent
d874266e87
commit
25fd9c9d6d
13 files changed
+102
-13
No files matched your search
+16
-1
@@ -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 {
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -57,7 +57,7 @@ SCHEDULE TYPES (schedule.kind):
|
||||
- "every": Recurring interval
|
||||
{ "kind": "every", "everyMs": <interval-ms> }
|
||||
- "cron": Cron expression
|
||||
{ "kind": "cron", "expr": "<5-field cron expression>", "tz": "<optional-timezone>" }
|
||||
{ "kind": "cron", "expr": "<5-field cron expression>", "tz": "<optional IANA timezone, e.g. Asia/Ho_Chi_Minh; omit to use gateway default>" }
|
||||
|
||||
CRITICAL CONSTRAINTS:
|
||||
- name must be a valid slug (lowercase letters, numbers, hyphens only)
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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 的后端",
|
||||
|
||||
@@ -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) {
|
||||
<CardDescription>{t("cron.description")}</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-4">
|
||||
<div className="grid gap-1.5">
|
||||
<InfoLabel tip={t("cron.defaultTimezoneTip")}>{t("cron.defaultTimezone")}</InfoLabel>
|
||||
<Select
|
||||
value={draft.default_timezone || "__system__"}
|
||||
onValueChange={(v) => update({ default_timezone: v === "__system__" ? "" : v })}
|
||||
>
|
||||
<SelectTrigger>
|
||||
<SelectValue placeholder={t("cron.defaultTimezonePlaceholder")} />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="__system__">{t("cron.defaultTimezonePlaceholder")}</SelectItem>
|
||||
{TIMEZONES.map((tz) => (
|
||||
<SelectItem key={tz.value} value={tz.value}>{tz.label}</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-3 gap-4">
|
||||
<div className="grid gap-1.5">
|
||||
<InfoLabel tip={t("cron.maxRetriesTip")}>{t("cron.maxRetries")}</InfoLabel>
|
||||
|
||||
Reference in new issue
Block a user