Reorganize source tree and build assets

This commit is contained in:
mixeme
2026-06-14 23:40:37 +03:00
parent 414be2dfe9
commit a9d1d9529e
22 changed files with 84 additions and 59 deletions
+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:"-"`
NextRun string `yaml:"-"`
LastState string `yaml:"-"`
Logs []RunRecord `yaml:"-"`
Output string `yaml:"-"`
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)
}
}
}
+180
View File
@@ -0,0 +1,180 @@
package core
import (
"context"
"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
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) {
next, ok := nextRunTime(job.Schedule, from)
if !ok {
job.NextRun = "Invalid schedule"
job.nextDue = time.Time{}
return
}
job.nextDue = next
job.NextRun = job.nextDue.Format("2006-01-02 15:04:05")
}
func nextRunTime(schedule string, from time.Time) (time.Time, bool) {
schedule = strings.TrimSpace(schedule)
if schedule == "" {
return time.Time{}, 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
}
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)
}
}
+212
View File
@@ -0,0 +1,212 @@
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
}
normalizeJobs(jobs)
if err := store.SaveJobs(jobs); 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()
normalizeJobs(jobs)
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 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)
}
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,
},
{
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,
},
}
}
func echoCommand(message string) string {
if runtime.GOOS == "windows" {
return "echo " + message
}
return "echo '" + strings.ReplaceAll(message, "'", "'\\''") + "'"
}
+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)
}
}
}
+651
View File
@@ -0,0 +1,651 @@
package gui
import (
"fmt"
"os"
"path/filepath"
"strconv"
"strings"
"time"
"github.com/pysentry/pysentry/src/core"
"fyne.io/fyne/v2"
"fyne.io/fyne/v2/app"
"fyne.io/fyne/v2/container"
"fyne.io/fyne/v2/dialog"
"fyne.io/fyne/v2/driver/desktop"
"fyne.io/fyne/v2/layout"
"fyne.io/fyne/v2/theme"
"fyne.io/fyne/v2/widget"
)
const appID = "io.github.pysentry.desktop"
const allFolders = "All"
const noFolder = "No folder"
type job = core.Job
type event = core.RunRecord
func Run() {
a := app.NewWithID(appID)
a.SetIcon(loadAppIcon())
w := a.NewWindow("PySentry")
configureSystemTray(a, w)
w.Resize(fyne.NewSize(1120, 720))
w.SetContent(newMainView(w))
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 {
return
}
menu := fyne.NewMenu("PySentry",
fyne.NewMenuItem("Show", func() {
w.Show()
w.RequestFocus()
}),
fyne.NewMenuItemSeparator(),
fyne.NewMenuItem("Quit", func() {
a.Quit()
}),
)
desk.SetSystemTrayMenu(menu)
w.SetCloseIntercept(func() {
w.Hide()
})
}
func newMainView(w fyne.Window) fyne.CanvasObject {
store, jobs, err := core.OpenStore()
if err != nil {
return container.NewPadded(widget.NewLabel("Failed to load PySentry configuration: " + err.Error()))
}
events := collectActivity(jobs)
nextJobID := nextID(jobs)
selected := 0
selectedFolder := allFolders
schedulerPaused := false
filteredJobs := filteredJobIndexes(jobs, selectedFolder)
title := widget.NewLabelWithStyle(jobs[selected].Name, fyne.TextAlignLeading, fyne.TextStyle{Bold: true})
folder := widget.NewLabel(jobs[selected].Folder)
schedule := widget.NewLabel(jobs[selected].Schedule)
command := widget.NewLabel(jobs[selected].Command)
lastRun := widget.NewLabel(jobs[selected].LastRun)
nextRun := widget.NewLabel(jobs[selected].NextRun)
state := widget.NewLabel(jobs[selected].LastState)
schedulerState := widget.NewLabel("Scheduler running")
commandOutput := widget.NewTextGrid()
commandOutput.SetText(jobs[selected].Output)
commandOutputScroll := container.NewScroll(commandOutput)
commandOutputScroll.SetMinSize(fyne.NewSize(520, 160))
history := newHistoryView(&events)
jobLogs := widget.NewList(
func() int {
if selected < 0 || selected >= len(jobs) {
return 0
}
return len(jobs[selected].Logs)
},
func() fyne.CanvasObject { return widget.NewLabel("log") },
func(id widget.ListItemID, item fyne.CanvasObject) {
item.(*widget.Label).SetText(eventText(jobs[selected].Logs[id]))
},
)
updateDetails := func(index int) {
if index < 0 || index >= len(jobs) {
title.SetText("No job selected")
folder.SetText("")
schedule.SetText("")
command.SetText("")
lastRun.SetText("")
nextRun.SetText("")
state.SetText("")
commandOutput.SetText("")
return
}
selected = index
current := jobs[selected]
title.SetText(current.Name)
folder.SetText(displayFolder(current.Folder))
schedule.SetText(current.Schedule)
command.SetText(current.Command)
lastRun.SetText(current.LastRun)
nextRun.SetText(current.NextRun)
state.SetText(current.LastState)
commandOutput.SetText(current.Output)
}
refresh := func() {
filteredJobs = filteredJobIndexes(jobs, selectedFolder)
updateDetails(selected)
jobLogs.Refresh()
history.Refresh()
}
var scheduler *core.Scheduler
list := widget.NewList(
func() int { return len(filteredJobs) },
func() fyne.CanvasObject {
name := widget.NewLabelWithStyle("Job name", fyne.TextAlignLeading, fyne.TextStyle{Bold: true})
meta := widget.NewLabel("schedule")
status := widget.NewLabel("status")
return container.NewVBox(name, meta, status)
},
func(id widget.ListItemID, item fyne.CanvasObject) {
row := item.(*fyne.Container)
name := row.Objects[0].(*widget.Label)
meta := row.Objects[1].(*widget.Label)
status := row.Objects[2].(*widget.Label)
current := jobs[filteredJobs[id]]
name.SetText(current.Name)
meta.SetText(displayFolder(current.Folder) + " " + current.Schedule + " " + current.Command)
status.SetText(statusText(current))
},
)
list.OnSelected = func(id widget.ListItemID) {
if id < 0 || id >= len(filteredJobs) {
updateDetails(-1)
return
}
updateDetails(filteredJobs[id])
}
list.Select(selected)
folderSelect := widget.NewSelect(folderOptions(jobs), func(value string) {
if value == "" {
return
}
selectedFolder = value
filteredJobs = filteredJobIndexes(jobs, selectedFolder)
list.Refresh()
if len(filteredJobs) == 0 {
selected = -1
updateDetails(-1)
return
}
selected = filteredJobs[0]
list.Select(0)
refresh()
})
folderSelect.SetSelected(selectedFolder)
addButton := widget.NewButtonWithIcon("New job", theme.ContentAddIcon(), func() {
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)
selected = len(jobs) - 1
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)
if selectedFolder != allFolders && selectedFolder != targetFolder {
selectedFolder = targetFolder
folderSelect.SetSelected(targetFolder)
}
filteredJobs = filteredJobIndexes(jobs, selectedFolder)
list.Refresh()
list.Select(displayIndex(filteredJobs, selected))
refresh()
})
})
editButton := widget.NewButtonWithIcon("Edit", theme.DocumentCreateIcon(), func() {
if selected < 0 || selected >= len(jobs) {
return
}
showJobDialog(w, "Edit job", jobs[selected], func(saved job) {
saved.ID = jobs[selected].ID
saved.Logs = jobs[selected].Logs
saved.Output = jobs[selected].Output
jobs[selected] = saved
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()
refresh()
})
})
runButton := widget.NewButtonWithIcon("Run now", theme.MediaPlayIcon(), func() {
if selected < 0 || selected >= len(jobs) {
return
}
if schedulerPaused {
dialog.ShowInformation("Scheduler paused", "Global pause is active. Resume the scheduler before running jobs.", w)
return
}
ran := scheduler.RunNow(selected)
if ran.Time == "" {
return
}
events = append([]event{ran}, events...)
list.Refresh()
refresh()
})
stopAllButton := widget.NewButtonWithIcon("Pause all", theme.MediaStopIcon(), nil)
stopAllButton.OnTapped = func() {
schedulerPaused = !schedulerPaused
if schedulerPaused {
schedulerState.SetText("Scheduler paused")
stopAllButton.SetText("Resume all")
stopAllButton.SetIcon(theme.MediaPlayIcon())
for index := range jobs {
if jobs[index].Enabled {
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")
stopAllButton.SetText("Pause all")
stopAllButton.SetIcon(theme.MediaStopIcon())
for index := range jobs {
if jobs[index].Enabled && jobs[index].NextRun == "Scheduler paused" {
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()
refresh()
}
pauseButton := widget.NewButtonWithIcon("Pause", theme.MediaPauseIcon(), func() {
if selected < 0 || selected >= len(jobs) {
return
}
current := &jobs[selected]
current.Enabled = !current.Enabled
if current.Enabled {
current.LastState = "Ready"
current.NextRun = "Waiting for scheduler"
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()
})
deleteButton := widget.NewButtonWithIcon("Delete", theme.DeleteIcon(), func() {
if selected < 0 || selected >= len(jobs) {
return
}
deleted := jobs[selected]
dialog.ShowConfirm("Delete job", fmt.Sprintf("Delete %q?", deleted.Name), func(confirm bool) {
if !confirm {
return
}
jobs = append(jobs[:selected], jobs[selected+1:]...)
folderSelect.Options = folderOptions(jobs)
folderSelect.Refresh()
filteredJobs = filteredJobIndexes(jobs, selectedFolder)
if len(filteredJobs) == 0 && selectedFolder != allFolders {
selectedFolder = allFolders
folderSelect.SetSelected(allFolders)
filteredJobs = filteredJobIndexes(jobs, selectedFolder)
}
if len(filteredJobs) == 0 {
selected = -1
} else {
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))
}
refresh()
}, w)
})
toolbar := container.NewHBox(addButton, editButton, runButton, pauseButton, deleteButton, layout.NewSpacer())
globalControls := container.NewHBox(stopAllButton, schedulerState, layout.NewSpacer())
sidebarHeader := container.NewVBox(globalControls, widget.NewSeparator(), widget.NewLabelWithStyle("Folder", fyne.TextAlignLeading, fyne.TextStyle{Bold: true}), folderSelect, toolbar)
sidebar := container.NewBorder(sidebarHeader, nil, nil, nil, list)
details := container.NewVBox(
title,
widget.NewSeparator(),
detailRow("Folder", folder),
detailRow("Schedule", schedule),
detailRow("Command", command),
detailRow("Last run", lastRun),
detailRow("Next run", nextRun),
detailRow("State", state),
widget.NewSeparator(),
widget.NewLabelWithStyle("Command output", fyne.TextAlignLeading, fyne.TextStyle{Bold: true}),
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(w, store, &jobs)),
)
tabs.SetTabLocation(container.TabLocationTop)
return tabs
}
func statusText(j job) string {
if !j.Enabled {
return "Paused"
}
return j.LastState
}
func newEvent(jobID int, jobName string, state string, detail string) event {
return event{
Time: time.Now().Format("15:04:05"),
JobID: jobID,
JobName: jobName,
Trigger: "UI",
State: state,
Detail: detail,
}
}
func eventText(e event) string {
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 {
caption := widget.NewLabelWithStyle(label, fyne.TextAlignLeading, fyne.TextStyle{Bold: true})
caption.Wrapping = fyne.TextTruncate
return container.NewGridWithColumns(2, caption, value)
}
func filteredJobIndexes(jobs []job, folder string) []int {
indexes := make([]int, 0, len(jobs))
for index, current := range jobs {
if folder == allFolders || filterValue(current.Folder) == folder {
indexes = append(indexes, index)
}
}
return indexes
}
func folderOptions(jobs []job) []string {
options := []string{allFolders, noFolder}
seen := map[string]bool{allFolders: true, noFolder: true}
for _, current := range jobs {
folder := strings.TrimSpace(current.Folder)
if folder == "" || seen[folder] {
continue
}
seen[folder] = true
options = append(options, folder)
}
return options
}
func filterValue(folder string) string {
if strings.TrimSpace(folder) == "" {
return noFolder
}
return strings.TrimSpace(folder)
}
func displayFolder(folder string) string {
if strings.TrimSpace(folder) == "" {
return "(" + noFolder + ")"
}
return strings.TrimSpace(folder)
}
func displayIndex(indexes []int, jobIndex int) int {
for display, index := range indexes {
if index == jobIndex {
return display
}
}
return 0
}
func showJobDialog(w fyne.Window, title string, current job, onSave func(job)) {
name := widget.NewEntry()
name.SetPlaceHolder("Nightly backup")
name.SetText(current.Name)
folder := widget.NewEntry()
folder.SetPlaceHolder("Maintenance")
folder.SetText(current.Folder)
schedule := widget.NewEntry()
schedule.SetPlaceHolder("@every 1m")
schedule.SetText(current.Schedule)
command := widget.NewEntry()
command.SetPlaceHolder("echo PySentry job ran")
command.SetText(current.Command)
enabled := widget.NewCheck("Enabled", nil)
enabled.SetChecked(current.Enabled)
form := dialog.NewForm(
title,
"Save",
"Cancel",
[]*widget.FormItem{
widget.NewFormItem("Name", name),
widget.NewFormItem("Folder", folder),
widget.NewFormItem("Schedule", schedule),
widget.NewFormItem("Command", command),
widget.NewFormItem("", enabled),
},
func(saved bool) {
if !saved {
return
}
if strings.TrimSpace(name.Text) == "" || strings.TrimSpace(schedule.Text) == "" || strings.TrimSpace(command.Text) == "" {
dialog.ShowError(fmt.Errorf("name, schedule, and command are required"), w)
return
}
current.Name = strings.TrimSpace(name.Text)
current.Folder = strings.TrimSpace(folder.Text)
current.Schedule = strings.TrimSpace(schedule.Text)
current.Command = strings.TrimSpace(command.Text)
current.Enabled = enabled.Checked
if current.LastRun == "" {
current.LastRun = "Never"
}
if current.Enabled {
current.NextRun = "Waiting for scheduler"
if current.LastState == "" || current.LastState == "Paused" {
current.LastState = "Ready"
}
} else {
current.NextRun = "Paused"
current.LastState = "Paused"
}
onSave(current)
},
w,
)
form.Resize(fyne.NewSize(560, 280))
form.Show()
}
func newHistoryView(events *[]event) *fyne.Container {
list := widget.NewList(
func() int { return len(*events) },
func() fyne.CanvasObject { return widget.NewLabel("event") },
func(id widget.ListItemID, item fyne.CanvasObject) {
item.(*widget.Label).SetText(eventText((*events)[id]))
},
)
return container.NewPadded(list)
}
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()
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)
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
store.Config.NotifyOnFailure = notifications.Checked
if err := store.SaveConfig(); err != nil {
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
}
settingsStatus.SetText("Saved")
})
return container.NewPadded(container.NewVBox(
widget.NewLabelWithStyle("Application", fyne.TextAlignLeading, fyne.TextStyle{Bold: true}),
runOnStartup,
minimizeToTray,
notifications,
widget.NewSeparator(),
widget.NewLabelWithStyle("Storage", fyne.TextAlignLeading, fyne.TextStyle{Bold: true}),
detailRow("Config YAML", widget.NewLabel(store.Paths.ConfigPath)),
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,
settingsStatus,
widget.NewSeparator(),
widget.NewLabelWithStyle("Scheduler", fyne.TextAlignLeading, fyne.TextStyle{Bold: true}),
widget.NewLabel("Current core supports @every schedules. Cron expressions come next."),
))
}
func chooseFolder(w fyne.Window, target *widget.Entry) {
folderDialog := dialog.NewFolderOpen(func(uri fyne.ListableURI, err error) {
if err != nil || uri == nil {
return
}
target.SetText(uri.Path())
}, w)
folderDialog.Resize(fyne.NewSize(900, 640))
folderDialog.Show()
}