From dea7f361f26d081c674c6bd702b3e6301b12d79e Mon Sep 17 00:00:00 2001 From: lpf Date: Thu, 26 Feb 2026 12:26:04 +0800 Subject: [PATCH] fix cron --- cmd/clawgo/cmd_gateway.go | 117 ++++++++++++------------ go.mod | 1 + go.sum | 2 + pkg/cron/cron_expr.go | 53 ----------- pkg/cron/service.go | 163 ++++++++++++++++++++++++++++------ pkg/nodes/registry_server.go | 77 +++++++++++----- webui/server.ts | 8 +- webui/src/pages/Cron.tsx | 166 ++++++++++++++--------------------- webui/src/types/index.ts | 2 - 9 files changed, 321 insertions(+), 268 deletions(-) delete mode 100644 pkg/cron/cron_expr.go diff --git a/cmd/clawgo/cmd_gateway.go b/cmd/clawgo/cmd_gateway.go index ad53bf1..e98395a 100644 --- a/cmd/clawgo/cmd_gateway.go +++ b/cmd/clawgo/cmd_gateway.go @@ -237,42 +237,38 @@ func gatewayCmd() { if name == "" { name = "webui-cron" } - kind := getStr("kind") - if kind == "" { - kind = "cron" - } - if kind == "once" { - kind = "at" - } msg := getStr("message") if msg == "" { return nil, fmt.Errorf("message required") } - schedule := cron.CronSchedule{Kind: kind} - if kind == "every" { - everyMS, ok := args["everyMs"].(float64) - if !ok || int64(everyMS) <= 0 { - return nil, fmt.Errorf("everyMs required for kind=every") - } - ev := int64(everyMS) - schedule.EveryMS = &ev - } - if kind == "at" { - atMS, ok := args["atMs"].(float64) - var at int64 - if !ok || int64(atMS) <= 0 { - at = time.Now().Add(1 * time.Minute).UnixMilli() - } else { - at = int64(atMS) - } - schedule.AtMS = &at - } - if kind == "cron" { - expr := getStr("expr") - if expr == "" { - expr = "*/10 * * * *" - } + schedule := cron.CronSchedule{} + if expr := getStr("expr"); expr != "" { schedule.Expr = expr + } else { + // Backward compatibility for older clients. + kind := strings.ToLower(getStr("kind")) + switch kind { + case "every": + everyMS, ok := args["everyMs"].(float64) + if !ok || int64(everyMS) <= 0 { + return nil, fmt.Errorf("expr required") + } + ev := int64(everyMS) + schedule.Kind = "every" + schedule.EveryMS = &ev + case "once", "at": + atMS, ok := args["atMs"].(float64) + var at int64 + if !ok || int64(atMS) <= 0 { + at = time.Now().Add(1 * time.Minute).UnixMilli() + } else { + at = int64(atMS) + } + schedule.Kind = "at" + schedule.AtMS = &at + default: + return nil, fmt.Errorf("expr required") + } } deliver := false if v, ok := args["deliver"].(bool); ok { @@ -303,18 +299,22 @@ func gatewayCmd() { if v := getStr("to"); v != "" { in.To = &v } - if kind := getStr("kind"); kind != "" { - if kind == "once" { - kind = "at" - } + if expr := getStr("expr"); expr != "" { + s := cron.CronSchedule{Expr: expr} + in.Schedule = &s + } else if kind := strings.ToLower(getStr("kind")); kind != "" { + // Backward compatibility for older clients. s := cron.CronSchedule{Kind: kind} - if kind == "every" { + switch kind { + case "every": if everyMS, ok := args["everyMs"].(float64); ok && int64(everyMS) > 0 { ev := int64(everyMS) s.EveryMS = &ev + } else { + return nil, fmt.Errorf("expr required") } - } - if kind == "at" { + case "once", "at": + s.Kind = "at" if atMS, ok := args["atMs"].(float64); ok && int64(atMS) > 0 { at := int64(atMS) s.AtMS = &at @@ -322,13 +322,8 @@ func gatewayCmd() { at := time.Now().Add(1 * time.Minute).UnixMilli() s.AtMS = &at } - } - if kind == "cron" { - expr := getStr("expr") - if expr == "" { - return nil, fmt.Errorf("expr required for kind=cron") - } - s.Expr = expr + default: + return nil, fmt.Errorf("expr required") } in.Schedule = &s } @@ -990,22 +985,22 @@ func buildHeartbeatService(cfg *config.Config, msgBus *bus.MessageBus) *heartbea func buildAutonomyEngine(cfg *config.Config, msgBus *bus.MessageBus) *autonomy.Engine { a := cfg.Agents.Defaults.Autonomy return autonomy.NewEngine(autonomy.Options{ - Enabled: a.Enabled, - TickIntervalSec: a.TickIntervalSec, - MinRunIntervalSec: a.MinRunIntervalSec, - MaxPendingDurationSec: a.MaxPendingDurationSec, - MaxConsecutiveStalls: a.MaxConsecutiveStalls, - MaxDispatchPerTick: a.MaxDispatchPerTick, - NotifyCooldownSec: a.NotifyCooldownSec, + Enabled: a.Enabled, + TickIntervalSec: a.TickIntervalSec, + MinRunIntervalSec: a.MinRunIntervalSec, + MaxPendingDurationSec: a.MaxPendingDurationSec, + MaxConsecutiveStalls: a.MaxConsecutiveStalls, + MaxDispatchPerTick: a.MaxDispatchPerTick, + NotifyCooldownSec: a.NotifyCooldownSec, NotifySameReasonCooldownSec: a.NotifySameReasonCooldownSec, - QuietHours: a.QuietHours, - UserIdleResumeSec: a.UserIdleResumeSec, - WaitingResumeDebounceSec: a.WaitingResumeDebounceSec, - ImportantKeywords: cfg.Agents.Defaults.Texts.AutonomyImportantKeywords, - CompletionTemplate: cfg.Agents.Defaults.Texts.AutonomyCompletionTemplate, - BlockedTemplate: cfg.Agents.Defaults.Texts.AutonomyBlockedTemplate, - Workspace: cfg.WorkspacePath(), - DefaultNotifyChannel: a.NotifyChannel, - DefaultNotifyChatID: a.NotifyChatID, + QuietHours: a.QuietHours, + UserIdleResumeSec: a.UserIdleResumeSec, + WaitingResumeDebounceSec: a.WaitingResumeDebounceSec, + ImportantKeywords: cfg.Agents.Defaults.Texts.AutonomyImportantKeywords, + CompletionTemplate: cfg.Agents.Defaults.Texts.AutonomyCompletionTemplate, + BlockedTemplate: cfg.Agents.Defaults.Texts.AutonomyBlockedTemplate, + Workspace: cfg.WorkspacePath(), + DefaultNotifyChannel: a.NotifyChannel, + DefaultNotifyChatID: a.NotifyChatID, }, msgBus) } diff --git a/go.mod b/go.mod index fed9881..50d7c58 100644 --- a/go.mod +++ b/go.mod @@ -10,6 +10,7 @@ require ( github.com/larksuite/oapi-sdk-go/v3 v3.5.3 github.com/mymmrac/telego v1.6.0 github.com/open-dingtalk/dingtalk-stream-sdk-go v0.9.1 + github.com/robfig/cron/v3 v3.0.1 github.com/tencent-connect/botgo v0.2.1 golang.org/x/oauth2 v0.35.0 golang.org/x/sync v0.19.0 diff --git a/go.sum b/go.sum index bbec4f9..c9ac220 100644 --- a/go.sum +++ b/go.sum @@ -89,6 +89,8 @@ github.com/open-dingtalk/dingtalk-stream-sdk-go v0.9.1/go.mod h1:ln3IqPYYocZbYvl github.com/pkg/diff v0.0.0-20210226163009-20ebb0f2a09e/go.mod h1:pJLUxLENpZxwdsKMEsNbx1VGcRFpLqf3715MtcvvzbA= github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= +github.com/robfig/cron/v3 v3.0.1 h1:WdRxkvbJztn8LMz/QEvLN5sBU+xKpSqwwUO1Pjr4qDs= +github.com/robfig/cron/v3 v3.0.1/go.mod h1:eQICP3HwyT7UooqI/z+Ov+PtYAWygg1TEWWzGIFLtro= github.com/rogpeppe/go-internal v1.6.1/go.mod h1:xXDCJY+GAPziupqXw64V24skbSoqbTEfhy4qGm1nDQc= github.com/rogpeppe/go-internal v1.9.0/go.mod h1:WtVeX8xhTBvf0smdhujwtBcq4Qrzq/fJaraNFVN+nFs= github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= diff --git a/pkg/cron/cron_expr.go b/pkg/cron/cron_expr.go deleted file mode 100644 index 611a11f..0000000 --- a/pkg/cron/cron_expr.go +++ /dev/null @@ -1,53 +0,0 @@ -package cron - -import ( - "fmt" - "strings" - "time" -) - -// SimpleCronParser is a minimal cron expression parser (minute hour day month dayOfWeek). -// It approximates standard cron behavior to provide missing Expr scheduling support. -type SimpleCronParser struct { - expr string -} - -func NewSimpleCronParser(expr string) *SimpleCronParser { - return &SimpleCronParser{expr: expr} -} - -func (p *SimpleCronParser) Next(from time.Time) time.Time { - fields := strings.Fields(p.expr) - if len(fields) != 5 { - return time.Time{} // Invalid format - } - - // This minimal implementation only supports "*" and exact numbers. - // For production, use github.com/robfig/cron/v3. - next := from.Add(1 * time.Minute).Truncate(time.Minute) - - // Simplified logic: if it is not "*" and does not match, keep incrementing until matched - // (up to one year of search). - for i := 0; i < 525600; i++ { - if p.match(next, fields) { - return next - } - next = next.Add(1 * time.Minute) - } - return time.Time{} -} - -func (p *SimpleCronParser) match(t time.Time, fields []string) bool { - return matchField(fmt.Sprintf("%d", t.Minute()), fields[0]) && - matchField(fmt.Sprintf("%d", t.Hour()), fields[1]) && - matchField(fmt.Sprintf("%d", t.Day()), fields[2]) && - matchField(fmt.Sprintf("%d", int(t.Month())), fields[3]) && - matchField(fmt.Sprintf("%d", int(t.Weekday())), fields[4]) -} - -func matchField(val, field string) bool { - if field == "*" { - return true - } - return val == field -} diff --git a/pkg/cron/service.go b/pkg/cron/service.go index 74b8e7a..cb8236a 100644 --- a/pkg/cron/service.go +++ b/pkg/cron/service.go @@ -6,10 +6,12 @@ import ( "math" "os" "path/filepath" + "strings" "sync" "time" "clawgo/pkg/lifecycle" + robcron "github.com/robfig/cron/v3" ) const ( @@ -342,6 +344,10 @@ func (cs *CronService) computeNextRun(schedule *CronSchedule, nowMS int64) *int6 } func (cs *CronService) computeNextRunAfter(schedule *CronSchedule, baseMS int64, nowMS int64) *int64 { + if schedule == nil { + return nil + } + if schedule.Kind == "at" { if schedule.AtMS != nil && *schedule.AtMS > nowMS { return schedule.AtMS @@ -349,26 +355,126 @@ func (cs *CronService) computeNextRunAfter(schedule *CronSchedule, baseMS int64, return nil } - if schedule.Kind == "every" { - if schedule.EveryMS == nil || *schedule.EveryMS <= 0 { - return nil - } - next := computeAlignedEveryNext(baseMS, nowMS, *schedule.EveryMS) - return &next + expr, ok := cronExprFromSchedule(schedule) + if !ok { + return nil + } + compiled, err := parseCronSchedule(expr, strings.TrimSpace(schedule.TZ)) + if err != nil { + return nil } - if schedule.Kind == "cron" && schedule.Expr != "" { - parser := NewSimpleCronParser(schedule.Expr) - next := parser.Next(time.UnixMilli(nowMS)) - if !next.IsZero() { - ms := next.UnixMilli() - return &ms - } + base := time.UnixMilli(baseMS) + now := time.UnixMilli(nowMS) + next := compiled.Next(base) + for i := 0; i < 1024 && !next.After(now); i++ { + next = compiled.Next(next) + } + if next.After(now) { + ms := next.UnixMilli() + return &ms } return nil } +func cronExprFromSchedule(schedule *CronSchedule) (string, bool) { + if schedule == nil { + return "", false + } + if expr := strings.TrimSpace(schedule.Expr); expr != "" { + return expr, true + } + if schedule.Kind == "every" && schedule.EveryMS != nil && *schedule.EveryMS > 0 { + d := time.Duration(*schedule.EveryMS) * time.Millisecond + return "@every " + d.String(), true + } + return "", false +} + +func parseCronSchedule(expr, tz string) (robcron.Schedule, error) { + spec := strings.TrimSpace(expr) + if spec == "" { + return nil, fmt.Errorf("empty cron expression") + } + tz = strings.TrimSpace(tz) + if tz != "" && !strings.HasPrefix(spec, "CRON_TZ=") && !strings.HasPrefix(spec, "TZ=") { + spec = "CRON_TZ=" + tz + " " + spec + } + parser := robcron.NewParser( + robcron.Minute | + robcron.Hour | + robcron.Dom | + robcron.Month | + robcron.Dow | + robcron.Descriptor, + ) + return parser.Parse(spec) +} + +func normalizeSchedule(schedule CronSchedule) CronSchedule { + schedule.Kind = strings.ToLower(strings.TrimSpace(schedule.Kind)) + schedule.Expr = strings.TrimSpace(schedule.Expr) + schedule.TZ = strings.TrimSpace(schedule.TZ) + + if schedule.Expr != "" { + schedule.Kind = "cron" + schedule.AtMS = nil + schedule.EveryMS = nil + return schedule + } + + if schedule.Kind == "every" && schedule.EveryMS != nil && *schedule.EveryMS > 0 { + d := time.Duration(*schedule.EveryMS) * time.Millisecond + schedule.Expr = "@every " + d.String() + schedule.Kind = "cron" + schedule.EveryMS = nil + return schedule + } + + if schedule.Kind == "at" { + schedule.EveryMS = nil + return schedule + } + + if schedule.AtMS != nil { + schedule.Kind = "at" + return schedule + } + + if schedule.EveryMS != nil && *schedule.EveryMS > 0 { + d := time.Duration(*schedule.EveryMS) * time.Millisecond + schedule.Expr = "@every " + d.String() + schedule.Kind = "cron" + schedule.EveryMS = nil + return schedule + } + + if schedule.Expr != "" { + schedule.Kind = "cron" + } + return schedule +} + +func validateSchedule(schedule CronSchedule) error { + if schedule.Kind == "at" { + if schedule.AtMS == nil || *schedule.AtMS <= time.Now().UnixMilli() { + return fmt.Errorf("invalid one-time schedule") + } + return nil + } + + expr, ok := cronExprFromSchedule(&schedule) + if !ok { + return fmt.Errorf("cron expression is required") + } + _, err := parseCronSchedule(expr, schedule.TZ) + if err != nil { + return fmt.Errorf("invalid cron expression: %w", err) + } + return nil +} + func (cs *CronService) recomputeNextRuns() bool { changed := false now := time.Now().UnixMilli() @@ -427,7 +533,13 @@ func (cs *CronService) loadStore() error { return err } - return json.Unmarshal(data, cs.store) + if err := json.Unmarshal(data, cs.store); err != nil { + return err + } + for i := range cs.store.Jobs { + cs.store.Jobs[i].Schedule = normalizeSchedule(cs.store.Jobs[i].Schedule) + } + return nil } func (cs *CronService) saveStore() error { @@ -479,6 +591,11 @@ func (cs *CronService) AddJob(name string, schedule CronSchedule, message string cs.mu.Lock() defer cs.mu.Unlock() + schedule = normalizeSchedule(schedule) + if err := validateSchedule(schedule); err != nil { + return nil, err + } + now := time.Now().UnixMilli() job := CronJob{ @@ -589,18 +706,6 @@ func (cs *CronService) unmarkJobRunning(jobID string) { delete(cs.running, jobID) } -func computeAlignedEveryNext(baseMS, nowMS, intervalMS int64) int64 { - if intervalMS <= 0 { - return nowMS - } - next := baseMS + intervalMS - if next > nowMS { - return next - } - miss := (nowMS-next)/intervalMS + 1 - return next + miss*intervalMS -} - func computeRetryBackoff(consecutiveFailures int64, base, max time.Duration) time.Duration { if base <= 0 { base = defaultRetryBackoffBase @@ -688,7 +793,11 @@ func (cs *CronService) UpdateJob(jobID string, in UpdateJobInput) (*CronJob, err job.Name = *in.Name } if in.Schedule != nil { - job.Schedule = *in.Schedule + nextSchedule := normalizeSchedule(*in.Schedule) + if err := validateSchedule(nextSchedule); err != nil { + return nil, err + } + job.Schedule = nextSchedule if job.Enabled { now := time.Now().UnixMilli() job.State.NextRunAtMS = cs.computeNextRun(&job.Schedule, now) diff --git a/pkg/nodes/registry_server.go b/pkg/nodes/registry_server.go index 8790b2b..b163e24 100644 --- a/pkg/nodes/registry_server.go +++ b/pkg/nodes/registry_server.go @@ -46,9 +46,9 @@ func NewRegistryServer(host string, port int, token string, mgr *Manager) *Regis return &RegistryServer{addr: fmt.Sprintf("%s:%d", addr, port), token: strings.TrimSpace(token), mgr: mgr} } -func (s *RegistryServer) SetConfigPath(path string) { s.configPath = strings.TrimSpace(path) } +func (s *RegistryServer) SetConfigPath(path string) { s.configPath = strings.TrimSpace(path) } func (s *RegistryServer) SetWorkspacePath(path string) { s.workspacePath = strings.TrimSpace(path) } -func (s *RegistryServer) SetLogFilePath(path string) { s.logFilePath = strings.TrimSpace(path) } +func (s *RegistryServer) SetLogFilePath(path string) { s.logFilePath = strings.TrimSpace(path) } func (s *RegistryServer) SetChatHandler(fn func(ctx context.Context, sessionKey, content string) (string, error)) { s.onChat = fn } @@ -131,7 +131,9 @@ func (s *RegistryServer) handleHeartbeat(w http.ResponseWriter, r *http.Request) http.Error(w, "unauthorized", http.StatusUnauthorized) return } - var body struct{ ID string `json:"id"` } + var body struct { + ID string `json:"id"` + } if err := json.NewDecoder(r.Body).Decode(&body); err != nil || strings.TrimSpace(body.ID) == "" { http.Error(w, "id required", http.StatusBadRequest) return @@ -633,17 +635,27 @@ func (s *RegistryServer) handleWebUISkills(w http.ResponseWriter, r *http.Reques desc, tools, sys := readSkillMeta(filepath.Join(dir, name, "SKILL.md")) if desc == "" || len(tools) == 0 || sys == "" { d2, t2, s2 := readSkillMeta(filepath.Join(dir, baseName, "SKILL.md")) - if desc == "" { desc = d2 } - if len(tools) == 0 { tools = t2 } - if sys == "" { sys = s2 } + if desc == "" { + desc = d2 + } + if len(tools) == 0 { + tools = t2 + } + if sys == "" { + sys = s2 + } + } + if tools == nil { + tools = []string{} } - if tools == nil { tools = []string{} } it := skillItem{ID: baseName, Name: baseName, Description: desc, Tools: tools, SystemPrompt: sys, Enabled: enabled, UpdateChecked: checkUpdates, Source: dir} if checkUpdates { found, version, checkErr := queryClawHubSkillVersion(r.Context(), baseName) it.RemoteFound = found it.RemoteVersion = version - if checkErr != nil { it.CheckError = checkErr.Error() } + if checkErr != nil { + it.CheckError = checkErr.Error() + } } items = append(items, it) } @@ -660,7 +672,9 @@ func (s *RegistryServer) handleWebUISkills(w http.ResponseWriter, r *http.Reques action = strings.ToLower(strings.TrimSpace(action)) id, _ := body["id"].(string) name, _ := body["name"].(string) - if strings.TrimSpace(name) == "" { name = id } + if strings.TrimSpace(name) == "" { + name = id + } name = strings.TrimSpace(name) if name == "" { http.Error(w, "name required", http.StatusBadRequest) @@ -701,7 +715,9 @@ func (s *RegistryServer) handleWebUISkills(w http.ResponseWriter, r *http.Reques var toolsList []string if arr, ok := body["tools"].([]interface{}); ok { for _, v := range arr { - if sv, ok := v.(string); ok && strings.TrimSpace(sv) != "" { toolsList = append(toolsList, strings.TrimSpace(sv)) } + if sv, ok := v.(string); ok && strings.TrimSpace(sv) != "" { + toolsList = append(toolsList, strings.TrimSpace(sv)) + } } } if action == "create" { @@ -733,8 +749,12 @@ func (s *RegistryServer) handleWebUISkills(w http.ResponseWriter, r *http.Reques pathA := filepath.Join(skillsDir, id) pathB := pathA + ".disabled" deleted := false - if err := os.RemoveAll(pathA); err == nil { deleted = true } - if err := os.RemoveAll(pathB); err == nil { deleted = true } + if err := os.RemoveAll(pathA); err == nil { + deleted = true + } + if err := os.RemoveAll(pathB); err == nil { + deleted = true + } _ = json.NewEncoder(w).Encode(map[string]interface{}{"ok": true, "deleted": deleted, "id": id}) default: @@ -742,7 +762,6 @@ func (s *RegistryServer) handleWebUISkills(w http.ResponseWriter, r *http.Reques } } - func buildSkillMarkdown(name, desc string, tools []string, systemPrompt string) string { if strings.TrimSpace(desc) == "" { desc = "No description provided." @@ -913,16 +932,32 @@ func normalizeCronJob(v interface{}) map[string]interface{} { out[k] = val } if sch, ok := m["schedule"].(map[string]interface{}); ok { - if kind, ok := sch["kind"]; ok { out["kind"] = kind } - if every, ok := sch["everyMs"]; ok { out["everyMs"] = every } - if expr, ok := sch["expr"]; ok { out["expr"] = expr } - if at, ok := sch["atMs"]; ok { out["atMs"] = at } + kind, _ := sch["kind"].(string) + if expr, ok := sch["expr"].(string); ok && strings.TrimSpace(expr) != "" { + out["expr"] = strings.TrimSpace(expr) + } else if strings.EqualFold(strings.TrimSpace(kind), "every") { + if every, ok := sch["everyMs"].(float64); ok && every > 0 { + out["expr"] = fmt.Sprintf("@every %s", (time.Duration(int64(every)) * time.Millisecond).String()) + } + } else if strings.EqualFold(strings.TrimSpace(kind), "at") { + if at, ok := sch["atMs"].(float64); ok && at > 0 { + out["expr"] = time.UnixMilli(int64(at)).Format(time.RFC3339) + } + } } if payload, ok := m["payload"].(map[string]interface{}); ok { - if msg, ok := payload["message"]; ok { out["message"] = msg } - if d, ok := payload["deliver"]; ok { out["deliver"] = d } - if c, ok := payload["channel"]; ok { out["channel"] = c } - if to, ok := payload["to"]; ok { out["to"] = to } + if msg, ok := payload["message"]; ok { + out["message"] = msg + } + if d, ok := payload["deliver"]; ok { + out["deliver"] = d + } + if c, ok := payload["channel"]; ok { + out["channel"] = c + } + if to, ok := payload["to"]; ok { + out["to"] = to + } } return out } diff --git a/webui/server.ts b/webui/server.ts index 3b2d67a..a1481af 100644 --- a/webui/server.ts +++ b/webui/server.ts @@ -60,17 +60,19 @@ app.get("/webui/api/cron", (req, res) => { }); app.post("/webui/api/cron", (req, res) => { - const { action, id, ...rest } = req.body || {}; + const { action, id, expr, ...rest } = req.body || {}; if (action === "create") { + if (!String(expr || "").trim()) return res.status(400).json({ error: "expr required" }); const newId = Math.random().toString(36).slice(2); - const job = { id: newId, enabled: true, ...rest }; + const job = { id: newId, enabled: true, expr: String(expr).trim(), ...rest }; mem.cronJobs.push(job); addLog("INFO", `Created cron job: ${job.name || newId}`); return res.json({ id: newId, status: "ok" }); } if (action === "update") { + if (expr !== undefined && !String(expr || "").trim()) return res.status(400).json({ error: "expr required" }); const idx = mem.cronJobs.findIndex((j) => j.id === id); - if (idx >= 0) mem.cronJobs[idx] = { ...mem.cronJobs[idx], ...rest }; + if (idx >= 0) mem.cronJobs[idx] = { ...mem.cronJobs[idx], ...(expr !== undefined ? { expr: String(expr).trim() } : {}), ...rest }; addLog("INFO", `Updated cron job: ${id}`); return res.json({ status: "ok" }); } diff --git a/webui/src/pages/Cron.tsx b/webui/src/pages/Cron.tsx index 30fee69..7f5fbe6 100644 --- a/webui/src/pages/Cron.tsx +++ b/webui/src/pages/Cron.tsx @@ -7,15 +7,13 @@ import { CronJob } from '../types'; const initialCronForm = { name: '', - kind: 'cron', - everyMs: 600000, expr: '*/10 * * * *', message: '', deliver: false, channel: 'telegram', to: '', - enabled: true -} + enabled: true, +}; const Cron: React.FC = () => { const { t } = useTranslation(); @@ -27,7 +25,9 @@ const Cron: React.FC = () => { async function cronAction(action: 'delete' | 'enable' | 'disable', id: string) { try { await fetch(`/webui/api/cron${q}`, { - method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ action, id }), + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ action, id }), }); await refreshCron(); } catch (e) { @@ -44,18 +44,16 @@ const Cron: React.FC = () => { setEditingCron(details.job); setCronForm({ name: details.job.name || '', - kind: details.job.kind || 'cron', - everyMs: details.job.everyMs || 600000, expr: details.job.expr || '', message: details.job.message || '', deliver: details.job.deliver || false, channel: details.job.channel || 'telegram', to: details.job.to || '', - enabled: details.job.enabled ?? true + enabled: details.job.enabled ?? true, }); } } catch (e) { - console.error("Failed to fetch job details", e); + console.error('Failed to fetch job details', e); } } else { setEditingCron(null); @@ -70,24 +68,24 @@ const Cron: React.FC = () => { const payload = { action, ...(editingCron && { id: editingCron.id }), - ...cronForm + ...cronForm, }; - + const r = await fetch(`/webui/api/cron${q}`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify(payload) + body: JSON.stringify(payload), }); - + if (r.ok) { setIsCronModalOpen(false); await refreshCron(); } else { const err = await r.text(); - alert("Action failed: " + err); + alert('Action failed: ' + err); } } catch (e) { - alert("Action failed: " + e); + alert('Action failed: ' + e); } } @@ -106,56 +104,49 @@ const Cron: React.FC = () => {
- {cron.map(j => ( + {cron.map((j) => (

{j.name || j.id}

ID: {j.id.slice(-6)} - {j.kind}
{j.enabled ? ( - {t('active')} + {t('active')} ) : ( - {t('paused')} + {t('paused')} )}
- +
"{j.message}"
-
-
-
{t('kind')}
-
{j.kind}
-
-
-
{j.kind === 'cron' ? t('cronExpression') : t('everyMs')}
-
{j.kind === 'cron' ? j.expr : j.everyMs}
-
+
+
{t('cronExpression')}
+
{j.expr || '-'}
- - -
- {/* Cron Modal */} {isCronModalOpen && (
- setIsCronModalOpen(false)} className="absolute inset-0 bg-black/60 backdrop-blur-sm" /> - {
- +
-
- +