Add YAML scheduler core and run logs
This commit is contained in:
@@ -26,5 +26,12 @@ If Go is installed but not available in `PATH`, use the full path:
|
|||||||
|
|
||||||
- `cmd/pysentry` starts the desktop app.
|
- `cmd/pysentry` starts the desktop app.
|
||||||
- `internal/app` contains the first Fyne-based interface prototype.
|
- `internal/app` contains the first Fyne-based interface prototype.
|
||||||
- Jobs can be created, edited, paused/resumed, and run manually in memory.
|
- `internal/core` contains YAML storage, command execution, and the first scheduler loop.
|
||||||
- Job persistence, cron parsing, and process execution are planned for the next phase.
|
- 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.
|
||||||
|
|||||||
Binary file not shown.
|
After Width: | Height: | Size: 1.9 MiB |
@@ -2,7 +2,10 @@ module github.com/pysentry/pysentry
|
|||||||
|
|
||||||
go 1.22
|
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 (
|
require (
|
||||||
fyne.io/systray v1.11.0 // indirect
|
fyne.io/systray v1.11.0 // indirect
|
||||||
@@ -33,5 +36,4 @@ require (
|
|||||||
golang.org/x/net v0.25.0 // indirect
|
golang.org/x/net v0.25.0 // indirect
|
||||||
golang.org/x/sys v0.20.0 // indirect
|
golang.org/x/sys v0.20.0 // indirect
|
||||||
golang.org/x/text v0.16.0 // indirect
|
golang.org/x/text v0.16.0 // indirect
|
||||||
gopkg.in/yaml.v3 v3.0.1 // indirect
|
|
||||||
)
|
)
|
||||||
|
|||||||
+141
-95
@@ -2,9 +2,14 @@ package app
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"fmt"
|
"fmt"
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"strconv"
|
||||||
"strings"
|
"strings"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
|
"github.com/pysentry/pysentry/internal/core"
|
||||||
|
|
||||||
"fyne.io/fyne/v2"
|
"fyne.io/fyne/v2"
|
||||||
"fyne.io/fyne/v2/app"
|
"fyne.io/fyne/v2/app"
|
||||||
"fyne.io/fyne/v2/container"
|
"fyne.io/fyne/v2/container"
|
||||||
@@ -19,31 +24,12 @@ const appID = "io.github.pysentry.desktop"
|
|||||||
const allFolders = "All"
|
const allFolders = "All"
|
||||||
const noFolder = "No folder"
|
const noFolder = "No folder"
|
||||||
|
|
||||||
type job struct {
|
type job = core.Job
|
||||||
ID int
|
type event = core.RunRecord
|
||||||
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
|
|
||||||
}
|
|
||||||
|
|
||||||
func Run() {
|
func Run() {
|
||||||
a := app.NewWithID(appID)
|
a := app.NewWithID(appID)
|
||||||
a.SetIcon(theme.ComputerIcon())
|
a.SetIcon(loadAppIcon())
|
||||||
|
|
||||||
w := a.NewWindow("PySentry")
|
w := a.NewWindow("PySentry")
|
||||||
configureSystemTray(a, w)
|
configureSystemTray(a, w)
|
||||||
@@ -52,6 +38,22 @@ func Run() {
|
|||||||
w.ShowAndRun()
|
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) {
|
func configureSystemTray(a fyne.App, w fyne.Window) {
|
||||||
desk, ok := a.(desktop.App)
|
desk, ok := a.(desktop.App)
|
||||||
if !ok {
|
if !ok {
|
||||||
@@ -75,62 +77,13 @@ func configureSystemTray(a fyne.App, w fyne.Window) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func newMainView(w fyne.Window) fyne.CanvasObject {
|
func newMainView(w fyne.Window) fyne.CanvasObject {
|
||||||
jobs := []job{
|
store, jobs, err := core.OpenStore()
|
||||||
{
|
if err != nil {
|
||||||
ID: 1,
|
return container.NewPadded(widget.NewLabel("Failed to load PySentry configuration: " + err.Error()))
|
||||||
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"},
|
|
||||||
}
|
}
|
||||||
|
events := collectActivity(jobs)
|
||||||
|
|
||||||
nextJobID := 4
|
nextJobID := nextID(jobs)
|
||||||
selected := 0
|
selected := 0
|
||||||
selectedFolder := allFolders
|
selectedFolder := allFolders
|
||||||
schedulerPaused := false
|
schedulerPaused := false
|
||||||
@@ -143,9 +96,10 @@ func newMainView(w fyne.Window) fyne.CanvasObject {
|
|||||||
nextRun := widget.NewLabel(jobs[selected].NextRun)
|
nextRun := widget.NewLabel(jobs[selected].NextRun)
|
||||||
state := widget.NewLabel(jobs[selected].LastState)
|
state := widget.NewLabel(jobs[selected].LastState)
|
||||||
schedulerState := widget.NewLabel("Scheduler running")
|
schedulerState := widget.NewLabel("Scheduler running")
|
||||||
commandOutput := widget.NewMultiLineEntry()
|
commandOutput := widget.NewTextGrid()
|
||||||
commandOutput.SetText(jobs[selected].Output)
|
commandOutput.SetText(jobs[selected].Output)
|
||||||
commandOutput.Disable()
|
commandOutputScroll := container.NewScroll(commandOutput)
|
||||||
|
commandOutputScroll.SetMinSize(fyne.NewSize(520, 160))
|
||||||
history := newHistoryView(&events)
|
history := newHistoryView(&events)
|
||||||
jobLogs := widget.NewList(
|
jobLogs := widget.NewList(
|
||||||
func() int {
|
func() int {
|
||||||
@@ -189,6 +143,7 @@ func newMainView(w fyne.Window) fyne.CanvasObject {
|
|||||||
jobLogs.Refresh()
|
jobLogs.Refresh()
|
||||||
history.Refresh()
|
history.Refresh()
|
||||||
}
|
}
|
||||||
|
var scheduler *core.Scheduler
|
||||||
|
|
||||||
list := widget.NewList(
|
list := widget.NewList(
|
||||||
func() int { return len(filteredJobs) },
|
func() int { return len(filteredJobs) },
|
||||||
@@ -238,7 +193,7 @@ func newMainView(w fyne.Window) fyne.CanvasObject {
|
|||||||
folderSelect.SetSelected(selectedFolder)
|
folderSelect.SetSelected(selectedFolder)
|
||||||
|
|
||||||
addButton := widget.NewButtonWithIcon("New job", theme.ContentAddIcon(), func() {
|
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
|
saved.ID = nextJobID
|
||||||
nextJobID++
|
nextJobID++
|
||||||
jobs = append(jobs, saved)
|
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")
|
created := newEvent(saved.ID, saved.Name, "Created", "Job was added")
|
||||||
jobs[selected].Logs = append([]event{created}, jobs[selected].Logs...)
|
jobs[selected].Logs = append([]event{created}, jobs[selected].Logs...)
|
||||||
events = append([]event{created}, events...)
|
events = append([]event{created}, events...)
|
||||||
|
_ = store.SaveJobs(jobs)
|
||||||
folderSelect.Options = folderOptions(jobs)
|
folderSelect.Options = folderOptions(jobs)
|
||||||
folderSelect.Refresh()
|
folderSelect.Refresh()
|
||||||
targetFolder := filterValue(saved.Folder)
|
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")
|
updated := newEvent(saved.ID, saved.Name, "Updated", "Job settings changed")
|
||||||
jobs[selected].Logs = append([]event{updated}, jobs[selected].Logs...)
|
jobs[selected].Logs = append([]event{updated}, jobs[selected].Logs...)
|
||||||
events = append([]event{updated}, events...)
|
events = append([]event{updated}, events...)
|
||||||
|
if scheduler != nil {
|
||||||
|
scheduler.RefreshSchedule(selected)
|
||||||
|
}
|
||||||
|
_ = store.SaveJobs(jobs)
|
||||||
folderSelect.Options = folderOptions(jobs)
|
folderSelect.Options = folderOptions(jobs)
|
||||||
folderSelect.Refresh()
|
folderSelect.Refresh()
|
||||||
list.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)
|
dialog.ShowInformation("Scheduler paused", "Global pause is active. Resume the scheduler before running jobs.", w)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
jobs[selected].LastRun = "Just now"
|
ran := scheduler.RunNow(selected)
|
||||||
jobs[selected].LastState = "OK"
|
if ran.Time == "" {
|
||||||
jobs[selected].Output = "stdout: manual run simulated\nstderr: <empty>"
|
return
|
||||||
if jobs[selected].Enabled {
|
|
||||||
jobs[selected].NextRun = "Waiting for scheduler"
|
|
||||||
}
|
}
|
||||||
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...)
|
events = append([]event{ran}, events...)
|
||||||
list.Refresh()
|
list.Refresh()
|
||||||
refresh()
|
refresh()
|
||||||
@@ -309,6 +265,9 @@ func newMainView(w fyne.Window) fyne.CanvasObject {
|
|||||||
jobs[index].NextRun = "Scheduler paused"
|
jobs[index].NextRun = "Scheduler paused"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
if scheduler != nil {
|
||||||
|
scheduler.SetPaused(true)
|
||||||
|
}
|
||||||
events = append([]event{newEvent(0, "Scheduler", "Paused", "All job execution paused")}, events...)
|
events = append([]event{newEvent(0, "Scheduler", "Paused", "All job execution paused")}, events...)
|
||||||
} else {
|
} else {
|
||||||
schedulerState.SetText("Scheduler running")
|
schedulerState.SetText("Scheduler running")
|
||||||
@@ -319,6 +278,9 @@ func newMainView(w fyne.Window) fyne.CanvasObject {
|
|||||||
jobs[index].NextRun = "Waiting for scheduler"
|
jobs[index].NextRun = "Waiting for scheduler"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
if scheduler != nil {
|
||||||
|
scheduler.SetPaused(false)
|
||||||
|
}
|
||||||
events = append([]event{newEvent(0, "Scheduler", "Resumed", "All job execution resumed")}, events...)
|
events = append([]event{newEvent(0, "Scheduler", "Resumed", "All job execution resumed")}, events...)
|
||||||
}
|
}
|
||||||
list.Refresh()
|
list.Refresh()
|
||||||
@@ -336,13 +298,20 @@ func newMainView(w fyne.Window) fyne.CanvasObject {
|
|||||||
resumed := newEvent(current.ID, current.Name, "Resumed", "Job was enabled")
|
resumed := newEvent(current.ID, current.Name, "Resumed", "Job was enabled")
|
||||||
current.Logs = append([]event{resumed}, current.Logs...)
|
current.Logs = append([]event{resumed}, current.Logs...)
|
||||||
events = append([]event{resumed}, events...)
|
events = append([]event{resumed}, events...)
|
||||||
|
if scheduler != nil {
|
||||||
|
scheduler.RefreshSchedule(selected)
|
||||||
|
}
|
||||||
} else {
|
} else {
|
||||||
current.LastState = "Paused"
|
current.LastState = "Paused"
|
||||||
current.NextRun = "Paused"
|
current.NextRun = "Paused"
|
||||||
paused := newEvent(current.ID, current.Name, "Paused", "Job was disabled")
|
paused := newEvent(current.ID, current.Name, "Paused", "Job was disabled")
|
||||||
current.Logs = append([]event{paused}, current.Logs...)
|
current.Logs = append([]event{paused}, current.Logs...)
|
||||||
events = append([]event{paused}, events...)
|
events = append([]event{paused}, events...)
|
||||||
|
if scheduler != nil {
|
||||||
|
scheduler.RefreshSchedule(selected)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
_ = store.SaveJobs(jobs)
|
||||||
list.Refresh()
|
list.Refresh()
|
||||||
refresh()
|
refresh()
|
||||||
})
|
})
|
||||||
@@ -370,6 +339,7 @@ func newMainView(w fyne.Window) fyne.CanvasObject {
|
|||||||
selected = filteredJobs[0]
|
selected = filteredJobs[0]
|
||||||
}
|
}
|
||||||
events = append([]event{newEvent(deleted.ID, deleted.Name, "Deleted", "Job was removed")}, events...)
|
events = append([]event{newEvent(deleted.ID, deleted.Name, "Deleted", "Job was removed")}, events...)
|
||||||
|
_ = store.SaveJobs(jobs)
|
||||||
list.Refresh()
|
list.Refresh()
|
||||||
if selected >= 0 {
|
if selected >= 0 {
|
||||||
list.Select(displayIndex(filteredJobs, selected))
|
list.Select(displayIndex(filteredJobs, selected))
|
||||||
@@ -394,16 +364,22 @@ func newMainView(w fyne.Window) fyne.CanvasObject {
|
|||||||
detailRow("State", state),
|
detailRow("State", state),
|
||||||
widget.NewSeparator(),
|
widget.NewSeparator(),
|
||||||
widget.NewLabelWithStyle("Command output", fyne.TextAlignLeading, fyne.TextStyle{Bold: true}),
|
widget.NewLabelWithStyle("Command output", fyne.TextAlignLeading, fyne.TextStyle{Bold: true}),
|
||||||
commandOutput,
|
commandOutputScroll,
|
||||||
widget.NewSeparator(),
|
widget.NewSeparator(),
|
||||||
widget.NewLabelWithStyle("Selected job activity", fyne.TextAlignLeading, fyne.TextStyle{Bold: true}),
|
widget.NewLabelWithStyle("Selected job activity", fyne.TextAlignLeading, fyne.TextStyle{Bold: true}),
|
||||||
jobLogs,
|
jobLogs,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
scheduler = core.NewScheduler(store, &jobs, func(record core.RunRecord) {
|
||||||
|
events = append([]event{record}, events...)
|
||||||
|
refresh()
|
||||||
|
})
|
||||||
|
scheduler.Start()
|
||||||
|
|
||||||
tabs := container.NewAppTabs(
|
tabs := container.NewAppTabs(
|
||||||
container.NewTabItemWithIcon("Jobs", theme.ListIcon(), container.NewHSplit(sidebar, container.NewPadded(details))),
|
container.NewTabItemWithIcon("Jobs", theme.ListIcon(), container.NewHSplit(sidebar, container.NewPadded(details))),
|
||||||
container.NewTabItemWithIcon("History", theme.HistoryIcon(), history),
|
container.NewTabItemWithIcon("History", theme.HistoryIcon(), history),
|
||||||
container.NewTabItemWithIcon("Settings", theme.SettingsIcon(), settingsView()),
|
container.NewTabItemWithIcon("Settings", theme.SettingsIcon(), settingsView(store)),
|
||||||
)
|
)
|
||||||
tabs.SetTabLocation(container.TabLocationTop)
|
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"),
|
Time: time.Now().Format("15:04:05"),
|
||||||
JobID: jobID,
|
JobID: jobID,
|
||||||
JobName: jobName,
|
JobName: jobName,
|
||||||
|
Trigger: "UI",
|
||||||
State: state,
|
State: state,
|
||||||
Detail: detail,
|
Detail: detail,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func eventText(e event) string {
|
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 {
|
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.SetPlaceHolder("Maintenance")
|
||||||
folder.SetText(current.Folder)
|
folder.SetText(current.Folder)
|
||||||
schedule := widget.NewEntry()
|
schedule := widget.NewEntry()
|
||||||
schedule.SetPlaceHolder("0 2 * * *")
|
schedule.SetPlaceHolder("@every 1m")
|
||||||
schedule.SetText(current.Schedule)
|
schedule.SetText(current.Schedule)
|
||||||
command := widget.NewEntry()
|
command := widget.NewEntry()
|
||||||
command.SetPlaceHolder("python scripts/backup.py")
|
command.SetPlaceHolder("echo PySentry job ran")
|
||||||
command.SetText(current.Command)
|
command.SetText(current.Command)
|
||||||
enabled := widget.NewCheck("Enabled", nil)
|
enabled := widget.NewCheck("Enabled", nil)
|
||||||
enabled.SetChecked(current.Enabled)
|
enabled.SetChecked(current.Enabled)
|
||||||
@@ -555,12 +557,46 @@ func newHistoryView(events *[]event) *fyne.Container {
|
|||||||
return container.NewPadded(list)
|
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)
|
runOnStartup := widget.NewCheck("Start PySentry when I sign in", nil)
|
||||||
minimizeToTray := widget.NewCheck("Keep running in the system tray", 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 := 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(
|
return container.NewPadded(container.NewVBox(
|
||||||
widget.NewLabelWithStyle("Application", fyne.TextAlignLeading, fyne.TextStyle{Bold: true}),
|
widget.NewLabelWithStyle("Application", fyne.TextAlignLeading, fyne.TextStyle{Bold: true}),
|
||||||
@@ -568,7 +604,17 @@ func settingsView() fyne.CanvasObject {
|
|||||||
minimizeToTray,
|
minimizeToTray,
|
||||||
notifications,
|
notifications,
|
||||||
widget.NewSeparator(),
|
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.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."),
|
||||||
))
|
))
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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"`
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
}
|
||||||
@@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
}
|
||||||
@@ -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, "'", "'\\''") + "'"
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user