Add YAML scheduler core and run logs

This commit is contained in:
mixeme
2026-06-14 22:45:11 +03:00
parent 0a66d9da0e
commit 4c11bb4f06
10 changed files with 793 additions and 99 deletions
+141 -95
View File
@@ -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: <empty>",
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: <empty>",
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: <empty>"
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."),
))
}
+43
View File
@@ -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"`
}
+35
View File
@@ -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
}
+169
View File
@@ -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 = "<empty>"
}
if stderr == "" {
stderr = "<empty>"
}
return "stdout:\n" + stdout + "\n\nstderr:\n" + stderr
}
+40
View File
@@ -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)
}
}
}
+169
View File
@@ -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
}
+183
View File
@@ -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, "'", "'\\''") + "'"
}