Stabilize packaging and scheduler storage

This commit is contained in:
mixeme
2026-06-14 23:23:14 +03:00
parent 4c11bb4f06
commit 414be2dfe9
19 changed files with 440 additions and 84 deletions
+39 -20
View File
@@ -1,9 +1,8 @@
package app
import (
_ "embed"
"fmt"
"os"
"path/filepath"
"strconv"
"strings"
"time"
@@ -27,6 +26,9 @@ const noFolder = "No folder"
type job = core.Job
type event = core.RunRecord
//go:embed assets/pysentry-icon.png
var iconBytes []byte
func Run() {
a := app.NewWithID(appID)
a.SetIcon(loadAppIcon())
@@ -39,19 +41,7 @@ func Run() {
}
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()
return fyne.NewStaticResource("pysentry-icon.png", iconBytes)
}
func configureSystemTray(a fyne.App, w fyne.Window) {
@@ -379,7 +369,7 @@ func newMainView(w fyne.Window) fyne.CanvasObject {
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(store)),
container.NewTabItemWithIcon("Settings", theme.SettingsIcon(), settingsView(w, store, &jobs)),
)
tabs.SetTabLocation(container.TabLocationTop)
@@ -557,14 +547,22 @@ func newHistoryView(events *[]event) *fyne.Container {
return container.NewPadded(list)
}
func settingsView(store *core.Store) fyne.CanvasObject {
func settingsView(w fyne.Window, store *core.Store, jobs *[]job) fyne.CanvasObject {
runOnStartup := widget.NewCheck("Start PySentry when I sign in", nil)
minimizeToTray := widget.NewCheck("Keep running in the system tray", nil)
minimizeToTray.SetChecked(store.Config.KeepRunningInTray)
notifications := widget.NewCheck("Show desktop notifications for failed jobs", nil)
notifications.SetChecked(store.Config.NotifyOnFailure)
jobsDir := widget.NewEntry()
jobsDir.SetText(store.Config.JobsDir)
jobsDirBrowse := widget.NewButtonWithIcon("Browse", theme.FolderOpenIcon(), func() {
chooseFolder(w, jobsDir)
})
logsDir := widget.NewEntry()
logsDir.SetText(store.Config.LogsDir)
logsDirBrowse := widget.NewButtonWithIcon("Browse", theme.FolderOpenIcon(), func() {
chooseFolder(w, logsDir)
})
maxLogFiles := widget.NewEntry()
maxLogFiles.SetText(strconv.Itoa(store.Config.MaxLogFiles))
maxLogAgeDays := widget.NewEntry()
@@ -583,6 +581,15 @@ func settingsView(store *core.Store) fyne.CanvasObject {
return
}
store.Config.LogsDir = strings.TrimSpace(logsDir.Text)
if strings.TrimSpace(jobsDir.Text) == "" {
settingsStatus.SetText("Jobs directory is required")
return
}
if strings.TrimSpace(logsDir.Text) == "" {
settingsStatus.SetText("Logs directory is required")
return
}
store.Config.JobsDir = strings.TrimSpace(jobsDir.Text)
store.Config.MaxLogFiles = files
store.Config.MaxLogAgeDays = days
store.Config.KeepRunningInTray = minimizeToTray.Checked
@@ -591,6 +598,10 @@ func settingsView(store *core.Store) fyne.CanvasObject {
settingsStatus.SetText("Save failed: " + err.Error())
return
}
if err := store.SaveJobs(*jobs); err != nil {
settingsStatus.SetText("Jobs 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
@@ -606,9 +617,8 @@ func settingsView(store *core.Store) fyne.CanvasObject {
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("Jobs directory", container.NewBorder(nil, nil, nil, jobsDirBrowse, jobsDir)),
detailRow("Logs directory", container.NewBorder(nil, nil, nil, logsDirBrowse, logsDir)),
detailRow("Max log files", maxLogFiles),
detailRow("Max log age days", maxLogAgeDays),
saveSettings,
@@ -618,3 +628,12 @@ func settingsView(store *core.Store) fyne.CanvasObject {
widget.NewLabel("Current core supports @every schedules. Cron expressions come next."),
))
}
func chooseFolder(w fyne.Window, target *widget.Entry) {
dialog.ShowFolderOpen(func(uri fyne.ListableURI, err error) {
if err != nil || uri == nil {
return
}
target.SetText(uri.Path())
}, w)
}
Binary file not shown.

After

Width:  |  Height:  |  Size: 996 KiB

+5 -5
View File
@@ -22,11 +22,11 @@ type Job struct {
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"`
LastRun string `yaml:"-"`
NextRun string `yaml:"-"`
LastState string `yaml:"-"`
Logs []RunRecord `yaml:"-"`
Output string `yaml:"-"`
nextDue time.Time
}
+21 -10
View File
@@ -5,8 +5,12 @@ import (
"strings"
"sync"
"time"
"github.com/robfig/cron/v3"
)
var cronParser = cron.NewParser(cron.Minute | cron.Hour | cron.Dom | cron.Month | cron.Dow | cron.Descriptor)
type Scheduler struct {
store *Store
jobs *[]Job
@@ -146,24 +150,31 @@ func (s *Scheduler) resetNextRuns(now time.Time) {
}
func (s *Scheduler) prepareNextRun(job *Job, from time.Time) {
interval, ok := parseEvery(job.Schedule)
next, ok := nextRunTime(job.Schedule, from)
if !ok {
job.NextRun = "Unsupported schedule"
job.NextRun = "Invalid schedule"
job.nextDue = time.Time{}
return
}
job.nextDue = from.Add(interval)
job.nextDue = next
job.NextRun = job.nextDue.Format("2006-01-02 15:04:05")
}
func parseEvery(schedule string) (time.Duration, bool) {
func nextRunTime(schedule string, from time.Time) (time.Time, bool) {
schedule = strings.TrimSpace(schedule)
if !strings.HasPrefix(schedule, "@every ") {
return 0, false
if schedule == "" {
return time.Time{}, false
}
interval, err := time.ParseDuration(strings.TrimSpace(strings.TrimPrefix(schedule, "@every ")))
if err != nil || interval <= 0 {
return 0, false
if strings.HasPrefix(schedule, "@every ") {
interval, err := time.ParseDuration(strings.TrimSpace(strings.TrimPrefix(schedule, "@every ")))
if err != nil || interval <= 0 {
return time.Time{}, false
}
return from.Add(interval), true
}
return interval, true
parsed, err := cronParser.Parse(schedule)
if err != nil {
return time.Time{}, false
}
return parsed.Next(from), true
}
+29
View File
@@ -0,0 +1,29 @@
package core
import (
"testing"
"time"
)
func TestNextRunTimeSupportsEvery(t *testing.T) {
from := time.Date(2026, 6, 14, 12, 0, 0, 0, time.UTC)
next, ok := nextRunTime("@every 10s", from)
if !ok {
t.Fatal("expected @every schedule to parse")
}
if want := from.Add(10 * time.Second); !next.Equal(want) {
t.Fatalf("expected %s, got %s", want, next)
}
}
func TestNextRunTimeSupportsCron(t *testing.T) {
from := time.Date(2026, 6, 14, 12, 3, 0, 0, time.UTC)
next, ok := nextRunTime("*/5 * * * *", from)
if !ok {
t.Fatal("expected cron schedule to parse")
}
want := time.Date(2026, 6, 14, 12, 5, 0, 0, time.UTC)
if !next.Equal(want) {
t.Fatalf("expected %s, got %s", want, next)
}
}
+58 -29
View File
@@ -36,6 +36,10 @@ func OpenStore() (*Store, []Job, error) {
if err != nil {
return nil, nil, err
}
normalizeJobs(jobs)
if err := store.SaveJobs(jobs); err != nil {
return nil, nil, err
}
return store, jobs, nil
}
@@ -93,6 +97,7 @@ func loadOrCreateConfig(paths Paths) (Config, error) {
func loadOrCreateJobs(path string) ([]Job, error) {
if _, err := os.Stat(path); errors.Is(err, os.ErrNotExist) {
jobs := defaultJobs()
normalizeJobs(jobs)
return jobs, writeYAML(path, JobsFile{Jobs: jobs})
}
@@ -107,6 +112,42 @@ func loadOrCreateJobs(path string) ([]Job, error) {
return file.Jobs, nil
}
func normalizeJobs(jobs []Job) {
next := 1
for index := range jobs {
job := &jobs[index]
if job.ID <= 0 {
job.ID = next
}
if job.ID >= next {
next = job.ID + 1
}
if strings.TrimSpace(job.Name) == "" {
job.Name = "Untitled job"
}
if strings.TrimSpace(job.Schedule) == "" {
job.Schedule = "@every 1m"
}
if strings.TrimSpace(job.Command) == "" {
job.Command = echoCommand("PySentry job ran")
}
if job.LastRun == "" {
job.LastRun = "Never"
}
if job.Output == "" {
job.Output = "No command output captured yet."
}
if job.Enabled {
job.LastState = "Ready"
job.NextRun = "After start"
} else {
job.LastState = "Paused"
job.NextRun = "Paused"
}
job.Logs = nil
}
}
func resolveJobsDir(appDir string, jobsDir string) string {
return resolveConfiguredDir(appDir, jobsDir)
}
@@ -138,39 +179,27 @@ func writeYAML(path string, value any) error {
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: 1,
Name: "Hello scheduler",
Folder: "Examples",
Schedule: "@every 10s",
Command: echoCommand("PySentry test job: scheduler is alive"),
Enabled: true,
},
{
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: 2,
Name: "Write timestamp",
Folder: "Examples",
Schedule: "*/1 * * * *",
Command: echoCommand("PySentry test job: timestamp command ran"),
Enabled: true,
},
{
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.",
ID: 3,
Name: "Paused sample",
Schedule: "@every 1m",
Command: echoCommand("This paused sample should not run until enabled"),
Enabled: false,
},
}
}
+38
View File
@@ -0,0 +1,38 @@
package core
import (
"strings"
"testing"
"gopkg.in/yaml.v3"
)
func TestJobsYAMLDoesNotPersistRuntimeNoise(t *testing.T) {
jobs := []Job{
{
ID: 1,
Name: "Clean job",
Schedule: "@every 10s",
Command: echoCommand("ok"),
Enabled: true,
LastRun: "2026-06-14 12:00:00",
NextRun: "2026-06-14 12:00:10",
LastState: "OK",
Output: "stdout: ok",
Logs: []RunRecord{
{Time: "2026-06-14 12:00:00", JobName: "Clean job", Output: "stdout: ok"},
},
},
}
data, err := yaml.Marshal(JobsFile{Jobs: jobs})
if err != nil {
t.Fatal(err)
}
text := string(data)
for _, unwanted := range []string{"last_run", "next_run", "last_state", "activity", "last_output", "stdout"} {
if strings.Contains(text, unwanted) {
t.Fatalf("jobs yaml should not contain %q:\n%s", unwanted, text)
}
}
}