diff --git a/README.md b/README.md index 86ce382..5d6d4c5 100644 --- a/README.md +++ b/README.md @@ -26,5 +26,12 @@ If Go is installed but not available in `PATH`, use the full path: - `cmd/pysentry` starts the desktop app. - `internal/app` contains the first Fyne-based interface prototype. -- Jobs can be created, edited, paused/resumed, and run manually in memory. -- Job persistence, cron parsing, and process execution are planned for the next phase. +- `internal/core` contains YAML storage, command execution, and the first scheduler loop. +- Jobs can be created, edited, paused/resumed, run manually, and persisted to YAML. +- Settings are stored in `pysentry.yaml` next to the executable. +- Jobs are stored in one `jobs.yaml` file. The job directory is configured by `jobs_dir` and defaults to the executable directory. +- Command output is also written to per-run `.log` files in `logs_dir`. Log filenames include the run timestamp and job name. +- Log cleanup is controlled by `max_log_files` and `max_log_age_days`. +- The current scheduler supports `@every` schedules such as `@every 10s` and `@every 1m`. +- Run history records include a `trigger` value such as `Manual`, `Schedule`, or `UI`. +- Cron expression parsing is planned for the next phase. diff --git a/assets/pysentry-icon.png b/assets/pysentry-icon.png new file mode 100644 index 0000000..1ce55cb Binary files /dev/null and b/assets/pysentry-icon.png differ diff --git a/go.mod b/go.mod index 43ca880..9faa01f 100644 --- a/go.mod +++ b/go.mod @@ -2,7 +2,10 @@ module github.com/pysentry/pysentry go 1.22 -require fyne.io/fyne/v2 v2.5.3 +require ( + fyne.io/fyne/v2 v2.5.3 + gopkg.in/yaml.v3 v3.0.1 +) require ( fyne.io/systray v1.11.0 // indirect @@ -33,5 +36,4 @@ require ( golang.org/x/net v0.25.0 // indirect golang.org/x/sys v0.20.0 // indirect golang.org/x/text v0.16.0 // indirect - gopkg.in/yaml.v3 v3.0.1 // indirect ) diff --git a/internal/app/app.go b/internal/app/app.go index 5b40d38..d1ede49 100644 --- a/internal/app/app.go +++ b/internal/app/app.go @@ -2,9 +2,14 @@ package app import ( "fmt" + "os" + "path/filepath" + "strconv" "strings" "time" + "github.com/pysentry/pysentry/internal/core" + "fyne.io/fyne/v2" "fyne.io/fyne/v2/app" "fyne.io/fyne/v2/container" @@ -19,31 +24,12 @@ const appID = "io.github.pysentry.desktop" const allFolders = "All" const noFolder = "No folder" -type job struct { - ID int - Name string - Folder string - Schedule string - Command string - Enabled bool - LastRun string - NextRun string - LastState string - Logs []event - Output string -} - -type event struct { - Time string - JobID int - JobName string - State string - Detail string -} +type job = core.Job +type event = core.RunRecord func Run() { a := app.NewWithID(appID) - a.SetIcon(theme.ComputerIcon()) + a.SetIcon(loadAppIcon()) w := a.NewWindow("PySentry") configureSystemTray(a, w) @@ -52,6 +38,22 @@ func Run() { w.ShowAndRun() } +func loadAppIcon() fyne.Resource { + candidates := []string{} + if executable, err := os.Executable(); err == nil { + candidates = append(candidates, filepath.Join(filepath.Dir(executable), "assets", "pysentry-icon.png")) + } + if workingDir, err := os.Getwd(); err == nil { + candidates = append(candidates, filepath.Join(workingDir, "assets", "pysentry-icon.png")) + } + for _, path := range candidates { + if resource, err := fyne.LoadResourceFromPath(path); err == nil { + return resource + } + } + return theme.ComputerIcon() +} + func configureSystemTray(a fyne.App, w fyne.Window) { desk, ok := a.(desktop.App) if !ok { @@ -75,62 +77,13 @@ func configureSystemTray(a fyne.App, w fyne.Window) { } func newMainView(w fyne.Window) fyne.CanvasObject { - jobs := []job{ - { - ID: 1, - Name: "Nightly backup", - Folder: "Maintenance", - Schedule: "0 2 * * *", - Command: "python scripts/backup.py", - Enabled: true, - LastRun: "Today 02:00", - NextRun: "Tomorrow 02:00", - LastState: "OK", - Output: "stdout: backup archive created\nstderr: ", - Logs: []event{ - {Time: "Today 02:00", JobID: 1, JobName: "Nightly backup", State: "OK", Detail: "Completed in 42 s"}, - {Time: "Yesterday 02:00", JobID: 1, JobName: "Nightly backup", State: "OK", Detail: "Completed in 39 s"}, - }, - }, - { - ID: 2, - Name: "Health check", - Folder: "Monitoring", - Schedule: "*/15 * * * *", - Command: "curl -fsS https://example.test/health", - Enabled: true, - LastRun: "21:00", - NextRun: "21:15", - LastState: "OK", - Output: "stdout: HTTP 200 OK\nstderr: ", - Logs: []event{ - {Time: "21:00", JobID: 2, JobName: "Health check", State: "OK", Detail: "Completed in 184 ms"}, - {Time: "20:45", JobID: 2, JobName: "Health check", State: "OK", Detail: "Completed in 201 ms"}, - }, - }, - { - ID: 3, - Name: "Rotate logs", - Schedule: "30 1 * * 1", - Command: "pysentry rotate-logs", - Enabled: false, - LastRun: "Monday 01:30", - NextRun: "Paused", - LastState: "Paused", - Output: "No command output captured yet.", - Logs: []event{ - {Time: "Yesterday 01:30", JobID: 3, JobName: "Rotate logs", State: "Paused", Detail: "Skipped because the job is paused"}, - }, - }, - } - events := []event{ - {Time: "21:00", JobID: 2, JobName: "Health check", State: "OK", Detail: "Completed in 184 ms"}, - {Time: "20:45", JobID: 2, JobName: "Health check", State: "OK", Detail: "Completed in 201 ms"}, - {Time: "02:00", JobID: 1, JobName: "Nightly backup", State: "OK", Detail: "Completed in 42 s"}, - {Time: "Yesterday 01:30", JobID: 3, JobName: "Rotate logs", State: "Paused", Detail: "Skipped because the job is paused"}, + store, jobs, err := core.OpenStore() + if err != nil { + return container.NewPadded(widget.NewLabel("Failed to load PySentry configuration: " + err.Error())) } + events := collectActivity(jobs) - nextJobID := 4 + nextJobID := nextID(jobs) selected := 0 selectedFolder := allFolders schedulerPaused := false @@ -143,9 +96,10 @@ func newMainView(w fyne.Window) fyne.CanvasObject { nextRun := widget.NewLabel(jobs[selected].NextRun) state := widget.NewLabel(jobs[selected].LastState) schedulerState := widget.NewLabel("Scheduler running") - commandOutput := widget.NewMultiLineEntry() + commandOutput := widget.NewTextGrid() commandOutput.SetText(jobs[selected].Output) - commandOutput.Disable() + commandOutputScroll := container.NewScroll(commandOutput) + commandOutputScroll.SetMinSize(fyne.NewSize(520, 160)) history := newHistoryView(&events) jobLogs := widget.NewList( func() int { @@ -189,6 +143,7 @@ func newMainView(w fyne.Window) fyne.CanvasObject { jobLogs.Refresh() history.Refresh() } + var scheduler *core.Scheduler list := widget.NewList( func() int { return len(filteredJobs) }, @@ -238,7 +193,7 @@ func newMainView(w fyne.Window) fyne.CanvasObject { folderSelect.SetSelected(selectedFolder) addButton := widget.NewButtonWithIcon("New job", theme.ContentAddIcon(), func() { - showJobDialog(w, "New job", job{Enabled: true, LastRun: "Never", NextRun: "After save", LastState: "Ready"}, func(saved job) { + showJobDialog(w, "New job", job{Schedule: "@every 1m", Command: "echo PySentry job ran", Enabled: true, LastRun: "Never", NextRun: "After save", LastState: "Ready"}, func(saved job) { saved.ID = nextJobID nextJobID++ jobs = append(jobs, saved) @@ -246,6 +201,7 @@ func newMainView(w fyne.Window) fyne.CanvasObject { created := newEvent(saved.ID, saved.Name, "Created", "Job was added") jobs[selected].Logs = append([]event{created}, jobs[selected].Logs...) events = append([]event{created}, events...) + _ = store.SaveJobs(jobs) folderSelect.Options = folderOptions(jobs) folderSelect.Refresh() targetFolder := filterValue(saved.Folder) @@ -271,6 +227,10 @@ func newMainView(w fyne.Window) fyne.CanvasObject { updated := newEvent(saved.ID, saved.Name, "Updated", "Job settings changed") jobs[selected].Logs = append([]event{updated}, jobs[selected].Logs...) events = append([]event{updated}, events...) + if scheduler != nil { + scheduler.RefreshSchedule(selected) + } + _ = store.SaveJobs(jobs) folderSelect.Options = folderOptions(jobs) folderSelect.Refresh() list.Refresh() @@ -285,14 +245,10 @@ func newMainView(w fyne.Window) fyne.CanvasObject { dialog.ShowInformation("Scheduler paused", "Global pause is active. Resume the scheduler before running jobs.", w) return } - jobs[selected].LastRun = "Just now" - jobs[selected].LastState = "OK" - jobs[selected].Output = "stdout: manual run simulated\nstderr: " - if jobs[selected].Enabled { - jobs[selected].NextRun = "Waiting for scheduler" + ran := scheduler.RunNow(selected) + if ran.Time == "" { + return } - ran := newEvent(jobs[selected].ID, jobs[selected].Name, "OK", "Manual run simulated") - jobs[selected].Logs = append([]event{ran}, jobs[selected].Logs...) events = append([]event{ran}, events...) list.Refresh() refresh() @@ -309,6 +265,9 @@ func newMainView(w fyne.Window) fyne.CanvasObject { jobs[index].NextRun = "Scheduler paused" } } + if scheduler != nil { + scheduler.SetPaused(true) + } events = append([]event{newEvent(0, "Scheduler", "Paused", "All job execution paused")}, events...) } else { schedulerState.SetText("Scheduler running") @@ -319,6 +278,9 @@ func newMainView(w fyne.Window) fyne.CanvasObject { jobs[index].NextRun = "Waiting for scheduler" } } + if scheduler != nil { + scheduler.SetPaused(false) + } events = append([]event{newEvent(0, "Scheduler", "Resumed", "All job execution resumed")}, events...) } list.Refresh() @@ -336,13 +298,20 @@ func newMainView(w fyne.Window) fyne.CanvasObject { resumed := newEvent(current.ID, current.Name, "Resumed", "Job was enabled") current.Logs = append([]event{resumed}, current.Logs...) events = append([]event{resumed}, events...) + if scheduler != nil { + scheduler.RefreshSchedule(selected) + } } else { current.LastState = "Paused" current.NextRun = "Paused" paused := newEvent(current.ID, current.Name, "Paused", "Job was disabled") current.Logs = append([]event{paused}, current.Logs...) events = append([]event{paused}, events...) + if scheduler != nil { + scheduler.RefreshSchedule(selected) + } } + _ = store.SaveJobs(jobs) list.Refresh() refresh() }) @@ -370,6 +339,7 @@ func newMainView(w fyne.Window) fyne.CanvasObject { selected = filteredJobs[0] } events = append([]event{newEvent(deleted.ID, deleted.Name, "Deleted", "Job was removed")}, events...) + _ = store.SaveJobs(jobs) list.Refresh() if selected >= 0 { list.Select(displayIndex(filteredJobs, selected)) @@ -394,16 +364,22 @@ func newMainView(w fyne.Window) fyne.CanvasObject { detailRow("State", state), widget.NewSeparator(), widget.NewLabelWithStyle("Command output", fyne.TextAlignLeading, fyne.TextStyle{Bold: true}), - commandOutput, + commandOutputScroll, widget.NewSeparator(), widget.NewLabelWithStyle("Selected job activity", fyne.TextAlignLeading, fyne.TextStyle{Bold: true}), jobLogs, ) + scheduler = core.NewScheduler(store, &jobs, func(record core.RunRecord) { + events = append([]event{record}, events...) + refresh() + }) + scheduler.Start() + tabs := container.NewAppTabs( container.NewTabItemWithIcon("Jobs", theme.ListIcon(), container.NewHSplit(sidebar, container.NewPadded(details))), container.NewTabItemWithIcon("History", theme.HistoryIcon(), history), - container.NewTabItemWithIcon("Settings", theme.SettingsIcon(), settingsView()), + container.NewTabItemWithIcon("Settings", theme.SettingsIcon(), settingsView(store)), ) tabs.SetTabLocation(container.TabLocationTop) @@ -422,13 +398,39 @@ func newEvent(jobID int, jobName string, state string, detail string) event { Time: time.Now().Format("15:04:05"), JobID: jobID, JobName: jobName, + Trigger: "UI", State: state, Detail: detail, } } func eventText(e event) string { - return fmt.Sprintf("%s %s %s %s", e.Time, e.JobName, e.State, e.Detail) + trigger := e.Trigger + if trigger == "" { + trigger = "Unknown" + } + if e.LogFile != "" { + return fmt.Sprintf("%s %s %s %s %s %s", e.Time, trigger, e.JobName, e.State, e.Detail, e.LogFile) + } + return fmt.Sprintf("%s %s %s %s %s", e.Time, trigger, e.JobName, e.State, e.Detail) +} + +func collectActivity(jobs []job) []event { + var events []event + for _, current := range jobs { + events = append(events, current.Logs...) + } + return events +} + +func nextID(jobs []job) int { + next := 1 + for _, current := range jobs { + if current.ID >= next { + next = current.ID + 1 + } + } + return next } func detailRow(label string, value fyne.CanvasObject) fyne.CanvasObject { @@ -492,10 +494,10 @@ func showJobDialog(w fyne.Window, title string, current job, onSave func(job)) { folder.SetPlaceHolder("Maintenance") folder.SetText(current.Folder) schedule := widget.NewEntry() - schedule.SetPlaceHolder("0 2 * * *") + schedule.SetPlaceHolder("@every 1m") schedule.SetText(current.Schedule) command := widget.NewEntry() - command.SetPlaceHolder("python scripts/backup.py") + command.SetPlaceHolder("echo PySentry job ran") command.SetText(current.Command) enabled := widget.NewCheck("Enabled", nil) enabled.SetChecked(current.Enabled) @@ -555,12 +557,46 @@ func newHistoryView(events *[]event) *fyne.Container { return container.NewPadded(list) } -func settingsView() fyne.CanvasObject { +func settingsView(store *core.Store) fyne.CanvasObject { runOnStartup := widget.NewCheck("Start PySentry when I sign in", nil) minimizeToTray := widget.NewCheck("Keep running in the system tray", nil) - minimizeToTray.SetChecked(true) + minimizeToTray.SetChecked(store.Config.KeepRunningInTray) notifications := widget.NewCheck("Show desktop notifications for failed jobs", nil) - notifications.SetChecked(true) + notifications.SetChecked(store.Config.NotifyOnFailure) + logsDir := widget.NewEntry() + logsDir.SetText(store.Config.LogsDir) + maxLogFiles := widget.NewEntry() + maxLogFiles.SetText(strconv.Itoa(store.Config.MaxLogFiles)) + maxLogAgeDays := widget.NewEntry() + maxLogAgeDays.SetText(strconv.Itoa(store.Config.MaxLogAgeDays)) + settingsStatus := widget.NewLabel("") + + saveSettings := widget.NewButtonWithIcon("Save settings", theme.DocumentSaveIcon(), func() { + files, err := strconv.Atoi(strings.TrimSpace(maxLogFiles.Text)) + if err != nil || files <= 0 { + settingsStatus.SetText("Max log files must be a positive number") + return + } + days, err := strconv.Atoi(strings.TrimSpace(maxLogAgeDays.Text)) + if err != nil || days <= 0 { + settingsStatus.SetText("Max log age days must be a positive number") + return + } + store.Config.LogsDir = strings.TrimSpace(logsDir.Text) + store.Config.MaxLogFiles = files + store.Config.MaxLogAgeDays = days + store.Config.KeepRunningInTray = minimizeToTray.Checked + store.Config.NotifyOnFailure = notifications.Checked + if err := store.SaveConfig(); err != nil { + settingsStatus.SetText("Save failed: " + err.Error()) + return + } + if err := core.CleanupLogs(store.Paths.LogsDir, store.Config.MaxLogFiles, store.Config.MaxLogAgeDays); err != nil { + settingsStatus.SetText("Saved, cleanup failed: " + err.Error()) + return + } + settingsStatus.SetText("Saved") + }) return container.NewPadded(container.NewVBox( widget.NewLabelWithStyle("Application", fyne.TextAlignLeading, fyne.TextStyle{Bold: true}), @@ -568,7 +604,17 @@ func settingsView() fyne.CanvasObject { minimizeToTray, notifications, widget.NewSeparator(), + widget.NewLabelWithStyle("Storage", fyne.TextAlignLeading, fyne.TextStyle{Bold: true}), + detailRow("Config YAML", widget.NewLabel(store.Paths.ConfigPath)), + detailRow("Jobs YAML", widget.NewLabel(store.Paths.JobsPath)), + detailRow("Jobs directory", widget.NewLabel(store.Paths.JobsDir)), + detailRow("Logs directory", logsDir), + detailRow("Max log files", maxLogFiles), + detailRow("Max log age days", maxLogAgeDays), + saveSettings, + settingsStatus, + widget.NewSeparator(), widget.NewLabelWithStyle("Scheduler", fyne.TextAlignLeading, fyne.TextStyle{Bold: true}), - widget.NewLabel("The scheduler service, job storage, and cron parser come next."), + widget.NewLabel("Current core supports @every schedules. Cron expressions come next."), )) } diff --git a/internal/core/model.go b/internal/core/model.go new file mode 100644 index 0000000..e3eeb6f --- /dev/null +++ b/internal/core/model.go @@ -0,0 +1,43 @@ +package core + +import "time" + +type Config struct { + JobsDir string `yaml:"jobs_dir"` + LogsDir string `yaml:"logs_dir"` + MaxLogFiles int `yaml:"max_log_files"` + MaxLogAgeDays int `yaml:"max_log_age_days"` + KeepRunningInTray bool `yaml:"keep_running_in_tray"` + NotifyOnFailure bool `yaml:"notify_on_failure"` +} + +type JobsFile struct { + Jobs []Job `yaml:"jobs"` +} + +type Job struct { + ID int `yaml:"id"` + Name string `yaml:"name"` + Folder string `yaml:"folder,omitempty"` + Schedule string `yaml:"schedule"` + Command string `yaml:"command"` + Enabled bool `yaml:"enabled"` + LastRun string `yaml:"last_run,omitempty"` + NextRun string `yaml:"next_run,omitempty"` + LastState string `yaml:"last_state,omitempty"` + Logs []RunRecord `yaml:"activity,omitempty"` + Output string `yaml:"last_output,omitempty"` + + nextDue time.Time +} + +type RunRecord struct { + Time string `yaml:"time"` + JobID int `yaml:"job_id"` + JobName string `yaml:"job_name"` + Trigger string `yaml:"trigger,omitempty"` + State string `yaml:"state"` + Detail string `yaml:"detail"` + LogFile string `yaml:"log_file,omitempty"` + Output string `yaml:"output,omitempty"` +} diff --git a/internal/core/paths.go b/internal/core/paths.go new file mode 100644 index 0000000..3450010 --- /dev/null +++ b/internal/core/paths.go @@ -0,0 +1,35 @@ +package core + +import ( + "os" + "path/filepath" +) + +const ( + ConfigFileName = "pysentry.yaml" + JobsFileName = "jobs.yaml" +) + +type Paths struct { + AppDir string + ConfigPath string + JobsDir string + JobsPath string + LogsDir string +} + +func ResolvePaths() (Paths, error) { + executable, err := os.Executable() + if err != nil { + return Paths{}, err + } + + appDir := filepath.Dir(executable) + configPath := filepath.Join(appDir, ConfigFileName) + return Paths{ + AppDir: appDir, + ConfigPath: configPath, + JobsDir: appDir, + JobsPath: filepath.Join(appDir, JobsFileName), + }, nil +} diff --git a/internal/core/runner.go b/internal/core/runner.go new file mode 100644 index 0000000..3e7f0be --- /dev/null +++ b/internal/core/runner.go @@ -0,0 +1,169 @@ +package core + +import ( + "bytes" + "context" + "errors" + "fmt" + "os" + "os/exec" + "path/filepath" + "runtime" + "sort" + "strings" + "time" + "unicode" +) + +const commandTimeout = 30 * time.Second + +func RunJob(ctx context.Context, job *Job, trigger string, logsDir string) RunRecord { + started := time.Now() + runCtx, cancel := context.WithTimeout(ctx, commandTimeout) + defer cancel() + + command := shellCommand(runCtx, job.Command) + var stdout bytes.Buffer + var stderr bytes.Buffer + command.Stdout = &stdout + command.Stderr = &stderr + + err := command.Run() + duration := time.Since(started).Round(time.Millisecond) + output := formatOutput(stdout.String(), stderr.String()) + + state := "OK" + detail := fmt.Sprintf("Completed in %s", duration) + if err != nil { + state = "Failed" + if errors.Is(runCtx.Err(), context.DeadlineExceeded) { + detail = fmt.Sprintf("Timed out after %s", commandTimeout) + } else { + detail = err.Error() + } + } + + now := time.Now() + job.LastRun = now.Format("2006-01-02 15:04:05") + job.LastState = state + job.Output = output + logFile := writeRunLog(logsDir, *job, trigger, state, detail, output, now) + + record := RunRecord{ + Time: job.LastRun, + JobID: job.ID, + JobName: job.Name, + Trigger: trigger, + State: state, + Detail: detail, + LogFile: logFile, + Output: output, + } + job.Logs = append([]RunRecord{record}, job.Logs...) + if len(job.Logs) > 50 { + job.Logs = job.Logs[:50] + } + return record +} + +func CleanupLogs(logsDir string, maxFiles int, maxAgeDays int) error { + entries, err := os.ReadDir(logsDir) + if err != nil { + if errors.Is(err, os.ErrNotExist) { + return nil + } + return err + } + + type logFile struct { + path string + modTime time.Time + } + var logs []logFile + cutoff := time.Now().AddDate(0, 0, -maxAgeDays) + for _, entry := range entries { + if entry.IsDir() || !strings.HasSuffix(strings.ToLower(entry.Name()), ".log") { + continue + } + path := filepath.Join(logsDir, entry.Name()) + info, err := entry.Info() + if err != nil { + continue + } + if maxAgeDays > 0 && info.ModTime().Before(cutoff) { + _ = os.Remove(path) + continue + } + logs = append(logs, logFile{path: path, modTime: info.ModTime()}) + } + + if maxFiles <= 0 || len(logs) <= maxFiles { + return nil + } + sort.Slice(logs, func(i int, j int) bool { + return logs[i].modTime.After(logs[j].modTime) + }) + for _, old := range logs[maxFiles:] { + _ = os.Remove(old.path) + } + return nil +} + +func writeRunLog(logsDir string, job Job, trigger string, state string, detail string, output string, started time.Time) string { + if strings.TrimSpace(logsDir) == "" { + return "" + } + if err := os.MkdirAll(logsDir, 0o755); err != nil { + return "" + } + fileName := started.Format("20060102-150405") + "_" + sanitizeFileName(job.Name) + ".log" + path := filepath.Join(logsDir, fileName) + content := fmt.Sprintf("time: %s\njob_id: %d\njob_name: %s\ntrigger: %s\nstate: %s\ndetail: %s\ncommand: %s\n\n%s\n", + started.Format("2006-01-02 15:04:05"), job.ID, job.Name, trigger, state, detail, job.Command, output) + if err := os.WriteFile(path, []byte(content), 0o644); err != nil { + return "" + } + return path +} + +func sanitizeFileName(name string) string { + name = strings.TrimSpace(name) + if name == "" { + return "job" + } + var builder strings.Builder + for _, r := range name { + switch { + case unicode.IsLetter(r), unicode.IsDigit(r): + builder.WriteRune(r) + case r == '-', r == '_': + builder.WriteRune(r) + default: + builder.WriteRune('_') + } + } + result := strings.Trim(builder.String(), "_") + if result == "" { + return "job" + } + return result +} + +func shellCommand(ctx context.Context, command string) *exec.Cmd { + if runtime.GOOS == "windows" { + return exec.CommandContext(ctx, "cmd.exe", "/C", command) + } + return exec.CommandContext(ctx, "sh", "-c", command) +} + +func formatOutput(stdout string, stderr string) string { + stdout = strings.TrimSpace(stdout) + stderr = strings.TrimSpace(stderr) + if stdout == "" { + stdout = "" + } + if stderr == "" { + stderr = "" + } + return "stdout:\n" + stdout + "\n\nstderr:\n" + stderr +} diff --git a/internal/core/runner_test.go b/internal/core/runner_test.go new file mode 100644 index 0000000..671b328 --- /dev/null +++ b/internal/core/runner_test.go @@ -0,0 +1,40 @@ +package core + +import ( + "context" + "os" + "path/filepath" + "strings" + "testing" +) + +func TestRunJobWritesLogFile(t *testing.T) { + logsDir := t.TempDir() + job := Job{ + ID: 42, + Name: "Hello Test", + Command: echoCommand("hello from test"), + } + + record := RunJob(context.Background(), &job, "Manual", logsDir) + if record.LogFile == "" { + t.Fatal("expected log file path") + } + if filepath.Dir(record.LogFile) != logsDir { + t.Fatalf("expected log in %q, got %q", logsDir, record.LogFile) + } + if !strings.Contains(filepath.Base(record.LogFile), "Hello_Test") { + t.Fatalf("expected job name in log filename, got %q", record.LogFile) + } + + data, err := os.ReadFile(record.LogFile) + if err != nil { + t.Fatal(err) + } + content := string(data) + for _, want := range []string{"trigger: Manual", "job_name: Hello Test", "hello from test"} { + if !strings.Contains(content, want) { + t.Fatalf("expected log content to contain %q, got:\n%s", want, content) + } + } +} diff --git a/internal/core/scheduler.go b/internal/core/scheduler.go new file mode 100644 index 0000000..ea17692 --- /dev/null +++ b/internal/core/scheduler.go @@ -0,0 +1,169 @@ +package core + +import ( + "context" + "strings" + "sync" + "time" +) + +type Scheduler struct { + store *Store + jobs *[]Job + onChange func(RunRecord) + + mu sync.Mutex + ctx context.Context + cancel context.CancelFunc + paused bool +} + +func NewScheduler(store *Store, jobs *[]Job, onChange func(RunRecord)) *Scheduler { + ctx, cancel := context.WithCancel(context.Background()) + s := &Scheduler{ + store: store, + jobs: jobs, + onChange: onChange, + ctx: ctx, + cancel: cancel, + } + s.resetNextRuns(time.Now()) + return s +} + +func (s *Scheduler) Start() { + ticker := time.NewTicker(time.Second) + go func() { + defer ticker.Stop() + for { + select { + case <-s.ctx.Done(): + return + case now := <-ticker.C: + s.tick(now) + } + } + }() +} + +func (s *Scheduler) Stop() { + s.cancel() +} + +func (s *Scheduler) SetPaused(paused bool) { + s.mu.Lock() + defer s.mu.Unlock() + + s.paused = paused + now := time.Now() + for index := range *s.jobs { + job := &(*s.jobs)[index] + if !job.Enabled { + job.NextRun = "Paused" + continue + } + if paused { + job.NextRun = "Scheduler paused" + continue + } + s.prepareNextRun(job, now) + } + _ = s.store.SaveJobs(*s.jobs) +} + +func (s *Scheduler) RunNow(index int) RunRecord { + s.mu.Lock() + defer s.mu.Unlock() + + if index < 0 || index >= len(*s.jobs) { + return RunRecord{} + } + job := &(*s.jobs)[index] + record := RunJob(s.ctx, job, "Manual", s.store.Paths.LogsDir) + s.prepareNextRun(job, time.Now()) + _ = CleanupLogs(s.store.Paths.LogsDir, s.store.Config.MaxLogFiles, s.store.Config.MaxLogAgeDays) + _ = s.store.SaveJobs(*s.jobs) + return record +} + +func (s *Scheduler) RefreshSchedule(index int) { + s.mu.Lock() + defer s.mu.Unlock() + + if index < 0 || index >= len(*s.jobs) { + return + } + job := &(*s.jobs)[index] + if !job.Enabled { + job.NextRun = "Paused" + return + } + if s.paused { + job.NextRun = "Scheduler paused" + return + } + s.prepareNextRun(job, time.Now()) +} + +func (s *Scheduler) tick(now time.Time) { + var record RunRecord + var changed bool + + s.mu.Lock() + if !s.paused { + for index := range *s.jobs { + job := &(*s.jobs)[index] + if !job.Enabled || job.nextDue.IsZero() || now.Before(job.nextDue) { + continue + } + record = RunJob(s.ctx, job, "Schedule", s.store.Paths.LogsDir) + s.prepareNextRun(job, time.Now()) + _ = CleanupLogs(s.store.Paths.LogsDir, s.store.Config.MaxLogFiles, s.store.Config.MaxLogAgeDays) + changed = true + break + } + } + if changed { + _ = s.store.SaveJobs(*s.jobs) + } + s.mu.Unlock() + + if changed && s.onChange != nil { + s.onChange(record) + } +} + +func (s *Scheduler) resetNextRuns(now time.Time) { + for index := range *s.jobs { + job := &(*s.jobs)[index] + if !job.Enabled { + job.NextRun = "Paused" + continue + } + s.prepareNextRun(job, now) + } + _ = s.store.SaveJobs(*s.jobs) +} + +func (s *Scheduler) prepareNextRun(job *Job, from time.Time) { + interval, ok := parseEvery(job.Schedule) + if !ok { + job.NextRun = "Unsupported schedule" + job.nextDue = time.Time{} + return + } + job.nextDue = from.Add(interval) + job.NextRun = job.nextDue.Format("2006-01-02 15:04:05") +} + +func parseEvery(schedule string) (time.Duration, bool) { + schedule = strings.TrimSpace(schedule) + if !strings.HasPrefix(schedule, "@every ") { + return 0, false + } + interval, err := time.ParseDuration(strings.TrimSpace(strings.TrimPrefix(schedule, "@every "))) + if err != nil || interval <= 0 { + return 0, false + } + return interval, true +} diff --git a/internal/core/store.go b/internal/core/store.go new file mode 100644 index 0000000..20c2ec1 --- /dev/null +++ b/internal/core/store.go @@ -0,0 +1,183 @@ +package core + +import ( + "errors" + "os" + "path/filepath" + "runtime" + "strings" + + "gopkg.in/yaml.v3" +) + +type Store struct { + Paths Paths + Config Config +} + +func OpenStore() (*Store, []Job, error) { + paths, err := ResolvePaths() + if err != nil { + return nil, nil, err + } + + store := &Store{Paths: paths} + config, err := loadOrCreateConfig(paths) + if err != nil { + return nil, nil, err + } + store.Config = config + store.applyConfigPaths() + if err := store.SaveConfig(); err != nil { + return nil, nil, err + } + + jobs, err := loadOrCreateJobs(store.Paths.JobsPath) + if err != nil { + return nil, nil, err + } + return store, jobs, nil +} + +func (s *Store) SaveConfig() error { + s.applyConfigPaths() + if err := os.MkdirAll(s.Paths.AppDir, 0o755); err != nil { + return err + } + return writeYAML(s.Paths.ConfigPath, s.Config) +} + +func (s *Store) SaveJobs(jobs []Job) error { + if err := os.MkdirAll(s.Paths.JobsDir, 0o755); err != nil { + return err + } + return writeYAML(s.Paths.JobsPath, JobsFile{Jobs: jobs}) +} + +func loadOrCreateConfig(paths Paths) (Config, error) { + config := Config{ + JobsDir: ".", + LogsDir: "logs", + MaxLogFiles: 100, + MaxLogAgeDays: 30, + KeepRunningInTray: true, + NotifyOnFailure: true, + } + + if _, err := os.Stat(paths.ConfigPath); errors.Is(err, os.ErrNotExist) { + return config, writeYAML(paths.ConfigPath, config) + } + + data, err := os.ReadFile(paths.ConfigPath) + if err != nil { + return Config{}, err + } + if err := yaml.Unmarshal(data, &config); err != nil { + return Config{}, err + } + if strings.TrimSpace(config.JobsDir) == "" { + config.JobsDir = "." + } + if strings.TrimSpace(config.LogsDir) == "" { + config.LogsDir = "logs" + } + if config.MaxLogFiles <= 0 { + config.MaxLogFiles = 100 + } + if config.MaxLogAgeDays <= 0 { + config.MaxLogAgeDays = 30 + } + return config, nil +} + +func loadOrCreateJobs(path string) ([]Job, error) { + if _, err := os.Stat(path); errors.Is(err, os.ErrNotExist) { + jobs := defaultJobs() + return jobs, writeYAML(path, JobsFile{Jobs: jobs}) + } + + data, err := os.ReadFile(path) + if err != nil { + return nil, err + } + var file JobsFile + if err := yaml.Unmarshal(data, &file); err != nil { + return nil, err + } + return file.Jobs, nil +} + +func resolveJobsDir(appDir string, jobsDir string) string { + return resolveConfiguredDir(appDir, jobsDir) +} + +func resolveConfiguredDir(appDir string, dir string) string { + if filepath.IsAbs(dir) { + return dir + } + return filepath.Clean(filepath.Join(appDir, dir)) +} + +func (s *Store) applyConfigPaths() { + s.Paths.JobsDir = resolveConfiguredDir(s.Paths.AppDir, s.Config.JobsDir) + s.Paths.JobsPath = filepath.Join(s.Paths.JobsDir, JobsFileName) + s.Paths.LogsDir = resolveConfiguredDir(s.Paths.AppDir, s.Config.LogsDir) +} + +func writeYAML(path string, value any) error { + if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil { + return err + } + data, err := yaml.Marshal(value) + if err != nil { + return err + } + return os.WriteFile(path, data, 0o644) +} + +func defaultJobs() []Job { + return []Job{ + { + ID: 1, + Name: "Hello scheduler", + Folder: "Examples", + Schedule: "@every 10s", + Command: echoCommand("PySentry test job: scheduler is alive"), + Enabled: true, + LastRun: "Never", + NextRun: "After start", + LastState: "Ready", + Output: "No command output captured yet.", + }, + { + ID: 2, + Name: "Write timestamp", + Folder: "Examples", + Schedule: "@every 15s", + Command: echoCommand("PySentry test job: timestamp command ran"), + Enabled: true, + LastRun: "Never", + NextRun: "After start", + LastState: "Ready", + Output: "No command output captured yet.", + }, + { + ID: 3, + Name: "Paused sample", + Schedule: "@every 1m", + Command: echoCommand("This paused sample should not run until enabled"), + Enabled: false, + LastRun: "Never", + NextRun: "Paused", + LastState: "Paused", + Output: "No command output captured yet.", + }, + } +} + +func echoCommand(message string) string { + if runtime.GOOS == "windows" { + return "echo " + message + } + return "echo '" + strings.ReplaceAll(message, "'", "'\\''") + "'" +}