Reorganize source tree and build assets
This commit is contained in:
@@ -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"`
|
||||
}
|
||||
@@ -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,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
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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, "'", "'\\''") + "'"
|
||||
}
|
||||
@@ -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
@@ -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()
|
||||
}
|
||||
Reference in New Issue
Block a user