Refactoring complete: v0.4.0 architectural milestone (#1)
## Summary Completed Phase 5 refactoring and reached the target architecture. **Architectural milestone achieved:** - Service layer owns all state and is the sole writer - UI is a thin Fyne view, all widget updates marshaled via `fyne.Do` - Core engines are stateless and injectable - Domain types are pure (no `yaml:"-"` fields) - Full module builds and `go vet ./...` clean ## Changes - Bump version: 0.3.6 → 0.4.0 - Update CHANGELOG with Phase 5 summary - Add ROADMAP "Refactoring Follow-Ups" section ## Known follow-up work 1. **Linux test build broken** — `runner_test.go` needs `//go:build windows` tag 2. **File-size limits exceeded** — `operations.go` (486 lines), `jobs_view.go` (415 lines) See ROADMAP.md for details. --------- Co-authored-by: mixeme <mix.public@ya.ru> Reviewed-on: #1
This commit was merged in pull request #1.
This commit is contained in:
@@ -0,0 +1,100 @@
|
||||
package app
|
||||
|
||||
import "gitea.mixdep.ru/mix/gosentry/src/domain"
|
||||
|
||||
// Event is something the Service did to its state that observers may want to
|
||||
// react to. It is a sealed interface: the concrete types in this file are the
|
||||
// only implementations (enforced by the unexported isEvent marker), so a UI
|
||||
// listener can exhaustively type-switch over them and the compiler will flag a
|
||||
// new event type that a switch forgot to handle.
|
||||
//
|
||||
// Events replace the old single onChange callback. Instead of the scheduler
|
||||
// reaching into the GUI, the Service emits typed events and the UI subscribes —
|
||||
// the UI's listener becomes the one place that touches widgets.
|
||||
type Event interface {
|
||||
isEvent()
|
||||
}
|
||||
|
||||
// JobChanged signals that a job's durable config or transient runtime changed:
|
||||
// created, edited, deleted, enabled/disabled, or a status transition such as a
|
||||
// run starting. Observers should re-read the affected state through the Service
|
||||
// (Jobs/Runtime) rather than expect a payload snapshot — that keeps the event
|
||||
// small and avoids handing out stale copies.
|
||||
//
|
||||
// JobID identifies the affected job. A zero JobID means a broad change (for
|
||||
// example a delete, or a global pause that touched every job) and observers
|
||||
// should refresh their whole view.
|
||||
type JobChanged struct {
|
||||
JobID int
|
||||
}
|
||||
|
||||
// RunRecorded signals that a job run finished and produced a RunRecord. It
|
||||
// carries the record by value because the record is an immutable result that
|
||||
// observers append to history; there is nothing for them to re-read.
|
||||
type RunRecorded struct {
|
||||
Record domain.RunRecord
|
||||
}
|
||||
|
||||
// SchedulerStateChanged signals that the global scheduler pause state flipped.
|
||||
// The UI uses it to update the pause/resume control and status text.
|
||||
type SchedulerStateChanged struct {
|
||||
Paused bool
|
||||
}
|
||||
|
||||
// ErrorOccurred signals a background error that could not be returned to a
|
||||
// caller — typically a failed save or cleanup after an async run. The UI
|
||||
// surfaces it in the History tab so the user is not silently left with
|
||||
// un-persisted state.
|
||||
type ErrorOccurred struct {
|
||||
Err error
|
||||
}
|
||||
|
||||
func (JobChanged) isEvent() {}
|
||||
func (RunRecorded) isEvent() {}
|
||||
func (SchedulerStateChanged) isEvent() {}
|
||||
func (ErrorOccurred) isEvent() {}
|
||||
|
||||
// Observer receives events emitted by the Service. OnEvent is the single
|
||||
// reaction point; the UI implements it and marshals any widget work onto the
|
||||
// main thread (fyne.Do) itself — the Service knows nothing about Fyne.
|
||||
type Observer interface {
|
||||
OnEvent(Event)
|
||||
}
|
||||
|
||||
// ObserverFunc adapts a plain function to the Observer interface, so callers can
|
||||
// subscribe a closure without declaring a type.
|
||||
type ObserverFunc func(Event)
|
||||
|
||||
// OnEvent calls the wrapped function.
|
||||
func (f ObserverFunc) OnEvent(event Event) { f(event) }
|
||||
|
||||
// Subscribe registers an observer to receive every subsequently emitted event.
|
||||
// Registration is expected during setup, before the scheduler starts, but is
|
||||
// guarded so it is safe at any time.
|
||||
func (s *Service) Subscribe(observer Observer) {
|
||||
s.dispatchMu.Lock()
|
||||
defer s.dispatchMu.Unlock()
|
||||
s.observers = append(s.observers, observer)
|
||||
}
|
||||
|
||||
// emit delivers an event to every registered observer.
|
||||
//
|
||||
// Single-threaded dispatch contract:
|
||||
// - emit holds dispatchMu for the whole dispatch, so observers are never
|
||||
// invoked concurrently and never overlap with each other or with Subscribe.
|
||||
// Each observer sees events one at a time, in emit order.
|
||||
// - emit must be called WITHOUT holding s.mu. The Service computes a state
|
||||
// change under mu, releases it, then emits — so an observer is free to call
|
||||
// back into read methods (Jobs/Runtime) without deadlocking on the state
|
||||
// lock.
|
||||
// - An observer must NOT call back into a Service method that emits (directly
|
||||
// or indirectly): dispatchMu is non-reentrant, so re-entrant emission would
|
||||
// deadlock. Observers react and return quickly; long or UI work is the
|
||||
// observer's own responsibility to defer (e.g. fyne.Do).
|
||||
func (s *Service) emit(event Event) {
|
||||
s.dispatchMu.Lock()
|
||||
defer s.dispatchMu.Unlock()
|
||||
for _, observer := range s.observers {
|
||||
observer.OnEvent(event)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,59 @@
|
||||
package app
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"gitea.mixdep.ru/mix/gosentry/src/domain"
|
||||
)
|
||||
|
||||
func TestEmitDeliversToAllObserversInOrder(t *testing.T) {
|
||||
svc := newTestService(nil)
|
||||
|
||||
var first, second []Event
|
||||
svc.Subscribe(ObserverFunc(func(e Event) { first = append(first, e) }))
|
||||
svc.Subscribe(ObserverFunc(func(e Event) { second = append(second, e) }))
|
||||
|
||||
svc.emit(JobChanged{JobID: 7})
|
||||
svc.emit(RunRecorded{Record: domain.RunRecord{JobID: 7, State: "Success"}})
|
||||
svc.emit(SchedulerStateChanged{Paused: true})
|
||||
|
||||
for name, got := range map[string][]Event{"first": first, "second": second} {
|
||||
if len(got) != 3 {
|
||||
t.Fatalf("%s observer got %d events, want 3", name, len(got))
|
||||
}
|
||||
if jc, ok := got[0].(JobChanged); !ok || jc.JobID != 7 {
|
||||
t.Errorf("%s event[0] = %#v, want JobChanged{JobID:7}", name, got[0])
|
||||
}
|
||||
if rr, ok := got[1].(RunRecorded); !ok || rr.Record.State != "Success" {
|
||||
t.Errorf("%s event[1] = %#v, want RunRecorded Success", name, got[1])
|
||||
}
|
||||
if ss, ok := got[2].(SchedulerStateChanged); !ok || !ss.Paused {
|
||||
t.Errorf("%s event[2] = %#v, want SchedulerStateChanged{Paused:true}", name, got[2])
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestEmitWithNoObserversIsNoop(t *testing.T) {
|
||||
svc := newTestService(nil)
|
||||
// Must not panic with an empty observer list.
|
||||
svc.emit(JobChanged{})
|
||||
}
|
||||
|
||||
// Observers may read Service state from within OnEvent without deadlocking,
|
||||
// because emit is called outside the state lock.
|
||||
func TestObserverCanReadServiceState(t *testing.T) {
|
||||
jobs := []domain.Job{{ID: 1, Name: "Job", Enabled: true}}
|
||||
svc := newTestService(jobs)
|
||||
|
||||
var sawName string
|
||||
svc.Subscribe(ObserverFunc(func(Event) {
|
||||
if snapshot := svc.Jobs(); len(snapshot) == 1 {
|
||||
sawName = snapshot[0].Name
|
||||
}
|
||||
}))
|
||||
|
||||
svc.emit(JobChanged{JobID: 1})
|
||||
if sawName != "Job" {
|
||||
t.Errorf("observer read name = %q, want %q", sawName, "Job")
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,89 @@
|
||||
package app
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"strings"
|
||||
|
||||
"gitea.mixdep.ru/mix/gosentry/src/domain"
|
||||
)
|
||||
|
||||
// StatusText formats a job's current state for display: "Paused" if disabled,
|
||||
// else its runtime LastState (Ready, Running, Success, etc).
|
||||
func StatusText(j domain.Job, runtime *domain.JobRuntime) string {
|
||||
if !j.Enabled {
|
||||
return "Paused"
|
||||
}
|
||||
if runtime == nil {
|
||||
return ""
|
||||
}
|
||||
return runtime.LastState
|
||||
}
|
||||
|
||||
// EventText formats a run record for the History table, showing time, trigger,
|
||||
// job name, outcome state, detail, and log file (if any).
|
||||
func EventText(e domain.RunRecord) 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)
|
||||
}
|
||||
|
||||
// DisplayFolder formats a job's folder for display: "(No folder)" if empty,
|
||||
// else the trimmed folder name.
|
||||
func DisplayFolder(folder string) string {
|
||||
if strings.TrimSpace(folder) == "" {
|
||||
return "(No folder)"
|
||||
}
|
||||
return strings.TrimSpace(folder)
|
||||
}
|
||||
|
||||
// DisplayArguments formats a job's arguments for display: "(none)" if empty,
|
||||
// else the trimmed arguments.
|
||||
func DisplayArguments(arguments string) string {
|
||||
if strings.TrimSpace(arguments) == "" {
|
||||
return "(none)"
|
||||
}
|
||||
return strings.TrimSpace(arguments)
|
||||
}
|
||||
|
||||
// DisplaySuccessExitCodes formats a job's success exit codes for display:
|
||||
// "0" (the default) if empty, else the trimmed codes.
|
||||
func DisplaySuccessExitCodes(codes string) string {
|
||||
if strings.TrimSpace(codes) == "" {
|
||||
return "0"
|
||||
}
|
||||
return strings.TrimSpace(codes)
|
||||
}
|
||||
|
||||
// DisplayRunMode formats a job's execution mode: "Start only" or
|
||||
// "Wait for completion".
|
||||
func DisplayRunMode(job domain.Job) string {
|
||||
if job.StartOnly {
|
||||
return "Start only"
|
||||
}
|
||||
return "Wait for completion"
|
||||
}
|
||||
|
||||
// DisplayInvocation formats a job's command and arguments for the jobs list,
|
||||
// joining them with spacing and collapsing newlines in arguments to spaces.
|
||||
func DisplayInvocation(job domain.Job) string {
|
||||
if strings.TrimSpace(job.Arguments) == "" {
|
||||
return job.Command
|
||||
}
|
||||
return job.Command + " " + strings.ReplaceAll(strings.TrimSpace(job.Arguments), "\n", " ")
|
||||
}
|
||||
|
||||
// DisplayIndex returns the position of jobIndex in the given slice of indexes,
|
||||
// or 0 if not found.
|
||||
func DisplayIndex(indexes []int, jobIndex int) int {
|
||||
for display, index := range indexes {
|
||||
if index == jobIndex {
|
||||
return display
|
||||
}
|
||||
}
|
||||
return 0
|
||||
}
|
||||
@@ -0,0 +1,109 @@
|
||||
package app
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"gitea.mixdep.ru/mix/gosentry/src/domain"
|
||||
)
|
||||
|
||||
func TestStatusText(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
job domain.Job
|
||||
runtime *domain.JobRuntime
|
||||
want string
|
||||
}{
|
||||
{"disabled is paused", domain.Job{Enabled: false}, &domain.JobRuntime{LastState: "Running"}, "Paused"},
|
||||
{"enabled shows runtime state", domain.Job{Enabled: true}, &domain.JobRuntime{LastState: "Success"}, "Success"},
|
||||
{"enabled with nil runtime is empty", domain.Job{Enabled: true}, nil, ""},
|
||||
}
|
||||
for _, tc := range tests {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
if got := StatusText(tc.job, tc.runtime); got != tc.want {
|
||||
t.Errorf("StatusText = %q, want %q", got, tc.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestEventText(t *testing.T) {
|
||||
withLog := domain.RunRecord{
|
||||
Time: "2026-06-19 12:00:00", Trigger: "Schedule", JobName: "Build",
|
||||
State: "Success", Detail: "ok", LogFile: "build.log",
|
||||
}
|
||||
if got, want := EventText(withLog), "2026-06-19 12:00:00 Schedule Build Success ok build.log"; got != want {
|
||||
t.Errorf("EventText with log = %q, want %q", got, want)
|
||||
}
|
||||
|
||||
noLog := domain.RunRecord{
|
||||
Time: "2026-06-19 12:00:00", Trigger: "Manual", JobName: "Build",
|
||||
State: "Success", Detail: "ok",
|
||||
}
|
||||
if got, want := EventText(noLog), "2026-06-19 12:00:00 Manual Build Success ok"; got != want {
|
||||
t.Errorf("EventText without log = %q, want %q", got, want)
|
||||
}
|
||||
|
||||
// An empty trigger is shown as "Unknown".
|
||||
blank := domain.RunRecord{Time: "t", JobName: "J", State: "S", Detail: "d"}
|
||||
if got, want := EventText(blank), "t Unknown J S d"; got != want {
|
||||
t.Errorf("EventText blank trigger = %q, want %q", got, want)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDisplayFolder(t *testing.T) {
|
||||
if got := DisplayFolder(" "); got != "(No folder)" {
|
||||
t.Errorf("blank folder = %q, want %q", got, "(No folder)")
|
||||
}
|
||||
if got := DisplayFolder(" Reports "); got != "Reports" {
|
||||
t.Errorf("folder = %q, want %q", got, "Reports")
|
||||
}
|
||||
}
|
||||
|
||||
func TestDisplayArguments(t *testing.T) {
|
||||
if got := DisplayArguments(""); got != "(none)" {
|
||||
t.Errorf("empty args = %q, want %q", got, "(none)")
|
||||
}
|
||||
if got := DisplayArguments(" -v "); got != "-v" {
|
||||
t.Errorf("args = %q, want %q", got, "-v")
|
||||
}
|
||||
}
|
||||
|
||||
func TestDisplaySuccessExitCodes(t *testing.T) {
|
||||
if got := DisplaySuccessExitCodes(" "); got != "0" {
|
||||
t.Errorf("empty codes = %q, want %q", got, "0")
|
||||
}
|
||||
if got := DisplaySuccessExitCodes(" 0,1 "); got != "0,1" {
|
||||
t.Errorf("codes = %q, want %q", got, "0,1")
|
||||
}
|
||||
}
|
||||
|
||||
func TestDisplayRunMode(t *testing.T) {
|
||||
if got := DisplayRunMode(domain.Job{StartOnly: true}); got != "Start only" {
|
||||
t.Errorf("start-only = %q, want %q", got, "Start only")
|
||||
}
|
||||
if got := DisplayRunMode(domain.Job{StartOnly: false}); got != "Wait for completion" {
|
||||
t.Errorf("wait = %q, want %q", got, "Wait for completion")
|
||||
}
|
||||
}
|
||||
|
||||
func TestDisplayInvocation(t *testing.T) {
|
||||
if got := DisplayInvocation(domain.Job{Command: "echo"}); got != "echo" {
|
||||
t.Errorf("no args = %q, want %q", got, "echo")
|
||||
}
|
||||
// Arguments are appended with spacing and their newlines collapsed to spaces.
|
||||
job := domain.Job{Command: "echo", Arguments: " hi\nthere "}
|
||||
if got, want := DisplayInvocation(job), "echo hi there"; got != want {
|
||||
t.Errorf("with args = %q, want %q", got, want)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDisplayIndex(t *testing.T) {
|
||||
indexes := []int{4, 7, 2}
|
||||
if got := DisplayIndex(indexes, 7); got != 1 {
|
||||
t.Errorf("DisplayIndex(7) = %d, want 1", got)
|
||||
}
|
||||
// A jobIndex not present returns 0.
|
||||
if got := DisplayIndex(indexes, 99); got != 0 {
|
||||
t.Errorf("DisplayIndex(missing) = %d, want 0", got)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,486 @@
|
||||
package app
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"gitea.mixdep.ru/mix/gosentry/src/domain"
|
||||
"gitea.mixdep.ru/mix/gosentry/src/runner"
|
||||
)
|
||||
|
||||
// maxJobLogs bounds the in-memory activity list kept per job. The full history
|
||||
// lives in the log files on disk; this is only the recent activity shown in the
|
||||
// GUI, so an old run aging out of the list is intentional.
|
||||
const maxJobLogs = 50
|
||||
|
||||
// timestampLayout matches the format used for run records so UI-action activity
|
||||
// and command runs line up in the History view.
|
||||
const timestampLayout = "2006-01-02 15:04:05"
|
||||
|
||||
// errJobNotFound is returned by the mutating operations when no loaded job has
|
||||
// the requested ID.
|
||||
var errJobNotFound = errors.New("job not found")
|
||||
|
||||
// CreateJob normalizes and validates the supplied configuration, assigns the
|
||||
// next free ID, and adds it to the loaded set. It returns the stored job (with
|
||||
// its assigned ID) so the caller can select it. The job is persisted and a
|
||||
// "Created" activity record is emitted.
|
||||
func (s *Service) CreateJob(job domain.Job) (domain.Job, error) {
|
||||
normalizeJob(&job)
|
||||
if err := validateJob(job); err != nil {
|
||||
return domain.Job{}, err
|
||||
}
|
||||
|
||||
s.mu.Lock()
|
||||
job.ID = s.nextIDLocked()
|
||||
s.jobs = append(s.jobs, job)
|
||||
runtime := domain.NewRuntime(job)
|
||||
s.runtimes[job.ID] = runtime
|
||||
s.parseScheduleLocked(&job)
|
||||
record := uiRecord(job.ID, job.Name, "Created", "Job was added")
|
||||
prependLog(runtime, record)
|
||||
err := s.store.SaveJobs(s.jobs)
|
||||
s.mu.Unlock()
|
||||
|
||||
s.emit(RunRecorded{Record: record})
|
||||
s.emit(JobChanged{JobID: job.ID})
|
||||
return job, err
|
||||
}
|
||||
|
||||
// UpdateJob replaces the durable configuration of the job with the same ID,
|
||||
// keeping its runtime state (keyed by ID) and recomputing its next run. The job
|
||||
// is persisted and an "Updated" activity record is emitted.
|
||||
func (s *Service) UpdateJob(job domain.Job) error {
|
||||
normalizeJob(&job)
|
||||
if err := validateJob(job); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
s.mu.Lock()
|
||||
existing := s.findByIDLocked(job.ID)
|
||||
if existing == nil {
|
||||
s.mu.Unlock()
|
||||
return fmt.Errorf("update job %d: %w", job.ID, errJobNotFound)
|
||||
}
|
||||
*existing = job
|
||||
runtime := s.runtimeForLocked(existing)
|
||||
// An edit may have toggled Enabled; reflect that into the status the same way
|
||||
// a dedicated enable/disable would, then recompute the next run.
|
||||
if job.Enabled {
|
||||
if runtime.LastState == "" || runtime.LastState == "Paused" {
|
||||
runtime.LastState = "Ready"
|
||||
}
|
||||
} else {
|
||||
runtime.LastState = "Paused"
|
||||
}
|
||||
s.parseScheduleLocked(existing)
|
||||
s.refreshNextRunLocked(existing, runtime)
|
||||
record := uiRecord(job.ID, job.Name, "Updated", "Job settings changed")
|
||||
prependLog(runtime, record)
|
||||
err := s.store.SaveJobs(s.jobs)
|
||||
s.mu.Unlock()
|
||||
|
||||
s.emit(RunRecorded{Record: record})
|
||||
s.emit(JobChanged{JobID: job.ID})
|
||||
return err
|
||||
}
|
||||
|
||||
// DeleteJob removes the job with the given ID along with its runtime and cached
|
||||
// schedule. The remaining jobs are persisted and a "Deleted" activity record is
|
||||
// emitted. The JobChanged event carries a zero ID to signal a broad change.
|
||||
func (s *Service) DeleteJob(id int) error {
|
||||
s.mu.Lock()
|
||||
index := s.indexByIDLocked(id)
|
||||
if index < 0 {
|
||||
s.mu.Unlock()
|
||||
return fmt.Errorf("delete job %d: %w", id, errJobNotFound)
|
||||
}
|
||||
deleted := s.jobs[index]
|
||||
s.jobs = append(s.jobs[:index], s.jobs[index+1:]...)
|
||||
delete(s.runtimes, id)
|
||||
delete(s.schedules, id)
|
||||
record := uiRecord(id, deleted.Name, "Deleted", "Job was removed")
|
||||
err := s.store.SaveJobs(s.jobs)
|
||||
s.mu.Unlock()
|
||||
|
||||
s.emit(RunRecorded{Record: record})
|
||||
s.emit(JobChanged{JobID: 0})
|
||||
return err
|
||||
}
|
||||
|
||||
// SetEnabled enables or disables a single job. Enabling moves it back to "Ready"
|
||||
// and recomputes its next run (respecting the global pause); disabling parks it
|
||||
// at "Paused". The job is persisted and a "Resumed"/"Paused" activity record is
|
||||
// emitted.
|
||||
func (s *Service) SetEnabled(id int, enabled bool) error {
|
||||
s.mu.Lock()
|
||||
job := s.findByIDLocked(id)
|
||||
if job == nil {
|
||||
s.mu.Unlock()
|
||||
return fmt.Errorf("set enabled job %d: %w", id, errJobNotFound)
|
||||
}
|
||||
job.Enabled = enabled
|
||||
runtime := s.runtimeForLocked(job)
|
||||
s.parseScheduleLocked(job)
|
||||
|
||||
var record domain.RunRecord
|
||||
if enabled {
|
||||
runtime.LastState = "Ready"
|
||||
s.refreshNextRunLocked(job, runtime)
|
||||
record = uiRecord(id, job.Name, "Resumed", "Job was enabled")
|
||||
} else {
|
||||
runtime.LastState = "Paused"
|
||||
runtime.NextRun = "Paused"
|
||||
runtime.NextDue = time.Time{}
|
||||
record = uiRecord(id, job.Name, "Paused", "Job was disabled")
|
||||
}
|
||||
prependLog(runtime, record)
|
||||
err := s.store.SaveJobs(s.jobs)
|
||||
s.mu.Unlock()
|
||||
|
||||
s.emit(RunRecorded{Record: record})
|
||||
s.emit(JobChanged{JobID: id})
|
||||
return err
|
||||
}
|
||||
|
||||
// SetGlobalPause flips the global pause that gates all execution, scheduled and
|
||||
// manual. Each enabled job's next-run text reflects the new state immediately so
|
||||
// the list view is understandable before the next tick. A "Paused"/"Resumed"
|
||||
// scheduler activity record and a SchedulerStateChanged event are emitted.
|
||||
func (s *Service) SetGlobalPause(paused bool) error {
|
||||
s.mu.Lock()
|
||||
s.paused = paused
|
||||
now := time.Now()
|
||||
for index := range s.jobs {
|
||||
job := &s.jobs[index]
|
||||
runtime := s.runtimeForLocked(job)
|
||||
s.refreshNextRunFromLocked(job, runtime, now)
|
||||
}
|
||||
err := s.store.SaveJobs(s.jobs)
|
||||
s.mu.Unlock()
|
||||
|
||||
state, detail := "Resumed", "All job execution resumed"
|
||||
if paused {
|
||||
state, detail = "Paused", "All job execution paused"
|
||||
}
|
||||
s.emit(RunRecorded{Record: uiRecord(0, "Scheduler", state, detail)})
|
||||
s.emit(SchedulerStateChanged{Paused: paused})
|
||||
return err
|
||||
}
|
||||
|
||||
// RunNow starts a manual run of a job. It refuses to run while globally paused —
|
||||
// the pause is an emergency stop for all execution — and will not start a job
|
||||
// that is already running. The run itself happens on a background goroutine that
|
||||
// records the result through the Service, so RunNow returns as soon as the run
|
||||
// is started. The error reports why a run could not be started (or a failure to
|
||||
// persist the "Running" status), not the run's own outcome.
|
||||
func (s *Service) RunNow(id int) error {
|
||||
s.mu.Lock()
|
||||
if s.paused {
|
||||
s.mu.Unlock()
|
||||
return errors.New("scheduler is paused")
|
||||
}
|
||||
job := s.findByIDLocked(id)
|
||||
if job == nil {
|
||||
s.mu.Unlock()
|
||||
return fmt.Errorf("run job %d: %w", id, errJobNotFound)
|
||||
}
|
||||
runtime := s.runtimeForLocked(job)
|
||||
if runtime.LastState == "Running" {
|
||||
s.mu.Unlock()
|
||||
return fmt.Errorf("job %d is already running", id)
|
||||
}
|
||||
err := s.startRunLocked(job, runtime, "Manual")
|
||||
s.mu.Unlock()
|
||||
|
||||
// Reflect the "Running" transition; the run's completion emits again later.
|
||||
s.emit(JobChanged{JobID: id})
|
||||
return err
|
||||
}
|
||||
|
||||
// RunDue is the scheduler's per-tick entry point: it starts whatever is due at
|
||||
// the given time. It is a no-op while globally paused. At most one job is started
|
||||
// per call so scheduled shell commands in this single process do not overlap; a
|
||||
// job already running is skipped. Run results are recorded back through the
|
||||
// Service, so the Service stays the sole writer of job and runtime state. The
|
||||
// time is supplied by the scheduler's clock, which lets tests drive
|
||||
// due-evaluation deterministically.
|
||||
func (s *Service) RunDue(now time.Time) {
|
||||
s.mu.Lock()
|
||||
var startedID int
|
||||
var startErr error
|
||||
if !s.paused {
|
||||
for index := range s.jobs {
|
||||
job := &s.jobs[index]
|
||||
runtime := s.runtimeForLocked(job)
|
||||
if !job.Enabled || runtime.NextDue.IsZero() || now.Before(runtime.NextDue) {
|
||||
continue
|
||||
}
|
||||
if runtime.LastState == "Running" {
|
||||
continue
|
||||
}
|
||||
startErr = s.startRunLocked(job, runtime, "Schedule")
|
||||
startedID = job.ID
|
||||
break
|
||||
}
|
||||
}
|
||||
s.mu.Unlock()
|
||||
|
||||
if startErr != nil {
|
||||
s.emit(ErrorOccurred{Err: fmt.Errorf("save jobs before scheduled run: %w", startErr)})
|
||||
}
|
||||
if startedID != 0 {
|
||||
s.emit(JobChanged{JobID: startedID})
|
||||
}
|
||||
}
|
||||
|
||||
// UpdateSettings validates and persists a new application configuration. The
|
||||
// loaded jobs are re-saved because the jobs directory may have changed, and log
|
||||
// cleanup runs so a tightened retention policy takes effect immediately.
|
||||
func (s *Service) UpdateSettings(config domain.Config) error {
|
||||
if err := validateConfig(config); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
s.mu.Lock()
|
||||
s.store.Config = config
|
||||
if err := s.store.SaveConfig(); err != nil {
|
||||
s.mu.Unlock()
|
||||
return err
|
||||
}
|
||||
// SaveConfig re-resolved the paths from the new config, so SaveJobs writes to
|
||||
// the (possibly new) jobs directory and cleanup targets the new logs dir.
|
||||
if err := s.store.SaveJobs(s.jobs); err != nil {
|
||||
s.mu.Unlock()
|
||||
return err
|
||||
}
|
||||
logsDir := s.store.Paths.LogsDir
|
||||
maxFiles := s.store.Config.MaxLogFiles
|
||||
maxAge := s.store.Config.MaxLogAgeDays
|
||||
s.mu.Unlock()
|
||||
|
||||
return runner.CleanupLogs(logsDir, maxFiles, maxAge)
|
||||
}
|
||||
|
||||
// startRunLocked transitions a job to "Running", persists that, and launches the
|
||||
// run on a background goroutine. The caller must hold mu.
|
||||
func (s *Service) startRunLocked(job *domain.Job, runtime *domain.JobRuntime, trigger string) error {
|
||||
jobCopy := *job
|
||||
runtime.LastState = "Running"
|
||||
runtime.NextRun = "Running"
|
||||
runtime.Output = runningOutput(jobCopy, trigger, time.Now())
|
||||
runtime.NextDue = time.Time{}
|
||||
err := s.store.SaveJobs(s.jobs)
|
||||
// Capture ctx under the lock so a concurrent Start/Stop cannot swap it out
|
||||
// from under the goroutine after we release mu.
|
||||
go s.executeRun(s.ctx, jobCopy, trigger)
|
||||
return err
|
||||
}
|
||||
|
||||
// executeRun runs the job off the lock, then records the result back through the
|
||||
// Service under the lock and announces it. It runs on its own goroutine.
|
||||
func (s *Service) executeRun(ctx context.Context, jobCopy domain.Job, trigger string) {
|
||||
record := s.runJob(ctx, &jobCopy, trigger, s.store.Paths.LogsDir)
|
||||
|
||||
s.mu.Lock()
|
||||
var cleanupErr, saveErr error
|
||||
if current := s.findByIDLocked(jobCopy.ID); current != nil {
|
||||
runtime := s.runtimeForLocked(current)
|
||||
runtime.LastRun = record.Time
|
||||
runtime.LastState = record.State
|
||||
runtime.Output = record.Output
|
||||
prependLog(runtime, record)
|
||||
s.refreshNextRunLocked(current, runtime)
|
||||
cleanupErr = runner.CleanupLogs(s.store.Paths.LogsDir, s.store.Config.MaxLogFiles, s.store.Config.MaxLogAgeDays)
|
||||
saveErr = s.store.SaveJobs(s.jobs)
|
||||
}
|
||||
s.mu.Unlock()
|
||||
|
||||
if cleanupErr != nil {
|
||||
s.emit(ErrorOccurred{Err: fmt.Errorf("log cleanup after run %q: %w", jobCopy.Name, cleanupErr)})
|
||||
}
|
||||
if saveErr != nil {
|
||||
s.emit(ErrorOccurred{Err: fmt.Errorf("save jobs after run %q: %w", jobCopy.Name, saveErr)})
|
||||
}
|
||||
s.emit(RunRecorded{Record: record})
|
||||
s.emit(JobChanged{JobID: jobCopy.ID})
|
||||
}
|
||||
|
||||
// refreshNextRunLocked recomputes a job's next-run display from the current time,
|
||||
// honoring enabled/paused state. The caller must hold mu.
|
||||
func (s *Service) refreshNextRunLocked(job *domain.Job, runtime *domain.JobRuntime) {
|
||||
s.refreshNextRunFromLocked(job, runtime, time.Now())
|
||||
}
|
||||
|
||||
// refreshNextRunFromLocked is refreshNextRunLocked with an explicit reference
|
||||
// time, used when one timestamp should drive a whole batch (e.g. a global
|
||||
// pause). The caller must hold mu.
|
||||
func (s *Service) refreshNextRunFromLocked(job *domain.Job, runtime *domain.JobRuntime, from time.Time) {
|
||||
if !job.Enabled {
|
||||
runtime.NextRun = "Paused"
|
||||
runtime.NextDue = time.Time{}
|
||||
return
|
||||
}
|
||||
if s.paused {
|
||||
runtime.NextRun = "Scheduler paused"
|
||||
runtime.NextDue = time.Time{}
|
||||
return
|
||||
}
|
||||
s.prepareNextRunLocked(job, runtime, from)
|
||||
}
|
||||
|
||||
// prepareNextRunLocked computes the concrete next-due time from the cached
|
||||
// schedule. A missing cache entry means the schedule string was unparseable.
|
||||
// The caller must hold mu.
|
||||
func (s *Service) prepareNextRunLocked(job *domain.Job, runtime *domain.JobRuntime, from time.Time) {
|
||||
sched, ok := s.schedules[job.ID]
|
||||
if !ok {
|
||||
runtime.NextRun = "Invalid schedule"
|
||||
runtime.NextDue = time.Time{}
|
||||
return
|
||||
}
|
||||
runtime.NextDue = sched.Next(from)
|
||||
runtime.NextRun = runtime.NextDue.Format(timestampLayout)
|
||||
}
|
||||
|
||||
// parseScheduleLocked caches a parsed schedule for the job, dropping the cache
|
||||
// entry when the schedule string is invalid so prepareNextRunLocked can tell the
|
||||
// two apart. The caller must hold mu.
|
||||
func (s *Service) parseScheduleLocked(job *domain.Job) {
|
||||
sched, err := domain.Parse(job.Schedule)
|
||||
if err != nil {
|
||||
delete(s.schedules, job.ID)
|
||||
return
|
||||
}
|
||||
s.schedules[job.ID] = sched
|
||||
}
|
||||
|
||||
// findByIDLocked returns a pointer into the jobs slice for the job with the
|
||||
// given ID, or nil. The caller must hold mu.
|
||||
func (s *Service) findByIDLocked(id int) *domain.Job {
|
||||
index := s.indexByIDLocked(id)
|
||||
if index < 0 {
|
||||
return nil
|
||||
}
|
||||
return &s.jobs[index]
|
||||
}
|
||||
|
||||
// indexByIDLocked returns the slice index of the job with the given ID, or -1.
|
||||
// The caller must hold mu.
|
||||
func (s *Service) indexByIDLocked(id int) int {
|
||||
for index := range s.jobs {
|
||||
if s.jobs[index].ID == id {
|
||||
return index
|
||||
}
|
||||
}
|
||||
return -1
|
||||
}
|
||||
|
||||
// runtimeForLocked returns the runtime for a job, lazily creating it if missing
|
||||
// so the Service stays robust if a job lacks an entry. The caller must hold mu.
|
||||
func (s *Service) runtimeForLocked(job *domain.Job) *domain.JobRuntime {
|
||||
runtime, ok := s.runtimes[job.ID]
|
||||
if !ok || runtime == nil {
|
||||
runtime = domain.NewRuntime(*job)
|
||||
s.runtimes[job.ID] = runtime
|
||||
}
|
||||
return runtime
|
||||
}
|
||||
|
||||
// nextIDLocked returns the smallest ID greater than every loaded job's ID. The
|
||||
// caller must hold mu.
|
||||
func (s *Service) nextIDLocked() int {
|
||||
next := 1
|
||||
for index := range s.jobs {
|
||||
if s.jobs[index].ID >= next {
|
||||
next = s.jobs[index].ID + 1
|
||||
}
|
||||
}
|
||||
return next
|
||||
}
|
||||
|
||||
// prependLog adds a record to the front of a runtime's activity list and caps
|
||||
// its length so it cannot grow without bound.
|
||||
func prependLog(runtime *domain.JobRuntime, record domain.RunRecord) {
|
||||
runtime.Logs = append([]domain.RunRecord{record}, runtime.Logs...)
|
||||
if len(runtime.Logs) > maxJobLogs {
|
||||
runtime.Logs = runtime.Logs[:maxJobLogs]
|
||||
}
|
||||
}
|
||||
|
||||
// uiRecord builds an activity record for a user/Service action, using the same
|
||||
// timestamp shape and "UI" trigger as the GUI did so History stays consistent.
|
||||
func uiRecord(jobID int, jobName string, state string, detail string) domain.RunRecord {
|
||||
return domain.RunRecord{
|
||||
Time: time.Now().Format(timestampLayout),
|
||||
JobID: jobID,
|
||||
JobName: jobName,
|
||||
Trigger: "UI",
|
||||
State: state,
|
||||
Detail: detail,
|
||||
}
|
||||
}
|
||||
|
||||
// runningOutput is the placeholder output shown while a job is running, before
|
||||
// the real command output replaces it.
|
||||
func runningOutput(job domain.Job, trigger string, started time.Time) string {
|
||||
var builder strings.Builder
|
||||
builder.WriteString("status:\n")
|
||||
builder.WriteString("Running since " + started.Format(timestampLayout) + "\n\n")
|
||||
builder.WriteString("trigger:\n")
|
||||
builder.WriteString(trigger + "\n\n")
|
||||
builder.WriteString("command:\n")
|
||||
builder.WriteString(job.Command + "\n\n")
|
||||
builder.WriteString("arguments:\n")
|
||||
builder.WriteString(runner.LogArguments(job.Arguments))
|
||||
builder.WriteString("\n\nsuccess_exit_codes:\n")
|
||||
builder.WriteString(runner.SuccessExitCodesText(job))
|
||||
builder.WriteString("\n\nstart_only:\n")
|
||||
builder.WriteString(fmt.Sprintf("%t", job.StartOnly))
|
||||
return builder.String()
|
||||
}
|
||||
|
||||
// normalizeJob trims user-entered fields and applies the same defaults the job
|
||||
// dialog used, so callers do not have to.
|
||||
func normalizeJob(job *domain.Job) {
|
||||
job.Name = strings.TrimSpace(job.Name)
|
||||
job.Folder = strings.TrimSpace(job.Folder)
|
||||
job.Schedule = strings.TrimSpace(job.Schedule)
|
||||
job.Command = strings.TrimSpace(job.Command)
|
||||
job.Arguments = strings.TrimSpace(job.Arguments)
|
||||
job.SuccessExitCodes = strings.TrimSpace(job.SuccessExitCodes)
|
||||
if job.SuccessExitCodes == "" {
|
||||
job.SuccessExitCodes = "0"
|
||||
}
|
||||
}
|
||||
|
||||
// validateJob enforces the minimum executable definition: name, schedule, and
|
||||
// command must be present. Folder is optional. The schedule string itself is not
|
||||
// rejected for being unparseable — that surfaces later as an "Invalid schedule"
|
||||
// next-run, matching the prior behavior.
|
||||
func validateJob(job domain.Job) error {
|
||||
if job.Name == "" || job.Schedule == "" || job.Command == "" {
|
||||
return errors.New("name, schedule, and command are required")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// validateConfig rejects settings that would break persistence or cleanup.
|
||||
func validateConfig(config domain.Config) error {
|
||||
if strings.TrimSpace(config.JobsDir) == "" {
|
||||
return errors.New("jobs directory is required")
|
||||
}
|
||||
if strings.TrimSpace(config.LogsDir) == "" {
|
||||
return errors.New("logs directory is required")
|
||||
}
|
||||
if config.MaxLogFiles <= 0 {
|
||||
return errors.New("max log files must be a positive number")
|
||||
}
|
||||
if config.MaxLogAgeDays <= 0 {
|
||||
return errors.New("max log age days must be a positive number")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
@@ -0,0 +1,536 @@
|
||||
package app
|
||||
|
||||
import (
|
||||
"context"
|
||||
"path/filepath"
|
||||
"sync/atomic"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"gitea.mixdep.ru/mix/gosentry/src/domain"
|
||||
"gitea.mixdep.ru/mix/gosentry/src/storage"
|
||||
)
|
||||
|
||||
// newTempService builds a Service backed by a store rooted in a temp directory,
|
||||
// so the mutating operations can persist to real (throwaway) files.
|
||||
func newTempService(t *testing.T, jobs []domain.Job) *Service {
|
||||
t.Helper()
|
||||
dir := t.TempDir()
|
||||
store := &storage.Store{
|
||||
Paths: storage.Paths{
|
||||
ExecutablePath: filepath.Join(dir, "gosentry"),
|
||||
AppDir: dir,
|
||||
ConfigPath: filepath.Join(dir, "gosentry.yaml"),
|
||||
JobsDir: dir,
|
||||
JobsPath: filepath.Join(dir, "jobs.yaml"),
|
||||
LogsDir: filepath.Join(dir, "logs"),
|
||||
},
|
||||
Config: domain.Config{JobsDir: ".", LogsDir: "logs", MaxLogFiles: 100, MaxLogAgeDays: 30},
|
||||
}
|
||||
return NewService(store, jobs)
|
||||
}
|
||||
|
||||
// recorder is a test observer that captures every emitted event.
|
||||
type recorder struct {
|
||||
events []Event
|
||||
}
|
||||
|
||||
func (r *recorder) OnEvent(e Event) { r.events = append(r.events, e) }
|
||||
|
||||
func (r *recorder) jobChanged() (ids []int) {
|
||||
for _, e := range r.events {
|
||||
if jc, ok := e.(JobChanged); ok {
|
||||
ids = append(ids, jc.JobID)
|
||||
}
|
||||
}
|
||||
return ids
|
||||
}
|
||||
|
||||
func (r *recorder) records() (out []domain.RunRecord) {
|
||||
for _, e := range r.events {
|
||||
if rr, ok := e.(RunRecorded); ok {
|
||||
out = append(out, rr.Record)
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func TestCreateJobAssignsIDAndEmits(t *testing.T) {
|
||||
svc := newTempService(t, nil)
|
||||
rec := &recorder{}
|
||||
svc.Subscribe(rec)
|
||||
|
||||
created, err := svc.CreateJob(domain.Job{Name: "Build", Schedule: "@every 1m", Command: "echo hi", Enabled: true})
|
||||
if err != nil {
|
||||
t.Fatalf("CreateJob: %v", err)
|
||||
}
|
||||
if created.ID != 1 {
|
||||
t.Errorf("first job ID = %d, want 1", created.ID)
|
||||
}
|
||||
if got := svc.Jobs(); len(got) != 1 || got[0].Name != "Build" {
|
||||
t.Fatalf("jobs after create = %+v", got)
|
||||
}
|
||||
if rt := svc.Runtime(1); rt == nil || rt.LastState != "Ready" {
|
||||
t.Errorf("runtime = %+v, want LastState Ready", rt)
|
||||
}
|
||||
if recs := rec.records(); len(recs) != 1 || recs[0].State != "Created" {
|
||||
t.Errorf("records = %+v, want one Created", recs)
|
||||
}
|
||||
if ids := rec.jobChanged(); len(ids) != 1 || ids[0] != 1 {
|
||||
t.Errorf("JobChanged ids = %v, want [1]", ids)
|
||||
}
|
||||
|
||||
// A second job takes the next free ID.
|
||||
second, err := svc.CreateJob(domain.Job{Name: "Two", Schedule: "@every 1m", Command: "echo two"})
|
||||
if err != nil {
|
||||
t.Fatalf("CreateJob 2: %v", err)
|
||||
}
|
||||
if second.ID != 2 {
|
||||
t.Errorf("second job ID = %d, want 2", second.ID)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCreateJobValidates(t *testing.T) {
|
||||
svc := newTempService(t, nil)
|
||||
if _, err := svc.CreateJob(domain.Job{Schedule: "@every 1m", Command: "echo"}); err == nil {
|
||||
t.Error("expected error for missing name")
|
||||
}
|
||||
if got := svc.Jobs(); len(got) != 0 {
|
||||
t.Errorf("invalid job should not be stored, jobs = %+v", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestUpdateJobKeepsRuntimeAndReflectsDisable(t *testing.T) {
|
||||
svc := newTempService(t, []domain.Job{{ID: 5, Name: "Old", Schedule: "@every 1m", Command: "echo", Enabled: true}})
|
||||
if err := svc.UpdateJob(domain.Job{ID: 5, Name: "New", Schedule: "@every 1m", Command: "echo", Enabled: false}); err != nil {
|
||||
t.Fatalf("UpdateJob: %v", err)
|
||||
}
|
||||
got := svc.Jobs()
|
||||
if got[0].Name != "New" || got[0].Enabled {
|
||||
t.Errorf("job after update = %+v", got[0])
|
||||
}
|
||||
if rt := svc.Runtime(5); rt == nil || rt.LastState != "Paused" || rt.NextRun != "Paused" {
|
||||
t.Errorf("runtime after disable = %+v", rt)
|
||||
}
|
||||
}
|
||||
|
||||
func TestUpdateJobReenablesPausedJob(t *testing.T) {
|
||||
svc := newTempService(t, []domain.Job{{ID: 5, Name: "Old", Schedule: "@every 1m", Command: "echo", Enabled: false}})
|
||||
if rt := svc.Runtime(5); rt.LastState != "Paused" {
|
||||
t.Fatalf("precondition: runtime = %+v, want Paused", rt)
|
||||
}
|
||||
if err := svc.UpdateJob(domain.Job{ID: 5, Name: "Old", Schedule: "@every 1m", Command: "echo", Enabled: true}); err != nil {
|
||||
t.Fatalf("UpdateJob: %v", err)
|
||||
}
|
||||
if rt := svc.Runtime(5); rt.LastState != "Ready" || rt.NextDue.IsZero() {
|
||||
t.Errorf("re-enabled runtime = %+v, want Ready with a next-due", rt)
|
||||
}
|
||||
}
|
||||
|
||||
// runtimeForLocked lazily recreates a missing runtime entry so the Service stays
|
||||
// robust if a job somehow lacks one. Dropping the entry and driving an operation
|
||||
// that needs it exercises that path.
|
||||
func TestRuntimeLazilyRecreated(t *testing.T) {
|
||||
svc := newTempService(t, []domain.Job{{ID: 1, Name: "A", Schedule: "@every 1m", Command: "echo", Enabled: true}})
|
||||
|
||||
svc.mu.Lock()
|
||||
delete(svc.runtimes, 1)
|
||||
svc.mu.Unlock()
|
||||
|
||||
if err := svc.SetEnabled(1, true); err != nil {
|
||||
t.Fatalf("SetEnabled: %v", err)
|
||||
}
|
||||
if rt := svc.Runtime(1); rt == nil {
|
||||
t.Error("runtime was not lazily recreated")
|
||||
}
|
||||
}
|
||||
|
||||
func TestUpdateJobNotFound(t *testing.T) {
|
||||
svc := newTempService(t, nil)
|
||||
if err := svc.UpdateJob(domain.Job{ID: 99, Name: "X", Schedule: "@every 1m", Command: "echo"}); err == nil {
|
||||
t.Error("expected not-found error")
|
||||
}
|
||||
}
|
||||
|
||||
func TestDeleteJobRemovesEverything(t *testing.T) {
|
||||
svc := newTempService(t, []domain.Job{{ID: 1, Name: "A", Schedule: "@every 1m", Command: "echo", Enabled: true}})
|
||||
rec := &recorder{}
|
||||
svc.Subscribe(rec)
|
||||
|
||||
if err := svc.DeleteJob(1); err != nil {
|
||||
t.Fatalf("DeleteJob: %v", err)
|
||||
}
|
||||
if got := svc.Jobs(); len(got) != 0 {
|
||||
t.Errorf("jobs after delete = %+v", got)
|
||||
}
|
||||
if rt := svc.Runtime(1); rt != nil {
|
||||
t.Errorf("runtime should be gone, got %+v", rt)
|
||||
}
|
||||
if recs := rec.records(); len(recs) != 1 || recs[0].State != "Deleted" {
|
||||
t.Errorf("records = %+v, want one Deleted", recs)
|
||||
}
|
||||
if ids := rec.jobChanged(); len(ids) != 1 || ids[0] != 0 {
|
||||
t.Errorf("JobChanged ids = %v, want [0] (broad)", ids)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDeleteJobNotFound(t *testing.T) {
|
||||
svc := newTempService(t, nil)
|
||||
if err := svc.DeleteJob(42); err == nil {
|
||||
t.Error("expected not-found error deleting unknown job")
|
||||
}
|
||||
}
|
||||
|
||||
func TestSetEnabledNotFound(t *testing.T) {
|
||||
svc := newTempService(t, nil)
|
||||
if err := svc.SetEnabled(42, true); err == nil {
|
||||
t.Error("expected not-found error enabling unknown job")
|
||||
}
|
||||
}
|
||||
|
||||
func TestSetEnabledToggles(t *testing.T) {
|
||||
svc := newTempService(t, []domain.Job{{ID: 1, Name: "A", Schedule: "@every 1m", Command: "echo", Enabled: false}})
|
||||
|
||||
if err := svc.SetEnabled(1, true); err != nil {
|
||||
t.Fatalf("SetEnabled true: %v", err)
|
||||
}
|
||||
if rt := svc.Runtime(1); rt.LastState != "Ready" || rt.NextDue.IsZero() {
|
||||
t.Errorf("enabled runtime = %+v, want Ready with a next-due", rt)
|
||||
}
|
||||
if err := svc.SetEnabled(1, false); err != nil {
|
||||
t.Fatalf("SetEnabled false: %v", err)
|
||||
}
|
||||
if rt := svc.Runtime(1); rt.LastState != "Paused" || !rt.NextDue.IsZero() {
|
||||
t.Errorf("disabled runtime = %+v, want Paused with no next-due", rt)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSetGlobalPauseUpdatesRuntimesAndEmits(t *testing.T) {
|
||||
svc := newTempService(t, []domain.Job{
|
||||
{ID: 1, Name: "On", Schedule: "@every 1m", Command: "echo", Enabled: true},
|
||||
{ID: 2, Name: "Off", Schedule: "@every 1m", Command: "echo", Enabled: false},
|
||||
})
|
||||
rec := &recorder{}
|
||||
svc.Subscribe(rec)
|
||||
|
||||
if err := svc.SetGlobalPause(true); err != nil {
|
||||
t.Fatalf("SetGlobalPause: %v", err)
|
||||
}
|
||||
if rt := svc.Runtime(1); rt.NextRun != "Scheduler paused" {
|
||||
t.Errorf("enabled job next-run = %q, want %q", rt.NextRun, "Scheduler paused")
|
||||
}
|
||||
if rt := svc.Runtime(2); rt.NextRun != "Paused" {
|
||||
t.Errorf("disabled job next-run = %q, want %q", rt.NextRun, "Paused")
|
||||
}
|
||||
var sawState bool
|
||||
for _, e := range rec.events {
|
||||
if ss, ok := e.(SchedulerStateChanged); ok && ss.Paused {
|
||||
sawState = true
|
||||
}
|
||||
}
|
||||
if !sawState {
|
||||
t.Error("expected a SchedulerStateChanged{Paused:true} event")
|
||||
}
|
||||
|
||||
// Resuming recomputes a real next run for the enabled job.
|
||||
if err := svc.SetGlobalPause(false); err != nil {
|
||||
t.Fatalf("resume: %v", err)
|
||||
}
|
||||
if rt := svc.Runtime(1); rt.NextDue.IsZero() {
|
||||
t.Errorf("resumed enabled job should have a next-due, got %+v", rt)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRunNowUsesRunnerAndRecords(t *testing.T) {
|
||||
svc := newTempService(t, []domain.Job{{ID: 1, Name: "A", Schedule: "@every 1m", Command: "echo", Enabled: true}})
|
||||
|
||||
done := make(chan domain.RunRecord, 1)
|
||||
svc.runJob = func(_ context.Context, job *domain.Job, trigger string, _ string) domain.RunRecord {
|
||||
if trigger != "Manual" {
|
||||
t.Errorf("trigger = %q, want Manual", trigger)
|
||||
}
|
||||
return domain.RunRecord{Time: "2026-06-19 12:00:00", JobID: job.ID, JobName: job.Name, State: "Success", Output: "ok"}
|
||||
}
|
||||
svc.Subscribe(ObserverFunc(func(e Event) {
|
||||
if rr, ok := e.(RunRecorded); ok && rr.Record.JobID == 1 {
|
||||
select {
|
||||
case done <- rr.Record:
|
||||
default:
|
||||
}
|
||||
}
|
||||
}))
|
||||
|
||||
if err := svc.RunNow(1); err != nil {
|
||||
t.Fatalf("RunNow: %v", err)
|
||||
}
|
||||
|
||||
select {
|
||||
case record := <-done:
|
||||
if record.State != "Success" {
|
||||
t.Errorf("recorded state = %q, want Success", record.State)
|
||||
}
|
||||
case <-time.After(2 * time.Second):
|
||||
t.Fatal("timed out waiting for run to be recorded")
|
||||
}
|
||||
|
||||
if rt := svc.Runtime(1); rt.LastState != "Success" || rt.Output != "ok" {
|
||||
t.Errorf("runtime after run = %+v", rt)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRunNowNotFound(t *testing.T) {
|
||||
svc := newTempService(t, nil)
|
||||
if err := svc.RunNow(99); err == nil {
|
||||
t.Error("expected not-found error for unknown job")
|
||||
}
|
||||
}
|
||||
|
||||
func TestRunNowRefusedWhileAlreadyRunning(t *testing.T) {
|
||||
svc := newTempService(t, []domain.Job{{ID: 1, Name: "A", Schedule: "@every 1m", Command: "echo", Enabled: true}})
|
||||
|
||||
// Park the job in the "Running" state so a second RunNow must refuse: the
|
||||
// runner signals once it has started and then blocks until released.
|
||||
entered := make(chan struct{}, 1)
|
||||
release := make(chan struct{})
|
||||
var calls int32
|
||||
svc.runJob = func(_ context.Context, job *domain.Job, _ string, _ string) domain.RunRecord {
|
||||
atomic.AddInt32(&calls, 1)
|
||||
entered <- struct{}{}
|
||||
<-release
|
||||
return domain.RunRecord{Time: "2026-06-19 12:00:00", JobID: job.ID, JobName: job.Name, State: "Success"}
|
||||
}
|
||||
done := make(chan struct{}, 1)
|
||||
svc.Subscribe(ObserverFunc(func(e Event) {
|
||||
if rr, ok := e.(RunRecorded); ok && rr.Record.State == "Success" {
|
||||
select {
|
||||
case done <- struct{}{}:
|
||||
default:
|
||||
}
|
||||
}
|
||||
}))
|
||||
|
||||
if err := svc.RunNow(1); err != nil {
|
||||
t.Fatalf("first RunNow: %v", err)
|
||||
}
|
||||
<-entered // the run is now in-flight and blocked
|
||||
|
||||
if err := svc.RunNow(1); err == nil {
|
||||
t.Error("expected RunNow to be refused while already running")
|
||||
}
|
||||
close(release)
|
||||
|
||||
// Wait for the in-flight run to finish before returning so its background
|
||||
// writes complete before t.TempDir cleanup removes the directory.
|
||||
select {
|
||||
case <-done:
|
||||
case <-time.After(2 * time.Second):
|
||||
t.Fatal("timed out waiting for the in-flight run to complete")
|
||||
}
|
||||
|
||||
// Only the first run should ever have reached the runner.
|
||||
if got := atomic.LoadInt32(&calls); got != 1 {
|
||||
t.Errorf("runner called %d times, want 1", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRunNowRefusedWhilePaused(t *testing.T) {
|
||||
svc := newTempService(t, []domain.Job{{ID: 1, Name: "A", Schedule: "@every 1m", Command: "echo", Enabled: true}})
|
||||
var ran bool
|
||||
svc.runJob = func(context.Context, *domain.Job, string, string) domain.RunRecord {
|
||||
ran = true
|
||||
return domain.RunRecord{}
|
||||
}
|
||||
if err := svc.SetGlobalPause(true); err != nil {
|
||||
t.Fatalf("SetGlobalPause: %v", err)
|
||||
}
|
||||
if err := svc.RunNow(1); err == nil {
|
||||
t.Error("expected RunNow to be refused while paused")
|
||||
}
|
||||
if ran {
|
||||
t.Error("runner must not be invoked while paused")
|
||||
}
|
||||
}
|
||||
|
||||
func TestRunDueStartsDueJob(t *testing.T) {
|
||||
svc := newTempService(t, []domain.Job{{ID: 1, Name: "A", Schedule: "@every 1m", Command: "echo", Enabled: true}})
|
||||
|
||||
done := make(chan domain.RunRecord, 1)
|
||||
svc.runJob = func(_ context.Context, job *domain.Job, trigger string, _ string) domain.RunRecord {
|
||||
if trigger != "Schedule" {
|
||||
t.Errorf("trigger = %q, want Schedule", trigger)
|
||||
}
|
||||
return domain.RunRecord{Time: "2026-06-19 12:00:00", JobID: job.ID, JobName: job.Name, State: "Success", Output: "ok"}
|
||||
}
|
||||
svc.Subscribe(ObserverFunc(func(e Event) {
|
||||
if rr, ok := e.(RunRecorded); ok && rr.Record.JobID == 1 && rr.Record.State == "Success" {
|
||||
select {
|
||||
case done <- rr.Record:
|
||||
default:
|
||||
}
|
||||
}
|
||||
}))
|
||||
|
||||
// The job's next-due was primed ~1m ahead at construction; tick well past it.
|
||||
svc.RunDue(time.Now().Add(2 * time.Minute))
|
||||
|
||||
select {
|
||||
case <-done:
|
||||
case <-time.After(2 * time.Second):
|
||||
t.Fatal("RunDue did not start the due job")
|
||||
}
|
||||
if rt := svc.Runtime(1); rt.LastState != "Success" || rt.Output != "ok" {
|
||||
t.Errorf("runtime after scheduled run = %+v", rt)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRunDueSkipsJobNotYetDue(t *testing.T) {
|
||||
svc := newTempService(t, []domain.Job{{ID: 1, Name: "A", Schedule: "@every 1m", Command: "echo", Enabled: true}})
|
||||
var ran int32
|
||||
svc.runJob = func(context.Context, *domain.Job, string, string) domain.RunRecord {
|
||||
atomic.AddInt32(&ran, 1)
|
||||
return domain.RunRecord{}
|
||||
}
|
||||
|
||||
// Next-due is ~1m out, so nothing is due "now".
|
||||
svc.RunDue(time.Now())
|
||||
time.Sleep(50 * time.Millisecond)
|
||||
if atomic.LoadInt32(&ran) != 0 {
|
||||
t.Error("RunDue ran a job before it was due")
|
||||
}
|
||||
}
|
||||
|
||||
// TestRunDueSkipsJobInRunningState verifies that RunDue will not start a second
|
||||
// concurrent instance of a job that is already in "Running" state — even if the
|
||||
// job's NextDue is in the past. This guards against the window between
|
||||
// executeRun completing and refreshNextRunLocked setting a new NextDue.
|
||||
func TestRunDueSkipsJobInRunningState(t *testing.T) {
|
||||
svc := newTempService(t, []domain.Job{{ID: 1, Name: "A", Schedule: "@every 1m", Command: "echo", Enabled: true}})
|
||||
|
||||
var calls int32
|
||||
svc.runJob = func(context.Context, *domain.Job, string, string) domain.RunRecord {
|
||||
atomic.AddInt32(&calls, 1)
|
||||
return domain.RunRecord{State: "Success"}
|
||||
}
|
||||
|
||||
// Force the job into "Running" with a past NextDue, simulating an in-flight
|
||||
// run. We set NextDue to a past time so the due check would otherwise pass.
|
||||
svc.mu.Lock()
|
||||
rt := svc.runtimes[1]
|
||||
rt.LastState = "Running"
|
||||
rt.NextDue = time.Now().Add(-time.Minute)
|
||||
svc.mu.Unlock()
|
||||
|
||||
svc.RunDue(time.Now().Add(2 * time.Minute))
|
||||
time.Sleep(50 * time.Millisecond)
|
||||
|
||||
if got := atomic.LoadInt32(&calls); got != 0 {
|
||||
t.Errorf("RunDue called runner %d time(s) for a job in Running state, want 0", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRunDueDoesNothingWhilePaused(t *testing.T) {
|
||||
svc := newTempService(t, []domain.Job{{ID: 1, Name: "A", Schedule: "@every 1m", Command: "echo", Enabled: true}})
|
||||
var ran int32
|
||||
svc.runJob = func(context.Context, *domain.Job, string, string) domain.RunRecord {
|
||||
atomic.AddInt32(&ran, 1)
|
||||
return domain.RunRecord{}
|
||||
}
|
||||
if err := svc.SetGlobalPause(true); err != nil {
|
||||
t.Fatalf("SetGlobalPause: %v", err)
|
||||
}
|
||||
|
||||
svc.RunDue(time.Now().Add(2 * time.Minute))
|
||||
time.Sleep(50 * time.Millisecond)
|
||||
if atomic.LoadInt32(&ran) != 0 {
|
||||
t.Error("RunDue ran a job while globally paused")
|
||||
}
|
||||
}
|
||||
|
||||
// appFakeClock is a scheduler.Clock whose tick and "now" the test controls, used
|
||||
// to verify Start wires the loop to RunDue without the wall clock.
|
||||
type appFakeClock struct {
|
||||
ticks chan time.Time
|
||||
now time.Time
|
||||
}
|
||||
|
||||
func (c *appFakeClock) Now() time.Time { return c.now }
|
||||
func (c *appFakeClock) Ticks() <-chan time.Time { return c.ticks }
|
||||
func (c *appFakeClock) Stop() {}
|
||||
|
||||
func TestStartDrivesRunDueOnTick(t *testing.T) {
|
||||
svc := newTempService(t, []domain.Job{{ID: 1, Name: "A", Schedule: "@every 1m", Command: "echo", Enabled: true}})
|
||||
|
||||
done := make(chan struct{}, 1)
|
||||
svc.runJob = func(context.Context, *domain.Job, string, string) domain.RunRecord {
|
||||
select {
|
||||
case done <- struct{}{}:
|
||||
default:
|
||||
}
|
||||
return domain.RunRecord{State: "Success"}
|
||||
}
|
||||
|
||||
clock := &appFakeClock{ticks: make(chan time.Time, 1), now: time.Now().Add(2 * time.Minute)}
|
||||
svc.StartWith(clock)
|
||||
defer svc.Stop()
|
||||
|
||||
clock.ticks <- clock.now
|
||||
select {
|
||||
case <-done:
|
||||
case <-time.After(2 * time.Second):
|
||||
t.Fatal("Start did not drive a run from a clock tick")
|
||||
}
|
||||
}
|
||||
|
||||
func TestUpdateSettingsPersistsAndValidates(t *testing.T) {
|
||||
svc := newTempService(t, nil)
|
||||
|
||||
bad := svc.store.Config
|
||||
bad.MaxLogFiles = 0
|
||||
if err := svc.UpdateSettings(bad); err == nil {
|
||||
t.Error("expected validation error for non-positive max log files")
|
||||
}
|
||||
|
||||
good := svc.store.Config
|
||||
good.NotifyOnFailure = false
|
||||
good.MaxLogAgeDays = 7
|
||||
if err := svc.UpdateSettings(good); err != nil {
|
||||
t.Fatalf("UpdateSettings: %v", err)
|
||||
}
|
||||
if svc.Store().Config.MaxLogAgeDays != 7 || svc.Store().Config.NotifyOnFailure {
|
||||
t.Errorf("config not applied: %+v", svc.Store().Config)
|
||||
}
|
||||
}
|
||||
|
||||
func TestUpdateSettingsRejectsInvalidConfigs(t *testing.T) {
|
||||
svc := newTempService(t, nil)
|
||||
base := svc.store.Config
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
mutate func(c *domain.Config)
|
||||
}{
|
||||
{"missing jobs dir", func(c *domain.Config) { c.JobsDir = " " }},
|
||||
{"missing logs dir", func(c *domain.Config) { c.LogsDir = "" }},
|
||||
{"non-positive max files", func(c *domain.Config) { c.MaxLogFiles = 0 }},
|
||||
{"non-positive max age", func(c *domain.Config) { c.MaxLogAgeDays = -1 }},
|
||||
}
|
||||
for _, tc := range tests {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
cfg := base
|
||||
tc.mutate(&cfg)
|
||||
if err := svc.UpdateSettings(cfg); err == nil {
|
||||
t.Errorf("expected validation error for %s", tc.name)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestPrependLogCapsActivityList(t *testing.T) {
|
||||
runtime := &domain.JobRuntime{}
|
||||
for i := 0; i < maxJobLogs+10; i++ {
|
||||
prependLog(runtime, domain.RunRecord{Detail: "r"})
|
||||
}
|
||||
if len(runtime.Logs) != maxJobLogs {
|
||||
t.Errorf("activity list len = %d, want capped at %d", len(runtime.Logs), maxJobLogs)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,43 @@
|
||||
package app
|
||||
|
||||
import (
|
||||
"gitea.mixdep.ru/mix/gosentry/src/platform/desktop"
|
||||
)
|
||||
|
||||
// InstallDesktopIcon installs the application's .desktop file and icon on
|
||||
// Linux (no-op on other platforms). The resulting icon path is stored in
|
||||
// store.Paths.DesktopIcon so ApplyAutostart can reference it.
|
||||
func (s *Service) InstallDesktopIcon(appID string, iconBytes []byte) {
|
||||
if iconPath, err := desktop.InstallDesktopIntegration(appID, s.store.Paths.ExecutablePath, iconBytes); err == nil {
|
||||
s.store.Paths.DesktopIcon = iconPath
|
||||
}
|
||||
}
|
||||
|
||||
// AutostartStatus reports whether the platform autostart entry matches the
|
||||
// current StartOnLogin setting in the stored config.
|
||||
func (s *Service) AutostartStatus() (ok bool, message string) {
|
||||
s.mu.Lock()
|
||||
enabled := s.store.Config.StartOnLogin
|
||||
execPath := s.store.Paths.ExecutablePath
|
||||
manager := s.manager
|
||||
s.mu.Unlock()
|
||||
if manager == nil {
|
||||
return false, "autostart not available"
|
||||
}
|
||||
return manager.Status(enabled, execPath)
|
||||
}
|
||||
|
||||
// ApplyAutostart writes or removes the platform autostart entry to match the
|
||||
// current StartOnLogin setting in the stored config. Call after UpdateSettings.
|
||||
func (s *Service) ApplyAutostart() error {
|
||||
s.mu.Lock()
|
||||
enabled := s.store.Config.StartOnLogin
|
||||
execPath := s.store.Paths.ExecutablePath
|
||||
iconPath := s.store.Paths.DesktopIcon
|
||||
manager := s.manager
|
||||
s.mu.Unlock()
|
||||
if manager == nil {
|
||||
return nil
|
||||
}
|
||||
return manager.Set(enabled, execPath, iconPath)
|
||||
}
|
||||
@@ -0,0 +1,178 @@
|
||||
package app
|
||||
|
||||
import (
|
||||
"context"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"gitea.mixdep.ru/mix/gosentry/src/domain"
|
||||
"gitea.mixdep.ru/mix/gosentry/src/platform/autostart"
|
||||
"gitea.mixdep.ru/mix/gosentry/src/runner"
|
||||
"gitea.mixdep.ru/mix/gosentry/src/scheduler"
|
||||
"gitea.mixdep.ru/mix/gosentry/src/storage"
|
||||
)
|
||||
|
||||
// Service is the application-service layer: the single owner of GoSentry's
|
||||
// in-memory state. It holds the durable jobs slice, the transient runtime map
|
||||
// keyed by Job.ID, and a reference to the store that persists them. All access
|
||||
// to that state goes through a mutex so the GUI and the scheduler can no longer
|
||||
// race on a shared *[]Job.
|
||||
//
|
||||
// State ownership and the locking contract were established in T3.1; the
|
||||
// event/observer machinery in T3.2. T3.3 added the state-mutating intents
|
||||
// (CreateJob, UpdateJob, DeleteJob, SetEnabled, RunNow, SetGlobalPause,
|
||||
// UpdateSettings) in operations.go: the Service is the sole writer of job and
|
||||
// runtime state, persisting through the store and announcing changes via events.
|
||||
//
|
||||
// T3.4 makes the Service drive scheduling too. It owns the timing loop through a
|
||||
// scheduler.Scheduler that calls RunDue on every tick; the scheduler holds no
|
||||
// job state and never touches the slice directly. The old shared *[]domain.Job
|
||||
// between GUI and scheduler is gone — both go through the Service.
|
||||
//
|
||||
// Locking contract: mu is a plain, non-reentrant mutex. Exported methods take
|
||||
// it; unexported helpers ending in "Locked" assume the caller already holds it.
|
||||
// The Service must never call back into the UI (or any code that might re-enter
|
||||
// the Service) while holding mu — in particular emit() is always called after
|
||||
// mu is released.
|
||||
type Service struct {
|
||||
mu sync.Mutex
|
||||
store *storage.Store
|
||||
jobs []domain.Job
|
||||
runtimes map[int]*domain.JobRuntime
|
||||
|
||||
// schedules caches a parsed Schedule per job ID so timing math does not
|
||||
// re-parse the schedule string on every use. paused is the global pause flag.
|
||||
// Both are guarded by mu.
|
||||
schedules map[int]domain.Schedule
|
||||
paused bool
|
||||
|
||||
// runJob is the run seam. It defaults to runner.RunJob and is overridden in
|
||||
// tests with a fake so the run paths can be exercised without spawning real
|
||||
// processes. ctx is the lifecycle context passed to runs; Start replaces it
|
||||
// with a cancelable context so Stop can abort in-flight runs, and until Start
|
||||
// it is context.Background().
|
||||
runJob func(ctx context.Context, job *domain.Job, trigger string, logsDir string) domain.RunRecord
|
||||
ctx context.Context
|
||||
|
||||
// sched is the timing loop installed by Start; cancel tears down ctx on Stop.
|
||||
// Both are guarded by mu.
|
||||
sched *scheduler.Scheduler
|
||||
cancel context.CancelFunc
|
||||
|
||||
// manager is the platform autostart implementation. It is nil in tests that
|
||||
// do not exercise autostart; Open() wires it via autostart.New().
|
||||
manager autostart.Manager
|
||||
|
||||
// observers and their guard live in events.go. dispatchMu is separate from mu
|
||||
// so that emitting an event never requires (or is held under) the state lock:
|
||||
// the Service must release mu before dispatching, per the locking contract.
|
||||
dispatchMu sync.Mutex
|
||||
observers []Observer
|
||||
}
|
||||
|
||||
// NewService wires the Service to a loaded store and its jobs. It builds the
|
||||
// initial runtime map from the durable jobs so every job has transient state
|
||||
// from the moment the Service exists, and parses each job's schedule once. The
|
||||
// store is the Service's sole channel to persistence.
|
||||
func NewService(store *storage.Store, jobs []domain.Job) *Service {
|
||||
s := &Service{
|
||||
store: store,
|
||||
jobs: jobs,
|
||||
runtimes: domain.NewRuntimes(jobs),
|
||||
schedules: make(map[int]domain.Schedule, len(jobs)),
|
||||
runJob: runner.RunJob,
|
||||
ctx: context.Background(),
|
||||
}
|
||||
// Parse every schedule once, then compute each job's first next-run so the
|
||||
// Service is ready to schedule the moment it exists — mirroring the old
|
||||
// scheduler's reset-on-construction. No lock is needed: construction is
|
||||
// single-threaded, before Start launches the timing loop.
|
||||
now := time.Now()
|
||||
for index := range s.jobs {
|
||||
job := &s.jobs[index]
|
||||
s.parseScheduleLocked(job)
|
||||
s.refreshNextRunFromLocked(job, s.runtimes[job.ID], now)
|
||||
}
|
||||
return s
|
||||
}
|
||||
|
||||
// Start begins scheduling with the real wall clock. It is the production entry
|
||||
// point; tests should call StartWith and supply a fake clock instead. Start is
|
||||
// expected once, during setup, before any concurrent use.
|
||||
func (s *Service) Start() {
|
||||
s.StartWith(scheduler.NewRealClock())
|
||||
}
|
||||
|
||||
// StartWith begins scheduling driven by the given clock; every tick calls
|
||||
// RunDue. Used by tests to inject a fake clock.
|
||||
func (s *Service) StartWith(clock scheduler.Clock) {
|
||||
s.mu.Lock()
|
||||
ctx, cancel := context.WithCancel(context.Background())
|
||||
s.ctx = ctx
|
||||
s.cancel = cancel
|
||||
s.sched = scheduler.NewScheduler(clock, s.RunDue)
|
||||
sched := s.sched
|
||||
s.mu.Unlock()
|
||||
|
||||
sched.Start()
|
||||
}
|
||||
|
||||
// Stop halts scheduling and cancels the run context so in-flight runs see a
|
||||
// canceled context. It is safe to call when Start was never called.
|
||||
func (s *Service) Stop() {
|
||||
s.mu.Lock()
|
||||
sched := s.sched
|
||||
cancel := s.cancel
|
||||
s.mu.Unlock()
|
||||
|
||||
if sched != nil {
|
||||
sched.Stop()
|
||||
}
|
||||
if cancel != nil {
|
||||
cancel()
|
||||
}
|
||||
}
|
||||
|
||||
// Open loads the store and constructs a Service from it in one step. It is the
|
||||
// convenience entry point for the application; tests inject a pre-built store
|
||||
// via NewService instead.
|
||||
func Open() (*Service, error) {
|
||||
store, jobs, err := storage.OpenStore()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
svc := NewService(store, jobs)
|
||||
svc.manager = autostart.New()
|
||||
return svc, nil
|
||||
}
|
||||
|
||||
// Store returns the underlying store. It is exposed so callers that still need
|
||||
// resolved paths and config (the GUI, during the transition) can reach them;
|
||||
// later phases narrow this surface.
|
||||
func (s *Service) Store() *storage.Store {
|
||||
return s.store
|
||||
}
|
||||
|
||||
// Jobs returns a copy of the durable jobs slice. Returning a copy keeps callers
|
||||
// from mutating Service-owned state behind its back: the Service stays the sole
|
||||
// writer.
|
||||
func (s *Service) Jobs() []domain.Job {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
|
||||
jobs := make([]domain.Job, len(s.jobs))
|
||||
copy(jobs, s.jobs)
|
||||
return jobs
|
||||
}
|
||||
|
||||
// Runtime returns the transient runtime state for a job ID, or nil if no job
|
||||
// with that ID is loaded. The returned pointer is the live runtime; reads of it
|
||||
// are only safe while no concurrent mutation is in flight. The scheduler now
|
||||
// drives the Service rather than sharing state, so the remaining concurrent
|
||||
// reader is the UI listener, which T4.1 marshals onto the main thread.
|
||||
func (s *Service) Runtime(id int) *domain.JobRuntime {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
|
||||
return s.runtimes[id]
|
||||
}
|
||||
@@ -0,0 +1,57 @@
|
||||
package app
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"gitea.mixdep.ru/mix/gosentry/src/domain"
|
||||
"gitea.mixdep.ru/mix/gosentry/src/storage"
|
||||
)
|
||||
|
||||
func newTestService(jobs []domain.Job) *Service {
|
||||
return NewService(&storage.Store{}, jobs)
|
||||
}
|
||||
|
||||
func TestNewServiceBuildsRuntimePerJob(t *testing.T) {
|
||||
jobs := []domain.Job{
|
||||
{ID: 1, Name: "Enabled", Enabled: true},
|
||||
{ID: 2, Name: "Disabled", Enabled: false},
|
||||
}
|
||||
svc := newTestService(jobs)
|
||||
|
||||
if got := svc.Runtime(1); got == nil {
|
||||
t.Fatal("expected runtime for enabled job 1")
|
||||
} else if got.LastState != "Ready" {
|
||||
t.Errorf("enabled job runtime state = %q, want %q", got.LastState, "Ready")
|
||||
}
|
||||
if got := svc.Runtime(2); got == nil {
|
||||
t.Fatal("expected runtime for disabled job 2")
|
||||
} else if got.LastState != "Paused" {
|
||||
t.Errorf("disabled job runtime state = %q, want %q", got.LastState, "Paused")
|
||||
}
|
||||
if got := svc.Runtime(99); got != nil {
|
||||
t.Errorf("expected nil runtime for unknown job, got %+v", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestJobsReturnsCopy(t *testing.T) {
|
||||
jobs := []domain.Job{{ID: 1, Name: "Original"}}
|
||||
svc := newTestService(jobs)
|
||||
|
||||
snapshot := svc.Jobs()
|
||||
if len(snapshot) != 1 {
|
||||
t.Fatalf("Jobs() len = %d, want 1", len(snapshot))
|
||||
}
|
||||
// Mutating the returned slice must not affect Service-owned state.
|
||||
snapshot[0].Name = "Mutated"
|
||||
if again := svc.Jobs(); again[0].Name != "Original" {
|
||||
t.Errorf("Service state leaked through Jobs(): name = %q, want %q", again[0].Name, "Original")
|
||||
}
|
||||
}
|
||||
|
||||
func TestStoreReturnsWiredStore(t *testing.T) {
|
||||
store := &storage.Store{}
|
||||
svc := NewService(store, nil)
|
||||
if svc.Store() != store {
|
||||
t.Error("Store() did not return the wired store")
|
||||
}
|
||||
}
|
||||
@@ -1,6 +1,6 @@
|
||||
package core
|
||||
package app
|
||||
|
||||
// Version is the application version shown in the GUI and used by build
|
||||
// scripts in artifact names. It is a var rather than a const so release builds
|
||||
// can override it with Go ldflags when CI tags a build.
|
||||
var Version = "0.3.2"
|
||||
var Version = "0.4.0"
|
||||
@@ -1,19 +0,0 @@
|
||||
//go:build !windows && !linux
|
||||
|
||||
package core
|
||||
|
||||
import "fmt"
|
||||
|
||||
func SetAutostart(enabled bool, executablePath string, iconPath string) error {
|
||||
if !enabled {
|
||||
return nil
|
||||
}
|
||||
return fmt.Errorf("autostart is not implemented for this platform")
|
||||
}
|
||||
|
||||
func AutostartStatus(expectedEnabled bool, executablePath string) (bool, string) {
|
||||
if !expectedEnabled {
|
||||
return true, "Autostart is off"
|
||||
}
|
||||
return false, "Autostart is not implemented for this platform"
|
||||
}
|
||||
@@ -1,68 +0,0 @@
|
||||
package core
|
||||
|
||||
import "time"
|
||||
|
||||
// StartInTrayArgument is written to the Windows Startup shortcut so autostart
|
||||
// can keep the scheduler running without flashing the main window. Manual
|
||||
// launches omit this flag and open the normal window.
|
||||
const StartInTrayArgument = "--start-in-tray"
|
||||
|
||||
// Config is stored in gosentry.yaml next to the program. It contains only
|
||||
// application-level choices: where to read jobs from, where to write logs, and
|
||||
// how the desktop shell should behave.
|
||||
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"`
|
||||
StartOnLogin bool `yaml:"start_on_login"`
|
||||
KeepRunningInTray bool `yaml:"keep_running_in_tray"`
|
||||
NotifyOnFailure bool `yaml:"notify_on_failure"`
|
||||
}
|
||||
|
||||
// JobsFile is the on-disk shape of jobs.yaml. Wrapping the slice in a top-level
|
||||
// object leaves room for future metadata without breaking the basic file format.
|
||||
type JobsFile struct {
|
||||
Jobs []Job `yaml:"jobs"`
|
||||
}
|
||||
|
||||
// Job is the user-visible scheduled command.
|
||||
//
|
||||
// Fields with yaml:"-" are deliberately runtime-only. They are useful in the GUI
|
||||
// while GoSentry is running, but writing them to jobs.yaml would make the jobs
|
||||
// file noisy and would mix durable configuration with transient execution state.
|
||||
type Job struct {
|
||||
ID int `yaml:"id"`
|
||||
Name string `yaml:"name"`
|
||||
Folder string `yaml:"folder,omitempty"`
|
||||
Schedule string `yaml:"schedule"`
|
||||
Command string `yaml:"command"`
|
||||
Arguments string `yaml:"arguments,omitempty"`
|
||||
SuccessExitCodes string `yaml:"success_exit_codes,omitempty"`
|
||||
StartOnly bool `yaml:"start_only,omitempty"`
|
||||
Enabled bool `yaml:"enabled"`
|
||||
LastRun string `yaml:"-"`
|
||||
NextRun string `yaml:"-"`
|
||||
LastState string `yaml:"-"`
|
||||
Logs []RunRecord `yaml:"-"`
|
||||
Output string `yaml:"-"`
|
||||
|
||||
// nextDue is kept as time.Time for scheduler comparisons. The formatted
|
||||
// NextRun string above exists only for display in the GUI and YAML rewriting
|
||||
// must not persist it.
|
||||
nextDue time.Time
|
||||
}
|
||||
|
||||
// RunRecord represents one visible activity item. Scheduled and manual command
|
||||
// output is also written to a log file; the in-memory Output copy exists so the
|
||||
// latest run can be displayed without reopening the log on every repaint.
|
||||
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"`
|
||||
}
|
||||
@@ -1,339 +0,0 @@
|
||||
package core
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"os"
|
||||
"os/exec"
|
||||
"path/filepath"
|
||||
"sort"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
"unicode"
|
||||
)
|
||||
|
||||
const commandTimeout = 30 * time.Second
|
||||
const commandWaitDelay = 2 * time.Second
|
||||
|
||||
func RunJob(ctx context.Context, job *Job, trigger string, logsDir string) RunRecord {
|
||||
started := time.Now()
|
||||
// Commands can hang forever if a script waits for input or a child process
|
||||
// stalls. A fixed timeout is a conservative first guardrail for a desktop
|
||||
// scheduler; later it can become a per-job setting without changing the
|
||||
// runner contract.
|
||||
runCtx, cancel := context.WithTimeout(ctx, commandTimeout)
|
||||
defer cancel()
|
||||
|
||||
var stdout bytes.Buffer
|
||||
var stderr bytes.Buffer
|
||||
var output string
|
||||
var state string
|
||||
var detail string
|
||||
if job.StartOnly {
|
||||
invocation := jobInvocation(context.Background(), *job)
|
||||
state, detail, output = startJobOnly(invocation, *job, started)
|
||||
} else {
|
||||
invocation := jobInvocation(runCtx, *job)
|
||||
command := invocation.command
|
||||
command.WaitDelay = commandWaitDelay
|
||||
if invocation.hideWindow {
|
||||
configureHiddenWindow(command)
|
||||
}
|
||||
command.Stdout = &stdout
|
||||
command.Stderr = &stderr
|
||||
|
||||
err := command.Run()
|
||||
duration := time.Since(started).Round(time.Millisecond)
|
||||
output = formatOutput(stdout.String(), stderr.String())
|
||||
state, detail = runStateDetail(err, runCtx.Err(), duration, *job)
|
||||
}
|
||||
|
||||
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,
|
||||
}
|
||||
// Keep a small in-memory history for the currently running GUI. Full command
|
||||
// output is persisted to files, so retaining every past record in RAM would
|
||||
// only duplicate data and make long sessions grow without bound.
|
||||
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 {
|
||||
// Only GoSentry run logs are managed here. Directories and non-.log files
|
||||
// are intentionally ignored so the user can keep notes or other artifacts
|
||||
// in the same folder without the cleanup policy deleting them.
|
||||
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) {
|
||||
// Cleanup is best-effort: failing to delete one file should not block
|
||||
// the scheduler from running future jobs.
|
||||
_ = 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 {
|
||||
// Newest files are kept first, then everything after maxFiles is removed.
|
||||
// This matches the user's expectation that the most recent failures and
|
||||
// command output remain available for investigation.
|
||||
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 ""
|
||||
}
|
||||
// The timestamp comes first so a plain directory listing is naturally sorted
|
||||
// by run time. The job name is included for human scanning, but sanitized to
|
||||
// avoid characters that are invalid on Windows or awkward on shells.
|
||||
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\narguments: %s\nsuccess_exit_codes: %s\nstart_only: %t\n\n%s\n",
|
||||
started.Format("2006-01-02 15:04:05"), job.ID, job.Name, trigger, state, detail, job.Command, logArguments(job.Arguments), successExitCodesText(job), job.StartOnly, 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 startJobOnly(invocation commandInvocation, job Job, started time.Time) (string, string, string) {
|
||||
command := invocation.command
|
||||
if invocation.hideWindow {
|
||||
configureHiddenWindow(command)
|
||||
}
|
||||
err := command.Start()
|
||||
duration := time.Since(started).Round(time.Millisecond)
|
||||
if err != nil {
|
||||
return "Failed", fmt.Sprintf("%T: %v", err, err), startOnlyOutput(job, 0)
|
||||
}
|
||||
pid := command.Process.Pid
|
||||
if releaseErr := command.Process.Release(); releaseErr != nil {
|
||||
return "Failed", fmt.Sprintf("process started with pid %d, but release failed: %T: %v", pid, releaseErr, releaseErr), startOnlyOutput(job, pid)
|
||||
}
|
||||
return "OK", fmt.Sprintf("Started in %s (pid %d); not waiting for process exit", duration, pid), startOnlyOutput(job, pid)
|
||||
}
|
||||
|
||||
func startOnlyOutput(job Job, pid int) string {
|
||||
var builder strings.Builder
|
||||
builder.WriteString("status:\n")
|
||||
if pid > 0 {
|
||||
builder.WriteString(fmt.Sprintf("Started process pid %d. GoSentry is not waiting for it to exit.\n\n", pid))
|
||||
} else {
|
||||
builder.WriteString("Process did not start.\n\n")
|
||||
}
|
||||
builder.WriteString("command:\n")
|
||||
builder.WriteString(job.Command + "\n\n")
|
||||
builder.WriteString("arguments:\n")
|
||||
builder.WriteString(logArguments(job.Arguments))
|
||||
builder.WriteString("\n\nstart_only:\ntrue")
|
||||
return builder.String()
|
||||
}
|
||||
|
||||
func runStateDetail(err error, runErr error, duration time.Duration, job Job) (string, string) {
|
||||
if err == nil {
|
||||
return "OK", fmt.Sprintf("Completed in %s (exit code 0)", duration)
|
||||
}
|
||||
if errors.Is(runErr, context.DeadlineExceeded) {
|
||||
return "Failed", fmt.Sprintf("Timed out after %s", commandTimeout)
|
||||
}
|
||||
if errors.Is(err, exec.ErrWaitDelay) {
|
||||
return "OK", fmt.Sprintf("Completed; output capture stopped after %s because a child process kept the stream open", commandWaitDelay)
|
||||
}
|
||||
|
||||
var exitError *exec.ExitError
|
||||
if errors.As(err, &exitError) {
|
||||
exitCode := exitError.ExitCode()
|
||||
if acceptedExitCode(exitCode, job.SuccessExitCodes) {
|
||||
return "OK", fmt.Sprintf("Completed in %s with accepted exit code %d", duration, exitCode)
|
||||
}
|
||||
return "Failed", fmt.Sprintf("Exit code %d is not in success_exit_codes (%s)", exitCode, successExitCodesText(job))
|
||||
}
|
||||
return "Failed", fmt.Sprintf("%T: %v", err, err)
|
||||
}
|
||||
|
||||
func acceptedExitCode(exitCode int, successExitCodes string) bool {
|
||||
for _, accepted := range parseExitCodes(successExitCodes) {
|
||||
if exitCode == accepted {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func parseExitCodes(value string) []int {
|
||||
value = strings.TrimSpace(value)
|
||||
if value == "" {
|
||||
return []int{0}
|
||||
}
|
||||
fields := strings.FieldsFunc(value, func(r rune) bool {
|
||||
return r == ',' || r == ';' || r == ' ' || r == '\t' || r == '\n' || r == '\r'
|
||||
})
|
||||
result := make([]int, 0, len(fields))
|
||||
seen := map[int]bool{}
|
||||
for _, field := range fields {
|
||||
code, err := strconv.Atoi(strings.TrimSpace(field))
|
||||
if err != nil || seen[code] {
|
||||
continue
|
||||
}
|
||||
seen[code] = true
|
||||
result = append(result, code)
|
||||
}
|
||||
if len(result) == 0 {
|
||||
return []int{0}
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
func successExitCodesText(job Job) string {
|
||||
codes := parseExitCodes(job.SuccessExitCodes)
|
||||
parts := make([]string, 0, len(codes))
|
||||
for _, code := range codes {
|
||||
parts = append(parts, strconv.Itoa(code))
|
||||
}
|
||||
return strings.Join(parts, ",")
|
||||
}
|
||||
|
||||
type commandInvocation struct {
|
||||
command *exec.Cmd
|
||||
hideWindow bool
|
||||
}
|
||||
|
||||
func jobInvocation(ctx context.Context, job Job) commandInvocation {
|
||||
command := strings.TrimSpace(job.Command)
|
||||
arguments := commandArguments(job.Arguments)
|
||||
if len(arguments) > 0 || commandPathExists(command) {
|
||||
return commandInvocation{
|
||||
command: exec.CommandContext(ctx, unquoteCommandPath(command), arguments...),
|
||||
hideWindow: false,
|
||||
}
|
||||
}
|
||||
|
||||
// Shell mode remains for existing jobs and for commands that intentionally
|
||||
// use builtins, redirection, variables, or chained command syntax.
|
||||
return commandInvocation{
|
||||
command: shellCommand(ctx, command),
|
||||
hideWindow: true,
|
||||
}
|
||||
}
|
||||
|
||||
func commandArguments(arguments string) []string {
|
||||
var result []string
|
||||
for _, line := range strings.FieldsFunc(arguments, func(r rune) bool {
|
||||
return r == '\n' || r == '\r'
|
||||
}) {
|
||||
line = strings.TrimSpace(line)
|
||||
if line != "" {
|
||||
result = append(result, line)
|
||||
}
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
func commandPathExists(command string) bool {
|
||||
command = unquoteCommandPath(strings.TrimSpace(command))
|
||||
if command == "" {
|
||||
return false
|
||||
}
|
||||
info, err := os.Stat(command)
|
||||
return err == nil && !info.IsDir()
|
||||
}
|
||||
|
||||
func unquoteCommandPath(command string) string {
|
||||
return strings.Trim(strings.TrimSpace(command), `"`)
|
||||
}
|
||||
|
||||
func logArguments(arguments string) string {
|
||||
if strings.TrimSpace(arguments) == "" {
|
||||
return "<empty>"
|
||||
}
|
||||
return strings.ReplaceAll(strings.TrimSpace(arguments), "\r\n", "\n")
|
||||
}
|
||||
|
||||
func formatOutput(stdout string, stderr string) string {
|
||||
stdout = strings.TrimSpace(stdout)
|
||||
stderr = strings.TrimSpace(stderr)
|
||||
if stdout == "" {
|
||||
// Showing an explicit placeholder is clearer than an empty panel in the
|
||||
// GUI: the user can tell that the command ran but produced no stream data.
|
||||
stdout = "<empty>"
|
||||
}
|
||||
if stderr == "" {
|
||||
stderr = "<empty>"
|
||||
}
|
||||
return "stdout:\n" + stdout + "\n\nstderr:\n" + stderr
|
||||
}
|
||||
@@ -1,252 +0,0 @@
|
||||
package core
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"github.com/robfig/cron/v3"
|
||||
)
|
||||
|
||||
var cronParser = cron.NewParser(cron.Minute | cron.Hour | cron.Dom | cron.Month | cron.Dow | cron.Descriptor)
|
||||
|
||||
// Scheduler owns the timing loop for jobs that are currently loaded in the GUI.
|
||||
// It receives a pointer to the jobs slice because the GUI edits the same slice;
|
||||
// this keeps the early architecture simple while storage and scheduling are
|
||||
// still in one desktop process.
|
||||
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() {
|
||||
// A one-second ticker is accurate enough for cron-style desktop automation
|
||||
// and avoids the complexity of maintaining one timer per job. Five-field cron
|
||||
// expressions have minute precision, while @every values may be shorter for
|
||||
// testing and lightweight local tasks.
|
||||
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()
|
||||
// Pause state is reflected into each job's display string so the list view is
|
||||
// understandable even before the next scheduler tick.
|
||||
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) bool {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
|
||||
if index < 0 || index >= len(*s.jobs) {
|
||||
return false
|
||||
}
|
||||
// Manual runs share the same runner and log writer as scheduled runs. The
|
||||
// Trigger field is the only difference, which keeps History comparable and
|
||||
// prevents "Run now" from becoming a separate behavior path.
|
||||
return s.startRunLocked(index, "Manual")
|
||||
}
|
||||
|
||||
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 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
|
||||
}
|
||||
// Run only one due job per tick for now. That avoids overlapping shell
|
||||
// commands in the GUI process and keeps the first version predictable;
|
||||
// a future worker pool can add concurrency once cancellation and status
|
||||
// reporting are more explicit.
|
||||
changed = s.startRunLocked(index, "Schedule")
|
||||
break
|
||||
}
|
||||
}
|
||||
s.mu.Unlock()
|
||||
_ = changed
|
||||
}
|
||||
|
||||
func (s *Scheduler) startRunLocked(index int, trigger string) bool {
|
||||
job := &(*s.jobs)[index]
|
||||
if job.LastState == "Running" {
|
||||
return false
|
||||
}
|
||||
|
||||
jobCopy := *job
|
||||
job.LastState = "Running"
|
||||
job.NextRun = "Running"
|
||||
job.Output = runningOutput(jobCopy, trigger, time.Now())
|
||||
job.nextDue = time.Time{}
|
||||
_ = s.store.SaveJobs(*s.jobs)
|
||||
|
||||
go func() {
|
||||
record := RunJob(s.ctx, &jobCopy, trigger, s.store.Paths.LogsDir)
|
||||
|
||||
s.mu.Lock()
|
||||
if current := s.findJobByIDLocked(jobCopy.ID); current != nil {
|
||||
current.LastRun = record.Time
|
||||
current.LastState = record.State
|
||||
current.Output = record.Output
|
||||
current.Logs = append([]RunRecord{record}, current.Logs...)
|
||||
if len(current.Logs) > 50 {
|
||||
current.Logs = current.Logs[:50]
|
||||
}
|
||||
s.prepareNextRun(current, time.Now())
|
||||
_ = CleanupLogs(s.store.Paths.LogsDir, s.store.Config.MaxLogFiles, s.store.Config.MaxLogAgeDays)
|
||||
_ = s.store.SaveJobs(*s.jobs)
|
||||
}
|
||||
s.mu.Unlock()
|
||||
|
||||
if s.onChange != nil {
|
||||
s.onChange(record)
|
||||
}
|
||||
}()
|
||||
return true
|
||||
}
|
||||
|
||||
func (s *Scheduler) findJobByIDLocked(id int) *Job {
|
||||
for index := range *s.jobs {
|
||||
if (*s.jobs)[index].ID == id {
|
||||
return &(*s.jobs)[index]
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func runningOutput(job Job, trigger string, started time.Time) string {
|
||||
var builder strings.Builder
|
||||
builder.WriteString("status:\n")
|
||||
builder.WriteString("Running since " + started.Format("2006-01-02 15:04:05") + "\n\n")
|
||||
builder.WriteString("trigger:\n")
|
||||
builder.WriteString(trigger + "\n\n")
|
||||
builder.WriteString("command:\n")
|
||||
builder.WriteString(job.Command + "\n\n")
|
||||
builder.WriteString("arguments:\n")
|
||||
builder.WriteString(logArguments(job.Arguments))
|
||||
builder.WriteString("\n\nsuccess_exit_codes:\n")
|
||||
builder.WriteString(successExitCodesText(job))
|
||||
builder.WriteString("\n\nstart_only:\n")
|
||||
builder.WriteString(fmt.Sprintf("%t", job.StartOnly))
|
||||
return builder.String()
|
||||
}
|
||||
|
||||
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 ") {
|
||||
// @every is kept alongside cron because it is convenient for quick tests
|
||||
// and for simple intervals that are awkward to express as five fields.
|
||||
interval, err := time.ParseDuration(strings.TrimSpace(strings.TrimPrefix(schedule, "@every ")))
|
||||
if err != nil || interval <= 0 {
|
||||
return time.Time{}, false
|
||||
}
|
||||
return from.Add(interval), true
|
||||
}
|
||||
// Standard five-field cron keeps GoSentry compatible with the mental model
|
||||
// users already know from Unix cron, while robfig/cron handles edge cases
|
||||
// such as ranges, steps, and day-of-week names.
|
||||
parsed, err := cronParser.Parse(schedule)
|
||||
if err != nil {
|
||||
return time.Time{}, false
|
||||
}
|
||||
return parsed.Next(from), true
|
||||
}
|
||||
@@ -1,54 +0,0 @@
|
||||
package core
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"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)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRunningOutputIncludesInvocation(t *testing.T) {
|
||||
started := time.Date(2026, 6, 17, 23, 40, 0, 0, time.Local)
|
||||
job := Job{
|
||||
Name: "Backup",
|
||||
Command: `C:\Program Files\FreeFileSync\FreeFileSync.exe`,
|
||||
Arguments: `D:\Local\Jobs\Auto.ffs_batch`,
|
||||
SuccessExitCodes: "0,1",
|
||||
}
|
||||
|
||||
output := runningOutput(job, "Manual", started)
|
||||
for _, want := range []string{
|
||||
"Running since 2026-06-17 23:40:00",
|
||||
"Manual",
|
||||
job.Command,
|
||||
job.Arguments,
|
||||
"0,1",
|
||||
"start_only",
|
||||
} {
|
||||
if !strings.Contains(output, want) {
|
||||
t.Fatalf("expected running output to contain %q, got:\n%s", want, output)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,38 +0,0 @@
|
||||
package core
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"go.yaml.in/yaml/v4"
|
||||
)
|
||||
|
||||
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)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,25 @@
|
||||
package domain
|
||||
|
||||
// StartInTrayArgument is written to the Windows Startup shortcut so autostart
|
||||
// can keep the scheduler running without flashing the main window. Manual
|
||||
// launches omit this flag and open the normal window.
|
||||
const StartInTrayArgument = "--start-in-tray"
|
||||
|
||||
// Config is stored in gosentry.yaml next to the program. It contains only
|
||||
// application-level choices: where to read jobs from, where to write logs, and
|
||||
// how the desktop shell should behave.
|
||||
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"`
|
||||
StartOnLogin bool `yaml:"start_on_login"`
|
||||
KeepRunningInTray bool `yaml:"keep_running_in_tray"`
|
||||
NotifyOnFailure bool `yaml:"notify_on_failure"`
|
||||
}
|
||||
|
||||
// JobsFile is the on-disk shape of jobs.yaml. Wrapping the slice in a top-level
|
||||
// object leaves room for future metadata without breaking the basic file format.
|
||||
type JobsFile struct {
|
||||
Jobs []Job `yaml:"jobs"`
|
||||
}
|
||||
@@ -0,0 +1,18 @@
|
||||
package domain
|
||||
|
||||
// Job is the user-visible scheduled command. It contains only durable
|
||||
// configuration: every field is persisted to jobs.yaml. Transient execution
|
||||
// state (last run, next run, command output, in-memory activity) lives in a
|
||||
// separate JobRuntime so the jobs file stays a clean, hand-editable record of
|
||||
// configuration and never mixes in process-lifetime bookkeeping.
|
||||
type Job struct {
|
||||
ID int `yaml:"id"`
|
||||
Name string `yaml:"name"`
|
||||
Folder string `yaml:"folder,omitempty"`
|
||||
Schedule string `yaml:"schedule"`
|
||||
Command string `yaml:"command"`
|
||||
Arguments string `yaml:"arguments,omitempty"`
|
||||
SuccessExitCodes string `yaml:"success_exit_codes,omitempty"`
|
||||
StartOnly bool `yaml:"start_only,omitempty"`
|
||||
Enabled bool `yaml:"enabled"`
|
||||
}
|
||||
@@ -0,0 +1,15 @@
|
||||
package domain
|
||||
|
||||
// RunRecord represents one visible activity item. Scheduled and manual command
|
||||
// output is also written to a log file; the in-memory Output copy exists so the
|
||||
// latest run can be displayed without reopening the log on every repaint.
|
||||
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,49 @@
|
||||
package domain
|
||||
|
||||
import "time"
|
||||
|
||||
// JobRuntime is the transient execution state for a Job. It is never written to
|
||||
// jobs.yaml: it is rebuilt from scratch each time GoSentry starts and is held in
|
||||
// memory keyed by Job.ID for the lifetime of the process. Keeping it separate
|
||||
// from Job is what lets the durable configuration file stay free of run records,
|
||||
// status strings, and scheduling bookkeeping.
|
||||
type JobRuntime struct {
|
||||
LastRun string
|
||||
NextRun string
|
||||
LastState string
|
||||
Output string
|
||||
Logs []RunRecord
|
||||
|
||||
// NextDue is the next scheduled execution time, kept as time.Time for
|
||||
// scheduler comparisons. NextRun above is its formatted display string and is
|
||||
// the only form shown in the GUI.
|
||||
NextDue time.Time
|
||||
}
|
||||
|
||||
// NewRuntime builds the initial runtime state for a freshly loaded or created
|
||||
// job. Enabled jobs start "Ready" and wait for the scheduler to compute their
|
||||
// first run; disabled jobs start "Paused".
|
||||
func NewRuntime(job Job) *JobRuntime {
|
||||
runtime := &JobRuntime{
|
||||
LastRun: "Never",
|
||||
Output: "No command output captured yet.",
|
||||
}
|
||||
if job.Enabled {
|
||||
runtime.LastState = "Ready"
|
||||
runtime.NextRun = "After start"
|
||||
} else {
|
||||
runtime.LastState = "Paused"
|
||||
runtime.NextRun = "Paused"
|
||||
}
|
||||
return runtime
|
||||
}
|
||||
|
||||
// NewRuntimes builds a runtime map for a slice of jobs, keyed by Job.ID. It is
|
||||
// the convenience entry point used when a whole jobs file has just been loaded.
|
||||
func NewRuntimes(jobs []Job) map[int]*JobRuntime {
|
||||
runtimes := make(map[int]*JobRuntime, len(jobs))
|
||||
for _, job := range jobs {
|
||||
runtimes[job.ID] = NewRuntime(job)
|
||||
}
|
||||
return runtimes
|
||||
}
|
||||
@@ -0,0 +1,87 @@
|
||||
package domain
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/robfig/cron/v3"
|
||||
)
|
||||
|
||||
// cronParser accepts standard five-field cron expressions (minute, hour, day of
|
||||
// month, month, day of week) plus descriptors such as "@daily". It is the single
|
||||
// source of truth for what GoSentry considers a valid cron schedule.
|
||||
var cronParser = cron.NewParser(cron.Minute | cron.Hour | cron.Dom | cron.Month | cron.Dow | cron.Descriptor)
|
||||
|
||||
// everyPrefix marks the "@every <duration>" form, which is kept alongside cron
|
||||
// because it is convenient for quick tests and for simple intervals that are
|
||||
// awkward to express as five fields.
|
||||
const everyPrefix = "@every "
|
||||
|
||||
// Schedule is a parsed, validated job schedule. It supports two forms:
|
||||
//
|
||||
// - "@every <duration>" intervals (e.g. "@every 10s"), and
|
||||
// - standard five-field cron expressions (e.g. "*/5 * * * *").
|
||||
//
|
||||
// Parsing once and reusing the value avoids re-validating and re-parsing the
|
||||
// same string on every scheduler tick. A zero Schedule is invalid; its Next
|
||||
// method returns the zero time.
|
||||
type Schedule struct {
|
||||
raw string
|
||||
every time.Duration // > 0 when the schedule is an "@every" interval
|
||||
cron cron.Schedule // non-nil when the schedule is a cron expression
|
||||
}
|
||||
|
||||
// Parse validates spec and returns a reusable Schedule. It returns an error
|
||||
// describing why the schedule is unusable, which callers can surface to the user.
|
||||
func Parse(spec string) (Schedule, error) {
|
||||
trimmed := strings.TrimSpace(spec)
|
||||
if trimmed == "" {
|
||||
return Schedule{}, fmt.Errorf("schedule is empty")
|
||||
}
|
||||
if strings.HasPrefix(trimmed, everyPrefix) {
|
||||
interval, err := time.ParseDuration(strings.TrimSpace(strings.TrimPrefix(trimmed, everyPrefix)))
|
||||
if err != nil {
|
||||
return Schedule{}, fmt.Errorf("invalid %q duration: %w", strings.TrimSpace(everyPrefix), err)
|
||||
}
|
||||
if interval <= 0 {
|
||||
return Schedule{}, fmt.Errorf("%q duration must be positive, got %s", strings.TrimSpace(everyPrefix), interval)
|
||||
}
|
||||
return Schedule{raw: trimmed, every: interval}, nil
|
||||
}
|
||||
// robfig/cron handles edge cases such as ranges, steps, and day-of-week names,
|
||||
// keeping GoSentry compatible with the mental model users know from Unix cron.
|
||||
parsed, err := cronParser.Parse(trimmed)
|
||||
if err != nil {
|
||||
return Schedule{}, fmt.Errorf("invalid cron expression: %w", err)
|
||||
}
|
||||
return Schedule{raw: trimmed, cron: parsed}, nil
|
||||
}
|
||||
|
||||
// Validate reports whether spec is a usable schedule string. It is a convenience
|
||||
// wrapper around Parse for callers (such as form validation) that only need the
|
||||
// yes/no answer and the error message.
|
||||
func Validate(spec string) error {
|
||||
_, err := Parse(spec)
|
||||
return err
|
||||
}
|
||||
|
||||
// Next returns the next time the schedule fires strictly after from. For an
|
||||
// "@every" interval this is from plus the interval; for a cron expression it is
|
||||
// the cron library's next matching time. A zero (unparsed) Schedule returns the
|
||||
// zero time.
|
||||
func (s Schedule) Next(from time.Time) time.Time {
|
||||
switch {
|
||||
case s.every > 0:
|
||||
return from.Add(s.every)
|
||||
case s.cron != nil:
|
||||
return s.cron.Next(from)
|
||||
default:
|
||||
return time.Time{}
|
||||
}
|
||||
}
|
||||
|
||||
// String returns the original, trimmed schedule specification.
|
||||
func (s Schedule) String() string {
|
||||
return s.raw
|
||||
}
|
||||
@@ -0,0 +1,103 @@
|
||||
package domain
|
||||
|
||||
import (
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
func TestParseRejectsInvalidSchedules(t *testing.T) {
|
||||
cases := []struct {
|
||||
spec string
|
||||
desc string
|
||||
}{
|
||||
{"", "empty string"},
|
||||
{" ", "whitespace only"},
|
||||
{"@every", "bare @every without duration"},
|
||||
{"@every ", "@every with trailing space but no duration"},
|
||||
{"@every xyz", "invalid @every duration string"},
|
||||
{"@every -1s", "negative @every duration"},
|
||||
{"@every 0s", "zero @every duration"},
|
||||
{"not-a-cron", "invalid cron expression"},
|
||||
{"60 * * * *", "cron minute out of range"},
|
||||
{"* * * *", "too few cron fields"},
|
||||
}
|
||||
for _, tc := range cases {
|
||||
if _, err := Parse(tc.spec); err == nil {
|
||||
t.Errorf("Parse(%q) [%s]: expected error, got nil", tc.spec, tc.desc)
|
||||
}
|
||||
if err := Validate(tc.spec); err == nil {
|
||||
t.Errorf("Validate(%q) [%s]: expected error, got nil", tc.spec, tc.desc)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseEveryInterval(t *testing.T) {
|
||||
from := time.Date(2026, 6, 14, 12, 0, 0, 0, time.UTC)
|
||||
s, err := Parse("@every 10s")
|
||||
if err != nil {
|
||||
t.Fatalf("Parse(@every 10s): unexpected error: %v", err)
|
||||
}
|
||||
if got, want := s.Next(from), from.Add(10*time.Second); !got.Equal(want) {
|
||||
t.Fatalf("Next: got %s, want %s", got, want)
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseEveryTrimsSurroundingWhitespace(t *testing.T) {
|
||||
from := time.Date(2026, 6, 14, 12, 0, 0, 0, time.UTC)
|
||||
s, err := Parse(" @every 90m ")
|
||||
if err != nil {
|
||||
t.Fatalf("Parse: unexpected error: %v", err)
|
||||
}
|
||||
if got, want := s.Next(from), from.Add(90*time.Minute); !got.Equal(want) {
|
||||
t.Fatalf("Next: got %s, want %s", got, want)
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseCronExpression(t *testing.T) {
|
||||
from := time.Date(2026, 6, 14, 12, 3, 0, 0, time.UTC)
|
||||
s, err := Parse("*/5 * * * *")
|
||||
if err != nil {
|
||||
t.Fatalf("Parse(*/5 * * * *): unexpected error: %v", err)
|
||||
}
|
||||
want := time.Date(2026, 6, 14, 12, 5, 0, 0, time.UTC)
|
||||
if got := s.Next(from); !got.Equal(want) {
|
||||
t.Fatalf("Next: got %s, want %s", got, want)
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseCronDescriptor(t *testing.T) {
|
||||
from := time.Date(2026, 6, 14, 12, 3, 0, 0, time.UTC)
|
||||
s, err := Parse("@daily")
|
||||
if err != nil {
|
||||
t.Fatalf("Parse(@daily): unexpected error: %v", err)
|
||||
}
|
||||
want := time.Date(2026, 6, 15, 0, 0, 0, 0, time.UTC)
|
||||
if got := s.Next(from); !got.Equal(want) {
|
||||
t.Fatalf("Next: got %s, want %s", got, want)
|
||||
}
|
||||
}
|
||||
|
||||
func TestValidateAcceptsValidSchedules(t *testing.T) {
|
||||
for _, spec := range []string{"@every 1s", "*/5 * * * *", "0 9 * * 1", "@hourly"} {
|
||||
if err := Validate(spec); err != nil {
|
||||
t.Errorf("Validate(%q): unexpected error: %v", spec, err)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestZeroScheduleNextIsZero(t *testing.T) {
|
||||
var s Schedule
|
||||
if got := s.Next(time.Now()); !got.IsZero() {
|
||||
t.Fatalf("zero Schedule Next: got %s, want zero time", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestStringReturnsTrimmedSpec(t *testing.T) {
|
||||
s, err := Parse(" */5 * * * * ")
|
||||
if err != nil {
|
||||
t.Fatalf("Parse: unexpected error: %v", err)
|
||||
}
|
||||
if got, want := s.String(), "*/5 * * * *"; got != want {
|
||||
t.Fatalf("String: got %q, want %q", got, want)
|
||||
}
|
||||
}
|
||||
-1057
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,9 @@
|
||||
package autostart
|
||||
|
||||
// Manager controls platform autostart for the application.
|
||||
type Manager interface {
|
||||
// Set writes or removes the platform autostart entry to match enabled.
|
||||
Set(enabled bool, executablePath, iconPath string) error
|
||||
// Status reports whether the platform autostart entry matches expectedEnabled.
|
||||
Status(expectedEnabled bool, executablePath string) (ok bool, message string)
|
||||
}
|
||||
@@ -1,6 +1,6 @@
|
||||
//go:build linux
|
||||
|
||||
package core
|
||||
package autostart
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
@@ -9,8 +9,23 @@ import (
|
||||
"path/filepath"
|
||||
"strconv"
|
||||
"strings"
|
||||
|
||||
"gitea.mixdep.ru/mix/gosentry/src/domain"
|
||||
)
|
||||
|
||||
type linuxManager struct{}
|
||||
|
||||
// New returns the Linux autostart Manager.
|
||||
func New() Manager { return linuxManager{} }
|
||||
|
||||
func (linuxManager) Set(enabled bool, executablePath, iconPath string) error {
|
||||
return SetAutostart(enabled, executablePath, iconPath)
|
||||
}
|
||||
|
||||
func (linuxManager) Status(expectedEnabled bool, executablePath string) (bool, string) {
|
||||
return AutostartStatus(expectedEnabled, executablePath)
|
||||
}
|
||||
|
||||
const autostartDesktopFileName = "gosentry.desktop"
|
||||
const legacyAutostartDesktopFileName = "pysentry.desktop"
|
||||
|
||||
@@ -43,7 +58,7 @@ Exec=%s %s
|
||||
%s
|
||||
Terminal=false
|
||||
X-GNOME-Autostart-enabled=true
|
||||
`, quoteDesktopExec(executablePath), StartInTrayArgument, desktopIconLine(iconPath))
|
||||
`, quoteDesktopExec(executablePath), domain.StartInTrayArgument, desktopIconLine(iconPath))
|
||||
return os.WriteFile(desktopPath, []byte(desktopFile), 0o644)
|
||||
}
|
||||
|
||||
@@ -75,7 +90,7 @@ func AutostartStatus(expectedEnabled bool, executablePath string) (bool, string)
|
||||
if readErr != nil {
|
||||
return false, "Autostart desktop entry is missing"
|
||||
}
|
||||
expectedExec := "Exec=" + quoteDesktopExec(executablePath) + " " + StartInTrayArgument
|
||||
expectedExec := "Exec=" + quoteDesktopExec(executablePath) + " " + domain.StartInTrayArgument
|
||||
if !strings.Contains(string(data), expectedExec) {
|
||||
return false, "Autostart desktop entry points to another executable"
|
||||
}
|
||||
@@ -1,12 +1,14 @@
|
||||
//go:build linux
|
||||
|
||||
package core
|
||||
package autostart
|
||||
|
||||
import (
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"gitea.mixdep.ru/mix/gosentry/src/domain"
|
||||
)
|
||||
|
||||
func TestLinuxAutostartStartsInTray(t *testing.T) {
|
||||
@@ -26,7 +28,7 @@ func TestLinuxAutostartStartsInTray(t *testing.T) {
|
||||
t.Fatalf("read desktop entry: %v", err)
|
||||
}
|
||||
|
||||
expectedExec := "Exec=" + quoteDesktopExec(executablePath) + " " + StartInTrayArgument
|
||||
expectedExec := "Exec=" + quoteDesktopExec(executablePath) + " " + domain.StartInTrayArgument
|
||||
if !strings.Contains(string(data), expectedExec) {
|
||||
t.Fatalf("desktop entry does not start in tray: %s", data)
|
||||
}
|
||||
@@ -0,0 +1,32 @@
|
||||
//go:build !windows && !linux
|
||||
|
||||
package autostart
|
||||
|
||||
import "fmt"
|
||||
|
||||
type otherManager struct{}
|
||||
|
||||
// New returns the stub autostart Manager for unsupported platforms.
|
||||
func New() Manager { return otherManager{} }
|
||||
|
||||
func (otherManager) Set(enabled bool, executablePath, iconPath string) error {
|
||||
return SetAutostart(enabled, executablePath, iconPath)
|
||||
}
|
||||
|
||||
func (otherManager) Status(expectedEnabled bool, executablePath string) (bool, string) {
|
||||
return AutostartStatus(expectedEnabled, executablePath)
|
||||
}
|
||||
|
||||
func SetAutostart(enabled bool, executablePath string, iconPath string) error {
|
||||
if !enabled {
|
||||
return nil
|
||||
}
|
||||
return fmt.Errorf("autostart is not implemented for this platform")
|
||||
}
|
||||
|
||||
func AutostartStatus(expectedEnabled bool, executablePath string) (bool, string) {
|
||||
if !expectedEnabled {
|
||||
return true, "Autostart is off"
|
||||
}
|
||||
return false, "Autostart is not implemented for this platform"
|
||||
}
|
||||
@@ -1,4 +1,4 @@
|
||||
package core
|
||||
package autostart
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
@@ -6,8 +6,24 @@ import (
|
||||
"os/exec"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
|
||||
"gitea.mixdep.ru/mix/gosentry/src/domain"
|
||||
"gitea.mixdep.ru/mix/gosentry/src/platform/winproc"
|
||||
)
|
||||
|
||||
type windowsManager struct{}
|
||||
|
||||
// New returns the Windows autostart Manager.
|
||||
func New() Manager { return windowsManager{} }
|
||||
|
||||
func (windowsManager) Set(enabled bool, executablePath, iconPath string) error {
|
||||
return SetAutostart(enabled, executablePath, iconPath)
|
||||
}
|
||||
|
||||
func (windowsManager) Status(expectedEnabled bool, executablePath string) (bool, string) {
|
||||
return AutostartStatus(expectedEnabled, executablePath)
|
||||
}
|
||||
|
||||
const autostartName = "GoSentry"
|
||||
const legacyAutostartName = "PySentry"
|
||||
const startupShortcutFile = autostartName + ".lnk"
|
||||
@@ -69,7 +85,7 @@ func AutostartStatus(expectedEnabled bool, executablePath string) (bool, string)
|
||||
if !sameWindowsPath(actual, executablePath) {
|
||||
return false, "Autostart shortcut points to another executable"
|
||||
}
|
||||
if strings.TrimSpace(arguments) != StartInTrayArgument {
|
||||
if strings.TrimSpace(arguments) != domain.StartInTrayArgument {
|
||||
return false, "Autostart shortcut does not start in tray"
|
||||
}
|
||||
return true, "Autostart is configured"
|
||||
@@ -101,11 +117,11 @@ func createStartupShortcut(shortcutPath string, executablePath string, iconPath
|
||||
command.Env = append(os.Environ(),
|
||||
"GOSENTRY_SHORTCUT_PATH="+shortcutPath,
|
||||
"GOSENTRY_TARGET_PATH="+executablePath,
|
||||
"GOSENTRY_ARGUMENTS="+StartInTrayArgument,
|
||||
"GOSENTRY_ARGUMENTS="+domain.StartInTrayArgument,
|
||||
"GOSENTRY_WORKING_DIRECTORY="+workingDirectory,
|
||||
"GOSENTRY_ICON_PATH="+iconPath,
|
||||
)
|
||||
configureHiddenWindow(command)
|
||||
winproc.ConfigureHiddenWindow(command)
|
||||
if output, err := command.CombinedOutput(); err != nil {
|
||||
return fmt.Errorf("create startup shortcut: %w: %s", err, strings.TrimSpace(string(output)))
|
||||
}
|
||||
@@ -123,7 +139,7 @@ func readShortcut(shortcutPath string) (string, string, error) {
|
||||
script := `[Console]::OutputEncoding = New-Object System.Text.UTF8Encoding($false); $shell = New-Object -ComObject WScript.Shell; $shortcut = $shell.CreateShortcut($env:GOSENTRY_SHORTCUT_PATH); [Console]::Out.Write($shortcut.TargetPath + [Environment]::NewLine + $shortcut.Arguments)`
|
||||
command := exec.Command("powershell.exe", "-NoProfile", "-NonInteractive", "-ExecutionPolicy", "Bypass", "-Command", script)
|
||||
command.Env = append(os.Environ(), "GOSENTRY_SHORTCUT_PATH="+shortcutPath)
|
||||
configureHiddenWindow(command)
|
||||
winproc.ConfigureHiddenWindow(command)
|
||||
output, err := command.CombinedOutput()
|
||||
if err != nil {
|
||||
return "", "", fmt.Errorf("read startup shortcut: %w: %s", err, strings.TrimSpace(string(output)))
|
||||
@@ -153,7 +169,7 @@ func removeIfExists(path string) error {
|
||||
func cleanupLegacyRegistryAutostart() error {
|
||||
for _, name := range []string{legacyAutostartName, autostartName} {
|
||||
command := exec.Command("reg.exe", "delete", `HKCU\Software\Microsoft\Windows\CurrentVersion\Run`, "/v", name, "/f")
|
||||
configureHiddenWindow(command)
|
||||
winproc.ConfigureHiddenWindow(command)
|
||||
_ = command.Run()
|
||||
}
|
||||
return nil
|
||||
@@ -162,7 +178,7 @@ func cleanupLegacyRegistryAutostart() error {
|
||||
func legacyRegistryAutostartExists() bool {
|
||||
for _, name := range []string{legacyAutostartName, autostartName} {
|
||||
command := exec.Command("reg.exe", "query", `HKCU\Software\Microsoft\Windows\CurrentVersion\Run`, "/v", name)
|
||||
configureHiddenWindow(command)
|
||||
winproc.ConfigureHiddenWindow(command)
|
||||
if command.Run() == nil {
|
||||
return true
|
||||
}
|
||||
+7
-5
@@ -1,12 +1,14 @@
|
||||
//go:build windows
|
||||
|
||||
package core
|
||||
package autostart
|
||||
|
||||
import (
|
||||
"os"
|
||||
"path/filepath"
|
||||
"syscall"
|
||||
"testing"
|
||||
|
||||
"gitea.mixdep.ru/mix/gosentry/src/domain"
|
||||
)
|
||||
|
||||
func TestParseRegistryRunValue(t *testing.T) {
|
||||
@@ -111,8 +113,8 @@ func TestCreateStartupShortcutHandlesCyrillicPath(t *testing.T) {
|
||||
if !sameWindowsPath(actual, targetPath) {
|
||||
t.Fatalf("shortcut target mismatch: got %q want %q", actual, targetPath)
|
||||
}
|
||||
if arguments != StartInTrayArgument {
|
||||
t.Fatalf("shortcut arguments mismatch: got %q want %q", arguments, StartInTrayArgument)
|
||||
if arguments != domain.StartInTrayArgument {
|
||||
t.Fatalf("shortcut arguments mismatch: got %q want %q", arguments, domain.StartInTrayArgument)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -138,7 +140,7 @@ func TestCreateStartupShortcutHandlesSpaces(t *testing.T) {
|
||||
if !sameWindowsPath(actual, targetPath) {
|
||||
t.Fatalf("shortcut target mismatch: got %q want %q", actual, targetPath)
|
||||
}
|
||||
if arguments != StartInTrayArgument {
|
||||
t.Fatalf("shortcut arguments mismatch: got %q want %q", arguments, StartInTrayArgument)
|
||||
if arguments != domain.StartInTrayArgument {
|
||||
t.Fatalf("shortcut arguments mismatch: got %q want %q", arguments, domain.StartInTrayArgument)
|
||||
}
|
||||
}
|
||||
@@ -1,11 +1,12 @@
|
||||
//go:build linux
|
||||
|
||||
package core
|
||||
package desktop
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strconv"
|
||||
)
|
||||
|
||||
func InstallDesktopIntegration(appID string, executablePath string, icon []byte) (string, error) {
|
||||
@@ -40,6 +41,10 @@ StartupWMClass=%s
|
||||
return iconPath, nil
|
||||
}
|
||||
|
||||
func quoteDesktopExec(path string) string {
|
||||
return strconv.Quote(path)
|
||||
}
|
||||
|
||||
func xdgDataHome() (string, error) {
|
||||
dataHome := os.Getenv("XDG_DATA_HOME")
|
||||
if dataHome == "" {
|
||||
@@ -1,6 +1,6 @@
|
||||
//go:build !linux
|
||||
|
||||
package core
|
||||
package desktop
|
||||
|
||||
func InstallDesktopIntegration(appID string, executablePath string, icon []byte) (string, error) {
|
||||
return "", nil
|
||||
@@ -0,0 +1,10 @@
|
||||
//go:build !windows
|
||||
|
||||
package winproc
|
||||
|
||||
import "os/exec"
|
||||
|
||||
// ConfigureHiddenWindow is a no-op on non-Windows platforms: launching sh -c
|
||||
// from a desktop process does not create a new console window in the same way
|
||||
// Windows does.
|
||||
func ConfigureHiddenWindow(command *exec.Cmd) {}
|
||||
@@ -0,0 +1,18 @@
|
||||
package winproc
|
||||
|
||||
import (
|
||||
"os/exec"
|
||||
"syscall"
|
||||
)
|
||||
|
||||
// ConfigureHiddenWindow suppresses the console window that Windows would
|
||||
// otherwise flash when running a child process from a GUI application.
|
||||
// CREATE_NO_WINDOW keeps cmd.exe and simple console tools quiet while
|
||||
// stdout/stderr are still captured through pipes.
|
||||
func ConfigureHiddenWindow(command *exec.Cmd) {
|
||||
if command.SysProcAttr == nil {
|
||||
command.SysProcAttr = &syscall.SysProcAttr{}
|
||||
}
|
||||
command.SysProcAttr.CreationFlags |= 0x08000000
|
||||
command.SysProcAttr.HideWindow = true
|
||||
}
|
||||
@@ -0,0 +1,61 @@
|
||||
package runner
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"sort"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
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 {
|
||||
// Only GoSentry run logs are managed here. Directories and non-.log files
|
||||
// are intentionally ignored so the user can keep notes or other artifacts
|
||||
// in the same folder without the cleanup policy deleting them.
|
||||
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) {
|
||||
// Cleanup is best-effort: failing to delete one file should not block
|
||||
// the scheduler from running future jobs.
|
||||
_ = 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 {
|
||||
// Newest files are kept first, then everything after maxFiles is removed.
|
||||
// This matches the user's expectation that the most recent failures and
|
||||
// command output remain available for investigation.
|
||||
return logs[i].modTime.After(logs[j].modTime)
|
||||
})
|
||||
for _, old := range logs[maxFiles:] {
|
||||
_ = os.Remove(old.path)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
@@ -0,0 +1,154 @@
|
||||
package runner
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
func writeLogFile(t *testing.T, dir, name string) string {
|
||||
t.Helper()
|
||||
path := filepath.Join(dir, name)
|
||||
if err := os.WriteFile(path, []byte("log"), 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
return path
|
||||
}
|
||||
|
||||
func setModTime(t *testing.T, path string, age time.Duration) {
|
||||
t.Helper()
|
||||
mt := time.Now().Add(-age)
|
||||
if err := os.Chtimes(path, mt, mt); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCleanupLogsMissingDirReturnsNil(t *testing.T) {
|
||||
err := CleanupLogs(filepath.Join(t.TempDir(), "nonexistent"), 100, 30)
|
||||
if err != nil {
|
||||
t.Errorf("missing dir should return nil, got %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCleanupLogsRemovesFilesPastMaxAge(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
old := writeLogFile(t, dir, "old.log")
|
||||
recent := writeLogFile(t, dir, "recent.log")
|
||||
setModTime(t, old, 31*24*time.Hour) // 31 days old → past the 30-day limit
|
||||
setModTime(t, recent, 5*24*time.Hour) // 5 days old → within limit
|
||||
|
||||
if err := CleanupLogs(dir, 100, 30); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, err := os.Stat(old); !os.IsNotExist(err) {
|
||||
t.Error("file older than maxAgeDays should be deleted")
|
||||
}
|
||||
if _, err := os.Stat(recent); err != nil {
|
||||
t.Errorf("file within maxAgeDays should be kept: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCleanupLogsKeepsFilesWithinAgeLimit(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
for i := 1; i <= 3; i++ {
|
||||
path := writeLogFile(t, dir, fmt.Sprintf("job_%d.log", i))
|
||||
setModTime(t, path, time.Duration(i)*24*time.Hour)
|
||||
}
|
||||
|
||||
if err := CleanupLogs(dir, 100, 30); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
entries, _ := os.ReadDir(dir)
|
||||
if len(entries) != 3 {
|
||||
t.Errorf("expected 3 files kept within age limit, got %d", len(entries))
|
||||
}
|
||||
}
|
||||
|
||||
// TestCleanupLogsByCountDeletesOldest verifies the count-based policy: when more
|
||||
// than maxFiles log files exist the oldest (by modification time) are removed.
|
||||
// maxAgeDays=0 disables age-based cleanup so the test exercises count only.
|
||||
func TestCleanupLogsByCountDeletesOldest(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
// Create 5 files; i=0 is newest (1 day old), i=4 is oldest (5 days old).
|
||||
var paths []string
|
||||
for i := 0; i < 5; i++ {
|
||||
path := writeLogFile(t, dir, fmt.Sprintf("job_%03d.log", i))
|
||||
setModTime(t, path, time.Duration(i+1)*24*time.Hour)
|
||||
paths = append(paths, path)
|
||||
}
|
||||
|
||||
if err := CleanupLogs(dir, 3, 0); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
entries, _ := os.ReadDir(dir)
|
||||
if len(entries) != 3 {
|
||||
t.Errorf("expected 3 files after count cleanup, got %d", len(entries))
|
||||
}
|
||||
// The 3 newest files (paths[0..2]) must survive.
|
||||
for _, kept := range paths[:3] {
|
||||
if _, err := os.Stat(kept); err != nil {
|
||||
t.Errorf("newest file %s should be kept: %v", filepath.Base(kept), err)
|
||||
}
|
||||
}
|
||||
// The 2 oldest files (paths[3..4]) must be removed.
|
||||
for _, deleted := range paths[3:] {
|
||||
if _, err := os.Stat(deleted); !os.IsNotExist(err) {
|
||||
t.Errorf("oldest file %s should have been deleted", filepath.Base(deleted))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestCleanupLogsNonLogFilesNotDeleted(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
logFile := writeLogFile(t, dir, "job.log")
|
||||
notALog := writeLogFile(t, dir, "notes.txt")
|
||||
// Both are old enough that age-based cleanup would remove them if it applied.
|
||||
setModTime(t, logFile, 35*24*time.Hour)
|
||||
setModTime(t, notALog, 35*24*time.Hour)
|
||||
|
||||
if err := CleanupLogs(dir, 100, 30); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, err := os.Stat(logFile); !os.IsNotExist(err) {
|
||||
t.Error("old .log file should be deleted")
|
||||
}
|
||||
if _, err := os.Stat(notALog); err != nil {
|
||||
t.Errorf(".txt file should not be deleted: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCleanupLogsSubdirsNotDeleted(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
subdir := filepath.Join(dir, "archive.log") // name looks like a log but is a dir
|
||||
if err := os.Mkdir(subdir, 0o755); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
setModTime(t, subdir, 60*24*time.Hour)
|
||||
|
||||
if err := CleanupLogs(dir, 100, 30); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, err := os.Stat(subdir); err != nil {
|
||||
t.Errorf("subdirectory should not be deleted: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// TestCleanupLogsZeroLimitsDisableBothPolicies confirms that maxFiles=0 disables
|
||||
// count-based cleanup and maxAgeDays=0 disables age-based cleanup independently.
|
||||
func TestCleanupLogsZeroLimitsDisableBothPolicies(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
for i := 0; i < 5; i++ {
|
||||
path := writeLogFile(t, dir, fmt.Sprintf("job_%d.log", i))
|
||||
setModTime(t, path, 60*24*time.Hour) // very old
|
||||
}
|
||||
|
||||
if err := CleanupLogs(dir, 0, 0); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
entries, _ := os.ReadDir(dir)
|
||||
if len(entries) != 5 {
|
||||
t.Errorf("expected all 5 files kept with both limits disabled, got %d", len(entries))
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,52 @@
|
||||
package runner
|
||||
|
||||
import (
|
||||
"strconv"
|
||||
"strings"
|
||||
|
||||
"gitea.mixdep.ru/mix/gosentry/src/domain"
|
||||
)
|
||||
|
||||
func acceptedExitCode(exitCode int, successExitCodes string) bool {
|
||||
for _, accepted := range parseExitCodes(successExitCodes) {
|
||||
if exitCode == accepted {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func parseExitCodes(value string) []int {
|
||||
value = strings.TrimSpace(value)
|
||||
if value == "" {
|
||||
return []int{0}
|
||||
}
|
||||
fields := strings.FieldsFunc(value, func(r rune) bool {
|
||||
return r == ',' || r == ';' || r == ' ' || r == '\t' || r == '\n' || r == '\r'
|
||||
})
|
||||
result := make([]int, 0, len(fields))
|
||||
seen := map[int]bool{}
|
||||
for _, field := range fields {
|
||||
code, err := strconv.Atoi(strings.TrimSpace(field))
|
||||
if err != nil || seen[code] {
|
||||
continue
|
||||
}
|
||||
seen[code] = true
|
||||
result = append(result, code)
|
||||
}
|
||||
if len(result) == 0 {
|
||||
return []int{0}
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
func SuccessExitCodesText(job domain.Job) string {
|
||||
codes := parseExitCodes(job.SuccessExitCodes)
|
||||
parts := make([]string, 0, len(codes))
|
||||
for _, code := range codes {
|
||||
parts = append(parts, strconv.Itoa(code))
|
||||
}
|
||||
return strings.Join(parts, ",")
|
||||
}
|
||||
|
||||
func successExitCodesText(job domain.Job) string { return SuccessExitCodesText(job) }
|
||||
@@ -0,0 +1,68 @@
|
||||
package runner
|
||||
|
||||
import (
|
||||
"context"
|
||||
"os"
|
||||
"os/exec"
|
||||
"strings"
|
||||
|
||||
"gitea.mixdep.ru/mix/gosentry/src/domain"
|
||||
)
|
||||
|
||||
type commandInvocation struct {
|
||||
command *exec.Cmd
|
||||
hideWindow bool
|
||||
}
|
||||
|
||||
func jobInvocation(ctx context.Context, job domain.Job) commandInvocation {
|
||||
command := strings.TrimSpace(job.Command)
|
||||
arguments := commandArguments(job.Arguments)
|
||||
if len(arguments) > 0 || commandPathExists(command) {
|
||||
return commandInvocation{
|
||||
command: exec.CommandContext(ctx, unquoteCommandPath(command), arguments...),
|
||||
hideWindow: false,
|
||||
}
|
||||
}
|
||||
|
||||
// Shell mode remains for existing jobs and for commands that intentionally
|
||||
// use builtins, redirection, variables, or chained command syntax.
|
||||
return commandInvocation{
|
||||
command: shellCommand(ctx, command),
|
||||
hideWindow: true,
|
||||
}
|
||||
}
|
||||
|
||||
func commandArguments(arguments string) []string {
|
||||
var result []string
|
||||
for _, line := range strings.FieldsFunc(arguments, func(r rune) bool {
|
||||
return r == '\n' || r == '\r'
|
||||
}) {
|
||||
line = strings.TrimSpace(line)
|
||||
if line != "" {
|
||||
result = append(result, line)
|
||||
}
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
func commandPathExists(command string) bool {
|
||||
command = unquoteCommandPath(strings.TrimSpace(command))
|
||||
if command == "" {
|
||||
return false
|
||||
}
|
||||
info, err := os.Stat(command)
|
||||
return err == nil && !info.IsDir()
|
||||
}
|
||||
|
||||
func unquoteCommandPath(command string) string {
|
||||
return strings.Trim(strings.TrimSpace(command), `"`)
|
||||
}
|
||||
|
||||
func LogArguments(arguments string) string {
|
||||
if strings.TrimSpace(arguments) == "" {
|
||||
return "<empty>"
|
||||
}
|
||||
return strings.ReplaceAll(strings.TrimSpace(arguments), "\r\n", "\n")
|
||||
}
|
||||
|
||||
func logArguments(arguments string) string { return LogArguments(arguments) }
|
||||
@@ -1,6 +1,6 @@
|
||||
//go:build !windows
|
||||
|
||||
package core
|
||||
package runner
|
||||
|
||||
import (
|
||||
"context"
|
||||
@@ -12,9 +12,3 @@ func shellCommand(ctx context.Context, command string) *exec.Cmd {
|
||||
// and avoids a hard dependency on a larger shell such as bash.
|
||||
return exec.CommandContext(ctx, "sh", "-c", command)
|
||||
}
|
||||
|
||||
func configureHiddenWindow(command *exec.Cmd) {
|
||||
// Non-Windows platforms do not create a new console window for sh -c from a
|
||||
// desktop process in the same way Windows does, so no extra process attribute
|
||||
// is required here.
|
||||
}
|
||||
@@ -1,4 +1,4 @@
|
||||
package core
|
||||
package runner
|
||||
|
||||
import (
|
||||
"context"
|
||||
@@ -56,14 +56,3 @@ func startsWithWindowsRootedPath(command string) bool {
|
||||
command[1] == ':' &&
|
||||
(command[2] == '\\' || command[2] == '/')
|
||||
}
|
||||
|
||||
func configureHiddenWindow(command *exec.Cmd) {
|
||||
// GoSentry is a GUI scheduler, so child commands should not flash a console
|
||||
// window on Windows. CREATE_NO_WINDOW keeps cmd.exe and simple console tools
|
||||
// quiet while stdout/stderr are still captured through pipes.
|
||||
if command.SysProcAttr == nil {
|
||||
command.SysProcAttr = &syscall.SysProcAttr{}
|
||||
}
|
||||
command.SysProcAttr.CreationFlags |= 0x08000000
|
||||
command.SysProcAttr.HideWindow = true
|
||||
}
|
||||
@@ -0,0 +1,55 @@
|
||||
package runner
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"time"
|
||||
"unicode"
|
||||
|
||||
"gitea.mixdep.ru/mix/gosentry/src/domain"
|
||||
)
|
||||
|
||||
func writeRunLog(logsDir string, job domain.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 ""
|
||||
}
|
||||
// The timestamp comes first so a plain directory listing is naturally sorted
|
||||
// by run time. The job name is included for human scanning, but sanitized to
|
||||
// avoid characters that are invalid on Windows or awkward on shells.
|
||||
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\narguments: %s\nsuccess_exit_codes: %s\nstart_only: %t\n\n%s\n",
|
||||
started.Format("2006-01-02 15:04:05"), job.ID, job.Name, trigger, state, detail, job.Command, logArguments(job.Arguments), successExitCodesText(job), job.StartOnly, 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
|
||||
}
|
||||
@@ -0,0 +1,137 @@
|
||||
package runner
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"os/exec"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"gitea.mixdep.ru/mix/gosentry/src/domain"
|
||||
"gitea.mixdep.ru/mix/gosentry/src/platform/winproc"
|
||||
)
|
||||
|
||||
const commandTimeout = 30 * time.Second
|
||||
const commandWaitDelay = 2 * time.Second
|
||||
|
||||
func RunJob(ctx context.Context, job *domain.Job, trigger string, logsDir string) domain.RunRecord {
|
||||
started := time.Now()
|
||||
// Commands can hang forever if a script waits for input or a child process
|
||||
// stalls. A fixed timeout is a conservative first guardrail for a desktop
|
||||
// scheduler; later it can become a per-job setting without changing the
|
||||
// runner contract.
|
||||
runCtx, cancel := context.WithTimeout(ctx, commandTimeout)
|
||||
defer cancel()
|
||||
|
||||
var output string
|
||||
var state string
|
||||
var detail string
|
||||
if job.StartOnly {
|
||||
invocation := jobInvocation(context.Background(), *job)
|
||||
state, detail, output = startJobOnly(invocation, *job, started)
|
||||
} else {
|
||||
var stdoutBuf strings.Builder
|
||||
var stderrBuf strings.Builder
|
||||
invocation := jobInvocation(runCtx, *job)
|
||||
command := invocation.command
|
||||
command.WaitDelay = commandWaitDelay
|
||||
if invocation.hideWindow {
|
||||
winproc.ConfigureHiddenWindow(command)
|
||||
}
|
||||
command.Stdout = &stdoutBuf
|
||||
command.Stderr = &stderrBuf
|
||||
|
||||
err := command.Run()
|
||||
duration := time.Since(started).Round(time.Millisecond)
|
||||
output = formatOutput(stdoutBuf.String(), stderrBuf.String())
|
||||
state, detail = runStateDetail(err, runCtx.Err(), duration, *job)
|
||||
}
|
||||
|
||||
now := time.Now()
|
||||
timestamp := now.Format("2006-01-02 15:04:05")
|
||||
logFile := writeRunLog(logsDir, *job, trigger, state, detail, output, now)
|
||||
|
||||
// The runner is now pure with respect to the job: it returns a RunRecord and
|
||||
// lets the caller fold that record into the job's JobRuntime. Run state no
|
||||
// longer lives on Job, so there is nothing on the job to mutate here.
|
||||
return domain.RunRecord{
|
||||
Time: timestamp,
|
||||
JobID: job.ID,
|
||||
JobName: job.Name,
|
||||
Trigger: trigger,
|
||||
State: state,
|
||||
Detail: detail,
|
||||
LogFile: logFile,
|
||||
Output: output,
|
||||
}
|
||||
}
|
||||
|
||||
func startJobOnly(invocation commandInvocation, job domain.Job, started time.Time) (string, string, string) {
|
||||
command := invocation.command
|
||||
if invocation.hideWindow {
|
||||
winproc.ConfigureHiddenWindow(command)
|
||||
}
|
||||
err := command.Start()
|
||||
duration := time.Since(started).Round(time.Millisecond)
|
||||
if err != nil {
|
||||
return "Failed", fmt.Sprintf("%T: %v", err, err), startOnlyOutput(job, 0)
|
||||
}
|
||||
pid := command.Process.Pid
|
||||
if releaseErr := command.Process.Release(); releaseErr != nil {
|
||||
return "Failed", fmt.Sprintf("process started with pid %d, but release failed: %T: %v", pid, releaseErr, releaseErr), startOnlyOutput(job, pid)
|
||||
}
|
||||
return "OK", fmt.Sprintf("Started in %s (pid %d); not waiting for process exit", duration, pid), startOnlyOutput(job, pid)
|
||||
}
|
||||
|
||||
func startOnlyOutput(job domain.Job, pid int) string {
|
||||
var builder strings.Builder
|
||||
builder.WriteString("status:\n")
|
||||
if pid > 0 {
|
||||
builder.WriteString(fmt.Sprintf("Started process pid %d. GoSentry is not waiting for it to exit.\n\n", pid))
|
||||
} else {
|
||||
builder.WriteString("Process did not start.\n\n")
|
||||
}
|
||||
builder.WriteString("command:\n")
|
||||
builder.WriteString(job.Command + "\n\n")
|
||||
builder.WriteString("arguments:\n")
|
||||
builder.WriteString(logArguments(job.Arguments))
|
||||
builder.WriteString("\n\nstart_only:\ntrue")
|
||||
return builder.String()
|
||||
}
|
||||
|
||||
func runStateDetail(err error, runErr error, duration time.Duration, job domain.Job) (string, string) {
|
||||
if err == nil {
|
||||
return "OK", fmt.Sprintf("Completed in %s (exit code 0)", duration)
|
||||
}
|
||||
if errors.Is(runErr, context.DeadlineExceeded) {
|
||||
return "Failed", fmt.Sprintf("Timed out after %s", commandTimeout)
|
||||
}
|
||||
if errors.Is(err, exec.ErrWaitDelay) {
|
||||
return "OK", fmt.Sprintf("Completed; output capture stopped after %s because a child process kept the stream open", commandWaitDelay)
|
||||
}
|
||||
|
||||
var exitError *exec.ExitError
|
||||
if errors.As(err, &exitError) {
|
||||
exitCode := exitError.ExitCode()
|
||||
if acceptedExitCode(exitCode, job.SuccessExitCodes) {
|
||||
return "OK", fmt.Sprintf("Completed in %s with accepted exit code %d", duration, exitCode)
|
||||
}
|
||||
return "Failed", fmt.Sprintf("Exit code %d is not in success_exit_codes (%s)", exitCode, successExitCodesText(job))
|
||||
}
|
||||
return "Failed", fmt.Sprintf("%T: %v", err, err)
|
||||
}
|
||||
|
||||
func formatOutput(stdout string, stderr string) string {
|
||||
stdout = strings.TrimSpace(stdout)
|
||||
stderr = strings.TrimSpace(stderr)
|
||||
if stdout == "" {
|
||||
// Showing an explicit placeholder is clearer than an empty panel in the
|
||||
// GUI: the user can tell that the command ran but produced no stream data.
|
||||
stdout = "<empty>"
|
||||
}
|
||||
if stderr == "" {
|
||||
stderr = "<empty>"
|
||||
}
|
||||
return "stdout:\n" + stdout + "\n\nstderr:\n" + stderr
|
||||
}
|
||||
@@ -1,4 +1,4 @@
|
||||
package core
|
||||
package runner
|
||||
|
||||
import (
|
||||
"context"
|
||||
@@ -7,11 +7,155 @@ import (
|
||||
"runtime"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"gitea.mixdep.ru/mix/gosentry/src/domain"
|
||||
"gitea.mixdep.ru/mix/gosentry/src/platform/winproc"
|
||||
)
|
||||
|
||||
func echoCommand(message string) string {
|
||||
if runtime.GOOS == "windows" {
|
||||
return "echo " + message
|
||||
}
|
||||
return "echo '" + strings.ReplaceAll(message, "'", "'\\''") + "'"
|
||||
}
|
||||
|
||||
func TestRunJobLogFileAllHeaders(t *testing.T) {
|
||||
logsDir := t.TempDir()
|
||||
job := domain.Job{
|
||||
ID: 99,
|
||||
Name: "Log Header Test",
|
||||
Command: echoCommand("header test output"),
|
||||
SuccessExitCodes: "0,1",
|
||||
}
|
||||
|
||||
record := RunJob(context.Background(), &job, "Schedule", logsDir)
|
||||
if record.LogFile == "" {
|
||||
t.Fatal("expected log file to be written")
|
||||
}
|
||||
|
||||
data, err := os.ReadFile(record.LogFile)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
content := string(data)
|
||||
|
||||
for _, want := range []string{
|
||||
"job_id: 99",
|
||||
"job_name: Log Header Test",
|
||||
"trigger: Schedule",
|
||||
"state: OK",
|
||||
"detail: ",
|
||||
"command: " + job.Command,
|
||||
"arguments: <empty>",
|
||||
"success_exit_codes: 0,1",
|
||||
"start_only: false",
|
||||
"stdout:",
|
||||
"stderr:",
|
||||
} {
|
||||
if !strings.Contains(content, want) {
|
||||
t.Errorf("log file missing %q:\n%s", want, content)
|
||||
}
|
||||
}
|
||||
|
||||
// The time header must use the documented format.
|
||||
for _, line := range strings.Split(content, "\n") {
|
||||
if strings.HasPrefix(line, "time: ") {
|
||||
ts := strings.TrimPrefix(line, "time: ")
|
||||
if _, err := time.Parse("2006-01-02 15:04:05", ts); err != nil {
|
||||
t.Errorf("time header %q does not match format 2006-01-02 15:04:05: %v", ts, err)
|
||||
}
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestRunJobRecordFields(t *testing.T) {
|
||||
job := domain.Job{
|
||||
ID: 55,
|
||||
Name: "Record Fields Test",
|
||||
Command: echoCommand("record field check"),
|
||||
}
|
||||
|
||||
record := RunJob(context.Background(), &job, "Schedule", t.TempDir())
|
||||
|
||||
if record.JobID != job.ID {
|
||||
t.Errorf("JobID: got %d, want %d", record.JobID, job.ID)
|
||||
}
|
||||
if record.JobName != job.Name {
|
||||
t.Errorf("JobName: got %q, want %q", record.JobName, job.Name)
|
||||
}
|
||||
if record.Trigger != "Schedule" {
|
||||
t.Errorf("Trigger: got %q, want 'Schedule'", record.Trigger)
|
||||
}
|
||||
if record.State != "OK" {
|
||||
t.Errorf("State: got %q, want 'OK' (detail: %q)", record.State, record.Detail)
|
||||
}
|
||||
if record.LogFile == "" {
|
||||
t.Error("LogFile should be a non-empty path")
|
||||
}
|
||||
if _, err := time.Parse("2006-01-02 15:04:05", record.Time); err != nil {
|
||||
t.Errorf("Time format wrong, got %q: %v", record.Time, err)
|
||||
}
|
||||
if !strings.Contains(record.Output, "stdout:") {
|
||||
t.Errorf("Output missing 'stdout:', got:\n%s", record.Output)
|
||||
}
|
||||
if !strings.Contains(record.Output, "stderr:") {
|
||||
t.Errorf("Output missing 'stderr:', got:\n%s", record.Output)
|
||||
}
|
||||
}
|
||||
|
||||
func TestFormatOutput(t *testing.T) {
|
||||
got := formatOutput("hello world", "some error")
|
||||
want := "stdout:\nhello world\n\nstderr:\nsome error"
|
||||
if got != want {
|
||||
t.Errorf("formatOutput:\ngot: %q\nwant: %q", got, want)
|
||||
}
|
||||
}
|
||||
|
||||
func TestFormatOutputEmptyStreams(t *testing.T) {
|
||||
got := formatOutput("", "")
|
||||
if !strings.Contains(got, "stdout:\n<empty>") {
|
||||
t.Errorf("empty stdout should show <empty>, got:\n%s", got)
|
||||
}
|
||||
if !strings.Contains(got, "stderr:\n<empty>") {
|
||||
t.Errorf("empty stderr should show <empty>, got:\n%s", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestLogArguments(t *testing.T) {
|
||||
cases := []struct{ input, want string }{
|
||||
{"", "<empty>"},
|
||||
{" ", "<empty>"},
|
||||
{"--flag", "--flag"},
|
||||
{"--flag\r\n--value", "--flag\n--value"},
|
||||
{"--flag\n--value", "--flag\n--value"},
|
||||
}
|
||||
for _, tc := range cases {
|
||||
if got := logArguments(tc.input); got != tc.want {
|
||||
t.Errorf("logArguments(%q) = %q, want %q", tc.input, got, tc.want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestSanitizeFileName(t *testing.T) {
|
||||
cases := []struct{ input, want string }{
|
||||
{"Hello Test", "Hello_Test"},
|
||||
{"job-1_ok", "job-1_ok"},
|
||||
{"!!!", "job"},
|
||||
{"", "job"},
|
||||
{"A/B:C", "A_B_C"},
|
||||
}
|
||||
for _, tc := range cases {
|
||||
if got := sanitizeFileName(tc.input); got != tc.want {
|
||||
t.Errorf("sanitizeFileName(%q) = %q, want %q", tc.input, got, tc.want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestRunJobWritesLogFile(t *testing.T) {
|
||||
logsDir := t.TempDir()
|
||||
job := Job{
|
||||
job := domain.Job{
|
||||
ID: 42,
|
||||
Name: "Hello Test",
|
||||
Command: echoCommand("hello from test"),
|
||||
@@ -46,7 +190,7 @@ func TestRunJobRunsQuotedWindowsExecutable(t *testing.T) {
|
||||
}
|
||||
|
||||
logsDir := t.TempDir()
|
||||
job := Job{
|
||||
job := domain.Job{
|
||||
ID: 43,
|
||||
Name: "Quoted Windows Command",
|
||||
Command: `"C:\Windows\System32\cmd.exe" /C echo quoted command ok`,
|
||||
@@ -75,7 +219,7 @@ func TestRunJobRunsUnquotedWindowsProgramPathWithSpaces(t *testing.T) {
|
||||
if err := os.WriteFile(scriptPath, []byte("@echo off\r\necho unquoted command ok\r\n"), 0o755); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
job := Job{
|
||||
job := domain.Job{
|
||||
ID: 44,
|
||||
Name: "Unquoted Windows Command",
|
||||
Command: scriptPath,
|
||||
@@ -96,7 +240,7 @@ func TestRunJobRunsWindowsCommandWithSeparateArguments(t *testing.T) {
|
||||
}
|
||||
|
||||
logsDir := t.TempDir()
|
||||
job := Job{
|
||||
job := domain.Job{
|
||||
ID: 45,
|
||||
Name: "Separate Arguments",
|
||||
Command: `C:\Windows\System32\cmd.exe`,
|
||||
@@ -117,7 +261,7 @@ func TestRunJobAcceptsConfiguredExitCode(t *testing.T) {
|
||||
if runtime.GOOS == "windows" {
|
||||
command = `C:\Windows\System32\cmd.exe`
|
||||
}
|
||||
job := Job{
|
||||
job := domain.Job{
|
||||
ID: 46,
|
||||
Name: "Accepted Exit Code",
|
||||
Command: command,
|
||||
@@ -141,7 +285,7 @@ func TestRunJobRejectsUnconfiguredExitCode(t *testing.T) {
|
||||
if runtime.GOOS == "windows" {
|
||||
command = `C:\Windows\System32\cmd.exe`
|
||||
}
|
||||
job := Job{
|
||||
job := domain.Job{
|
||||
ID: 47,
|
||||
Name: "Rejected Exit Code",
|
||||
Command: command,
|
||||
@@ -167,7 +311,7 @@ func TestRunJobStartOnlyDoesNotWaitForExitCode(t *testing.T) {
|
||||
command = `C:\Windows\System32\cmd.exe`
|
||||
arguments = "/C\nexit /b 7"
|
||||
}
|
||||
job := Job{
|
||||
job := domain.Job{
|
||||
ID: 48,
|
||||
Name: "Start Only",
|
||||
Command: command,
|
||||
@@ -188,7 +332,7 @@ func TestRunJobStartOnlyDoesNotWaitForExitCode(t *testing.T) {
|
||||
}
|
||||
|
||||
func TestRunJobStartOnlyReportsStartFailure(t *testing.T) {
|
||||
job := Job{
|
||||
job := domain.Job{
|
||||
ID: 49,
|
||||
Name: "Missing Start Only",
|
||||
Command: "definitely-missing-gosentry-command",
|
||||
@@ -223,7 +367,7 @@ func TestDirectCommandDoesNotHideWindow(t *testing.T) {
|
||||
t.Skip("Windows window visibility only")
|
||||
}
|
||||
|
||||
invocation := jobInvocation(context.Background(), Job{
|
||||
invocation := jobInvocation(context.Background(), domain.Job{
|
||||
Command: `C:\Windows\System32\cmd.exe`,
|
||||
Arguments: "/C\necho visible direct process",
|
||||
})
|
||||
@@ -237,11 +381,11 @@ func TestShellCommandHidesWindow(t *testing.T) {
|
||||
t.Skip("Windows window visibility only")
|
||||
}
|
||||
|
||||
invocation := jobInvocation(context.Background(), Job{Command: "echo hidden shell process"})
|
||||
invocation := jobInvocation(context.Background(), domain.Job{Command: "echo hidden shell process"})
|
||||
if !invocation.hideWindow {
|
||||
t.Fatal("shell command should request hidden startup window")
|
||||
}
|
||||
configureHiddenWindow(invocation.command)
|
||||
winproc.ConfigureHiddenWindow(invocation.command)
|
||||
if invocation.command.SysProcAttr == nil || !invocation.command.SysProcAttr.HideWindow {
|
||||
t.Fatal("expected shell command to be hidden")
|
||||
}
|
||||
@@ -253,7 +397,7 @@ func TestShellCommandUsesWindowsSafeQuoting(t *testing.T) {
|
||||
}
|
||||
|
||||
command := shellCommand(context.Background(), `"C:\Program Files\FreeFileSync\FreeFileSync.exe" "D:\Local\Programs\FreeFileSync\Jobs\Auto.ffs_batch"`)
|
||||
configureHiddenWindow(command)
|
||||
winproc.ConfigureHiddenWindow(command)
|
||||
|
||||
want := `cmd.exe /S /C ""C:\Program Files\FreeFileSync\FreeFileSync.exe" "D:\Local\Programs\FreeFileSync\Jobs\Auto.ffs_batch""`
|
||||
if command.SysProcAttr == nil {
|
||||
@@ -0,0 +1,55 @@
|
||||
package scheduler
|
||||
|
||||
import "time"
|
||||
|
||||
// Clock supplies the scheduler with the current time and a stream of ticks.
|
||||
// Hiding both behind an interface lets tests drive the loop deterministically —
|
||||
// firing ticks and controlling "now" — instead of waiting on the wall clock.
|
||||
// Production uses RealClock.
|
||||
type Clock interface {
|
||||
// Now returns the current time. It is the value passed to the tick callback
|
||||
// on each tick, so a fake can make due-evaluation deterministic.
|
||||
Now() time.Time
|
||||
// Ticks returns a channel that delivers a value on every scheduler tick. The
|
||||
// scheduler reads it for the lifetime of the loop.
|
||||
Ticks() <-chan time.Time
|
||||
// Stop releases the resources backing Ticks. The scheduler calls it once when
|
||||
// the loop exits.
|
||||
Stop()
|
||||
}
|
||||
|
||||
// RealClock is the production Clock: wall-clock time and a one-second ticker.
|
||||
//
|
||||
// A one-second cadence is accurate enough for cron-style desktop automation —
|
||||
// five-field cron expressions have minute precision, while @every values may be
|
||||
// shorter for testing and lightweight local tasks — and it keeps a single timer
|
||||
// instead of one per job.
|
||||
type RealClock struct {
|
||||
ticker *time.Ticker
|
||||
}
|
||||
|
||||
// NewRealClock returns a real clock. The underlying ticker is created lazily on
|
||||
// the first Ticks call so a clock that is never started leaks nothing.
|
||||
func NewRealClock() *RealClock {
|
||||
return &RealClock{}
|
||||
}
|
||||
|
||||
// Now returns the wall-clock time.
|
||||
func (c *RealClock) Now() time.Time {
|
||||
return time.Now()
|
||||
}
|
||||
|
||||
// Ticks starts (once) and returns the one-second ticker channel.
|
||||
func (c *RealClock) Ticks() <-chan time.Time {
|
||||
if c.ticker == nil {
|
||||
c.ticker = time.NewTicker(time.Second)
|
||||
}
|
||||
return c.ticker.C
|
||||
}
|
||||
|
||||
// Stop halts the ticker if it was ever started.
|
||||
func (c *RealClock) Stop() {
|
||||
if c.ticker != nil {
|
||||
c.ticker.Stop()
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,56 @@
|
||||
package scheduler
|
||||
|
||||
import (
|
||||
"context"
|
||||
"time"
|
||||
)
|
||||
|
||||
// Scheduler is a thin timing loop. It owns no job or runtime state: on every
|
||||
// clock tick it calls the injected tick function with the current time, and that
|
||||
// function — the application service's RunDue — decides what, if anything, to
|
||||
// run. Keeping all state and mutation in the service makes the service the sole
|
||||
// writer (resolving the old shared-*[]Job data race) and reduces the scheduler
|
||||
// to a loop that is trivially testable with a fake Clock.
|
||||
type Scheduler struct {
|
||||
clock Clock
|
||||
tick func(now time.Time)
|
||||
|
||||
ctx context.Context
|
||||
cancel context.CancelFunc
|
||||
}
|
||||
|
||||
// NewScheduler builds a scheduler that calls tick on every Clock tick. The clock
|
||||
// is injected so tests can drive the loop without the wall clock.
|
||||
func NewScheduler(clock Clock, tick func(now time.Time)) *Scheduler {
|
||||
ctx, cancel := context.WithCancel(context.Background())
|
||||
return &Scheduler{
|
||||
clock: clock,
|
||||
tick: tick,
|
||||
ctx: ctx,
|
||||
cancel: cancel,
|
||||
}
|
||||
}
|
||||
|
||||
// Start launches the loop on its own goroutine and returns immediately.
|
||||
func (s *Scheduler) Start() {
|
||||
go func() {
|
||||
ticks := s.clock.Ticks()
|
||||
defer s.clock.Stop()
|
||||
for {
|
||||
select {
|
||||
case <-s.ctx.Done():
|
||||
return
|
||||
case <-ticks:
|
||||
// Pass the clock's notion of "now" rather than the tick value so a
|
||||
// fake clock can control due-evaluation precisely.
|
||||
s.tick(s.clock.Now())
|
||||
}
|
||||
}
|
||||
}()
|
||||
}
|
||||
|
||||
// Stop ends the loop. A tick already in progress finishes; no further ticks are
|
||||
// delivered.
|
||||
func (s *Scheduler) Stop() {
|
||||
s.cancel()
|
||||
}
|
||||
@@ -0,0 +1,85 @@
|
||||
package scheduler
|
||||
|
||||
import (
|
||||
"sync"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
// fakeClock is a Clock whose ticks and "now" are driven by the test instead of
|
||||
// the wall clock, so the scheduler loop can be exercised deterministically.
|
||||
type fakeClock struct {
|
||||
ticks chan time.Time
|
||||
|
||||
mu sync.Mutex
|
||||
now time.Time
|
||||
stopped bool
|
||||
}
|
||||
|
||||
func newFakeClock(now time.Time) *fakeClock {
|
||||
return &fakeClock{ticks: make(chan time.Time, 1), now: now}
|
||||
}
|
||||
|
||||
func (c *fakeClock) Now() time.Time {
|
||||
c.mu.Lock()
|
||||
defer c.mu.Unlock()
|
||||
return c.now
|
||||
}
|
||||
|
||||
func (c *fakeClock) Ticks() <-chan time.Time { return c.ticks }
|
||||
|
||||
func (c *fakeClock) Stop() {
|
||||
c.mu.Lock()
|
||||
c.stopped = true
|
||||
c.mu.Unlock()
|
||||
}
|
||||
|
||||
func (c *fakeClock) isStopped() bool {
|
||||
c.mu.Lock()
|
||||
defer c.mu.Unlock()
|
||||
return c.stopped
|
||||
}
|
||||
|
||||
// fire advances the clock to t and delivers one tick.
|
||||
func (c *fakeClock) fire(t time.Time) {
|
||||
c.mu.Lock()
|
||||
c.now = t
|
||||
c.mu.Unlock()
|
||||
c.ticks <- t
|
||||
}
|
||||
|
||||
func TestSchedulerCallsTickWithClockNow(t *testing.T) {
|
||||
clock := newFakeClock(time.Unix(0, 0))
|
||||
got := make(chan time.Time, 1)
|
||||
s := NewScheduler(clock, func(now time.Time) { got <- now })
|
||||
s.Start()
|
||||
defer s.Stop()
|
||||
|
||||
want := time.Date(2026, 6, 19, 12, 0, 0, 0, time.UTC)
|
||||
clock.fire(want)
|
||||
|
||||
select {
|
||||
case now := <-got:
|
||||
if !now.Equal(want) {
|
||||
t.Errorf("tick now = %v, want %v", now, want)
|
||||
}
|
||||
case <-time.After(time.Second):
|
||||
t.Fatal("scheduler did not call tick after a clock tick")
|
||||
}
|
||||
}
|
||||
|
||||
func TestSchedulerStopReleasesClock(t *testing.T) {
|
||||
clock := newFakeClock(time.Now())
|
||||
s := NewScheduler(clock, func(time.Time) {})
|
||||
s.Start()
|
||||
s.Stop()
|
||||
|
||||
// After Stop the loop exits and releases the clock via the deferred Stop.
|
||||
deadline := time.Now().Add(time.Second)
|
||||
for !clock.isStopped() {
|
||||
if time.Now().After(deadline) {
|
||||
t.Fatal("clock was not stopped after scheduler Stop")
|
||||
}
|
||||
time.Sleep(time.Millisecond)
|
||||
}
|
||||
}
|
||||
@@ -1,4 +1,4 @@
|
||||
package core
|
||||
package storage
|
||||
|
||||
import (
|
||||
"os"
|
||||
@@ -1,4 +1,4 @@
|
||||
package core
|
||||
package storage
|
||||
|
||||
import (
|
||||
"errors"
|
||||
@@ -7,15 +7,16 @@ import (
|
||||
"runtime"
|
||||
"strings"
|
||||
|
||||
"gitea.mixdep.ru/mix/gosentry/src/domain"
|
||||
"go.yaml.in/yaml/v4"
|
||||
)
|
||||
|
||||
type Store struct {
|
||||
Paths Paths
|
||||
Config Config
|
||||
Config domain.Config
|
||||
}
|
||||
|
||||
func OpenStore() (*Store, []Job, error) {
|
||||
func OpenStore() (*Store, []domain.Job, error) {
|
||||
paths, err := ResolvePaths()
|
||||
if err != nil {
|
||||
return nil, nil, err
|
||||
@@ -57,17 +58,17 @@ func (s *Store) SaveConfig() error {
|
||||
return writeYAML(s.Paths.ConfigPath, s.Config)
|
||||
}
|
||||
|
||||
func (s *Store) SaveJobs(jobs []Job) error {
|
||||
func (s *Store) SaveJobs(jobs []domain.Job) error {
|
||||
if err := os.MkdirAll(s.Paths.JobsDir, 0o755); err != nil {
|
||||
return err
|
||||
}
|
||||
return writeYAML(s.Paths.JobsPath, JobsFile{Jobs: jobs})
|
||||
return writeYAML(s.Paths.JobsPath, domain.JobsFile{Jobs: jobs})
|
||||
}
|
||||
|
||||
func loadOrCreateConfig(paths Paths) (Config, error) {
|
||||
func loadOrCreateConfig(paths Paths) (domain.Config, error) {
|
||||
// Defaults favor a portable installation: settings and jobs begin next to the
|
||||
// executable, while logs are grouped under a dedicated subdirectory.
|
||||
config := Config{
|
||||
config := domain.Config{
|
||||
JobsDir: ".",
|
||||
LogsDir: "logs",
|
||||
MaxLogFiles: 100,
|
||||
@@ -98,10 +99,10 @@ func loadOrCreateConfig(paths Paths) (Config, error) {
|
||||
|
||||
data, err := os.ReadFile(configPath)
|
||||
if err != nil {
|
||||
return Config{}, err
|
||||
return domain.Config{}, err
|
||||
}
|
||||
if err := yaml.Unmarshal(data, &config); err != nil {
|
||||
return Config{}, err
|
||||
return domain.Config{}, err
|
||||
}
|
||||
if strings.TrimSpace(config.JobsDir) == "" {
|
||||
// Empty paths are treated as missing values rather than intentional root
|
||||
@@ -120,27 +121,27 @@ func loadOrCreateConfig(paths Paths) (Config, error) {
|
||||
return config, nil
|
||||
}
|
||||
|
||||
func loadOrCreateJobs(path string) ([]Job, error) {
|
||||
func loadOrCreateJobs(path string) ([]domain.Job, error) {
|
||||
if _, err := os.Stat(path); errors.Is(err, os.ErrNotExist) {
|
||||
// The first run creates harmless sample jobs so a new user can immediately
|
||||
// see scheduled and manual execution without inventing a command.
|
||||
jobs := defaultJobs()
|
||||
normalizeJobs(jobs)
|
||||
return jobs, writeYAML(path, JobsFile{Jobs: jobs})
|
||||
return jobs, writeYAML(path, domain.JobsFile{Jobs: jobs})
|
||||
}
|
||||
|
||||
data, err := os.ReadFile(path)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
var file JobsFile
|
||||
var file domain.JobsFile
|
||||
if err := yaml.Unmarshal(data, &file); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return file.Jobs, nil
|
||||
}
|
||||
|
||||
func normalizeJobs(jobs []Job) {
|
||||
func normalizeJobs(jobs []domain.Job) {
|
||||
next := 1
|
||||
for index := range jobs {
|
||||
job := &jobs[index]
|
||||
@@ -168,23 +169,9 @@ func normalizeJobs(jobs []Job) {
|
||||
if job.SuccessExitCodes == "" {
|
||||
job.SuccessExitCodes = "0"
|
||||
}
|
||||
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"
|
||||
}
|
||||
// Runtime fields are reconstructed each time the app starts. Persisted run
|
||||
// records live in log files, not in jobs.yaml, to keep the jobs file easy
|
||||
// to review and edit by hand.
|
||||
job.Logs = nil
|
||||
// Runtime state (last run, next run, status, output, activity) is no longer
|
||||
// part of Job. It is reconstructed each time the app starts via
|
||||
// domain.NewRuntime, so normalizeJobs only touches durable configuration.
|
||||
}
|
||||
}
|
||||
|
||||
@@ -222,8 +209,8 @@ func writeYAML(path string, value any) error {
|
||||
return os.WriteFile(path, data, 0o644)
|
||||
}
|
||||
|
||||
func defaultJobs() []Job {
|
||||
return []Job{
|
||||
func defaultJobs() []domain.Job {
|
||||
return []domain.Job{
|
||||
{
|
||||
ID: 1,
|
||||
Name: "Hello scheduler",
|
||||
@@ -0,0 +1,256 @@
|
||||
package storage
|
||||
|
||||
import (
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"gitea.mixdep.ru/mix/gosentry/src/domain"
|
||||
"go.yaml.in/yaml/v4"
|
||||
)
|
||||
|
||||
func TestJobsRoundTrip(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
path := filepath.Join(dir, "jobs.yaml")
|
||||
|
||||
original := []domain.Job{
|
||||
{
|
||||
ID: 7,
|
||||
Name: "Backup data",
|
||||
Folder: "Maintenance",
|
||||
Schedule: "0 2 * * *",
|
||||
Command: "/usr/bin/backup",
|
||||
Arguments: "--compress\n--verbose",
|
||||
SuccessExitCodes: "0,1",
|
||||
StartOnly: true,
|
||||
Enabled: true,
|
||||
},
|
||||
}
|
||||
|
||||
if err := writeYAML(path, domain.JobsFile{Jobs: original}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
got, err := loadOrCreateJobs(path)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(got) != 1 {
|
||||
t.Fatalf("expected 1 job, got %d", len(got))
|
||||
}
|
||||
|
||||
g, w := got[0], original[0]
|
||||
if g.ID != w.ID {
|
||||
t.Errorf("ID: got %d, want %d", g.ID, w.ID)
|
||||
}
|
||||
if g.Name != w.Name {
|
||||
t.Errorf("Name: got %q, want %q", g.Name, w.Name)
|
||||
}
|
||||
if g.Folder != w.Folder {
|
||||
t.Errorf("Folder: got %q, want %q", g.Folder, w.Folder)
|
||||
}
|
||||
if g.Schedule != w.Schedule {
|
||||
t.Errorf("Schedule: got %q, want %q", g.Schedule, w.Schedule)
|
||||
}
|
||||
if g.Command != w.Command {
|
||||
t.Errorf("Command: got %q, want %q", g.Command, w.Command)
|
||||
}
|
||||
if g.Arguments != w.Arguments {
|
||||
t.Errorf("Arguments: got %q, want %q", g.Arguments, w.Arguments)
|
||||
}
|
||||
if g.SuccessExitCodes != w.SuccessExitCodes {
|
||||
t.Errorf("SuccessExitCodes: got %q, want %q", g.SuccessExitCodes, w.SuccessExitCodes)
|
||||
}
|
||||
if g.StartOnly != w.StartOnly {
|
||||
t.Errorf("StartOnly: got %v, want %v", g.StartOnly, w.StartOnly)
|
||||
}
|
||||
if g.Enabled != w.Enabled {
|
||||
t.Errorf("Enabled: got %v, want %v", g.Enabled, w.Enabled)
|
||||
}
|
||||
|
||||
// Runtime state no longer lives on Job at all (it moved to domain.JobRuntime),
|
||||
// so there is nothing transient that could survive the save→load round-trip.
|
||||
}
|
||||
|
||||
func TestConfigRoundTrip(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
paths := Paths{
|
||||
AppDir: dir,
|
||||
ConfigPath: filepath.Join(dir, ConfigFileName),
|
||||
}
|
||||
|
||||
want := domain.Config{
|
||||
JobsDir: "/custom/jobs",
|
||||
LogsDir: "/custom/logs",
|
||||
MaxLogFiles: 50,
|
||||
MaxLogAgeDays: 14,
|
||||
StartOnLogin: true,
|
||||
KeepRunningInTray: false,
|
||||
NotifyOnFailure: false,
|
||||
}
|
||||
if err := writeYAML(paths.ConfigPath, want); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
got, err := loadOrCreateConfig(paths)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
if got.JobsDir != want.JobsDir {
|
||||
t.Errorf("JobsDir: got %q, want %q", got.JobsDir, want.JobsDir)
|
||||
}
|
||||
if got.LogsDir != want.LogsDir {
|
||||
t.Errorf("LogsDir: got %q, want %q", got.LogsDir, want.LogsDir)
|
||||
}
|
||||
if got.MaxLogFiles != want.MaxLogFiles {
|
||||
t.Errorf("MaxLogFiles: got %d, want %d", got.MaxLogFiles, want.MaxLogFiles)
|
||||
}
|
||||
if got.MaxLogAgeDays != want.MaxLogAgeDays {
|
||||
t.Errorf("MaxLogAgeDays: got %d, want %d", got.MaxLogAgeDays, want.MaxLogAgeDays)
|
||||
}
|
||||
if got.StartOnLogin != want.StartOnLogin {
|
||||
t.Errorf("StartOnLogin: got %v, want %v", got.StartOnLogin, want.StartOnLogin)
|
||||
}
|
||||
if got.KeepRunningInTray != want.KeepRunningInTray {
|
||||
t.Errorf("KeepRunningInTray: got %v, want %v", got.KeepRunningInTray, want.KeepRunningInTray)
|
||||
}
|
||||
if got.NotifyOnFailure != want.NotifyOnFailure {
|
||||
t.Errorf("NotifyOnFailure: got %v, want %v", got.NotifyOnFailure, want.NotifyOnFailure)
|
||||
}
|
||||
}
|
||||
|
||||
func TestNormalizeJobsFillsDefaults(t *testing.T) {
|
||||
jobs := []domain.Job{
|
||||
{Enabled: true},
|
||||
{Enabled: false},
|
||||
{ID: 5, Name: "Kept", Schedule: "*/10 * * * *", SuccessExitCodes: "0,1", Enabled: true},
|
||||
}
|
||||
|
||||
normalizeJobs(jobs)
|
||||
|
||||
// Blank enabled job gets default name, schedule, command, and exit codes.
|
||||
// normalizeJobs only fills durable configuration now; runtime status is built
|
||||
// separately by domain.NewRuntime.
|
||||
if jobs[0].ID != 1 {
|
||||
t.Errorf("first auto ID: got %d, want 1", jobs[0].ID)
|
||||
}
|
||||
if jobs[0].Name != "Untitled job" {
|
||||
t.Errorf("default name: got %q, want 'Untitled job'", jobs[0].Name)
|
||||
}
|
||||
if jobs[0].Schedule != "@every 1m" {
|
||||
t.Errorf("default schedule: got %q, want '@every 1m'", jobs[0].Schedule)
|
||||
}
|
||||
if jobs[0].SuccessExitCodes != "0" {
|
||||
t.Errorf("default exit codes: got %q, want '0'", jobs[0].SuccessExitCodes)
|
||||
}
|
||||
|
||||
// Pre-set fields survive normalization unchanged.
|
||||
if jobs[2].ID != 5 {
|
||||
t.Errorf("pre-set ID should be preserved: got %d, want 5", jobs[2].ID)
|
||||
}
|
||||
if jobs[2].SuccessExitCodes != "0,1" {
|
||||
t.Errorf("pre-set exit codes should be preserved: got %q, want '0,1'", jobs[2].SuccessExitCodes)
|
||||
}
|
||||
}
|
||||
|
||||
// TestLoadOrCreateConfigMigratesFromLegacy verifies that when gosentry.yaml is
|
||||
// absent but pysentry.yaml exists the config is read from the legacy file. This
|
||||
// lets portable installs that still carry a pysentry.yaml start without manual
|
||||
// migration.
|
||||
func TestLoadOrCreateConfigMigratesFromLegacy(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
paths := Paths{
|
||||
AppDir: dir,
|
||||
ConfigPath: filepath.Join(dir, ConfigFileName), // gosentry.yaml — not created
|
||||
}
|
||||
|
||||
legacy := domain.Config{
|
||||
JobsDir: "/legacy/jobs",
|
||||
LogsDir: "/legacy/logs",
|
||||
MaxLogFiles: 77,
|
||||
MaxLogAgeDays: 13,
|
||||
StartOnLogin: true,
|
||||
}
|
||||
if err := writeYAML(filepath.Join(dir, LegacyConfigFileName), legacy); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
got, err := loadOrCreateConfig(paths)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if got.JobsDir != legacy.JobsDir {
|
||||
t.Errorf("JobsDir: got %q, want %q", got.JobsDir, legacy.JobsDir)
|
||||
}
|
||||
if got.LogsDir != legacy.LogsDir {
|
||||
t.Errorf("LogsDir: got %q, want %q", got.LogsDir, legacy.LogsDir)
|
||||
}
|
||||
if got.MaxLogFiles != legacy.MaxLogFiles {
|
||||
t.Errorf("MaxLogFiles: got %d, want %d", got.MaxLogFiles, legacy.MaxLogFiles)
|
||||
}
|
||||
if got.MaxLogAgeDays != legacy.MaxLogAgeDays {
|
||||
t.Errorf("MaxLogAgeDays: got %d, want %d", got.MaxLogAgeDays, legacy.MaxLogAgeDays)
|
||||
}
|
||||
if got.StartOnLogin != legacy.StartOnLogin {
|
||||
t.Errorf("StartOnLogin: got %v, want %v", got.StartOnLogin, legacy.StartOnLogin)
|
||||
}
|
||||
}
|
||||
|
||||
// TestLoadOrCreateConfigCreatesDefaultsOnFirstRun verifies that the first run
|
||||
// (no config files present) writes gosentry.yaml and returns sensible defaults.
|
||||
func TestLoadOrCreateConfigCreatesDefaultsOnFirstRun(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
paths := Paths{
|
||||
AppDir: dir,
|
||||
ConfigPath: filepath.Join(dir, ConfigFileName),
|
||||
}
|
||||
|
||||
got, err := loadOrCreateConfig(paths)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if got.JobsDir != "." {
|
||||
t.Errorf("default JobsDir = %q, want '.'", got.JobsDir)
|
||||
}
|
||||
if got.LogsDir != "logs" {
|
||||
t.Errorf("default LogsDir = %q, want 'logs'", got.LogsDir)
|
||||
}
|
||||
if got.MaxLogFiles != 100 {
|
||||
t.Errorf("default MaxLogFiles = %d, want 100", got.MaxLogFiles)
|
||||
}
|
||||
if got.MaxLogAgeDays != 30 {
|
||||
t.Errorf("default MaxLogAgeDays = %d, want 30", got.MaxLogAgeDays)
|
||||
}
|
||||
// The function must have written the defaults to gosentry.yaml.
|
||||
if _, err := os.Stat(paths.ConfigPath); err != nil {
|
||||
t.Errorf("gosentry.yaml should have been created: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestJobsYAMLDoesNotPersistRuntimeNoise(t *testing.T) {
|
||||
// Job carries only durable configuration; runtime state lives in
|
||||
// domain.JobRuntime and is never marshalled. This guards against a future
|
||||
// runtime field accidentally being added back onto Job with a yaml tag.
|
||||
jobs := []domain.Job{
|
||||
{
|
||||
ID: 1,
|
||||
Name: "Clean job",
|
||||
Schedule: "@every 10s",
|
||||
Command: echoCommand("ok"),
|
||||
Enabled: true,
|
||||
},
|
||||
}
|
||||
|
||||
data, err := yaml.Marshal(domain.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)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,151 @@
|
||||
package ui
|
||||
|
||||
import (
|
||||
"sort"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"gitea.mixdep.ru/mix/gosentry/src/domain"
|
||||
|
||||
"fyne.io/fyne/v2"
|
||||
"fyne.io/fyne/v2/container"
|
||||
"fyne.io/fyne/v2/widget"
|
||||
)
|
||||
|
||||
func newEvent(jobID int, jobName string, state string, detail string) event {
|
||||
// Use the same timestamp shape as command run records so the History tab is
|
||||
// visually consistent across startup, UI actions, manual runs, and schedules.
|
||||
return event{
|
||||
Time: time.Now().Format("2006-01-02 15:04:05"),
|
||||
JobID: jobID,
|
||||
JobName: jobName,
|
||||
Trigger: "UI",
|
||||
State: state,
|
||||
Detail: detail,
|
||||
}
|
||||
}
|
||||
|
||||
func collectActivity(jobs []job, runtimes map[int]*domain.JobRuntime) []event {
|
||||
var events []event
|
||||
for _, current := range jobs {
|
||||
// At startup this is usually empty because jobs.yaml does not persist
|
||||
// runtime logs. The function still centralizes the merge for future
|
||||
// history loading from log metadata.
|
||||
if rt := runtimes[current.ID]; rt != nil {
|
||||
events = append(events, rt.Logs...)
|
||||
}
|
||||
}
|
||||
sort.SliceStable(events, func(left int, right int) bool {
|
||||
return events[left].Time < events[right].Time
|
||||
})
|
||||
return events
|
||||
}
|
||||
|
||||
func newHistoryView(events *[]event) *fyne.Container {
|
||||
descending := false
|
||||
headerText := func(id widget.TableCellID) string {
|
||||
headers := []string{"Time", "Trigger", "Job", "State", "Detail", "Log"}
|
||||
if id.Row < 0 && id.Col == 0 {
|
||||
if descending {
|
||||
return "Time desc"
|
||||
}
|
||||
return "Time asc"
|
||||
}
|
||||
if id.Row < 0 && id.Col >= 0 && id.Col < len(headers) {
|
||||
return headers[id.Col]
|
||||
}
|
||||
return ""
|
||||
}
|
||||
sortedEvents := func() []event {
|
||||
result := append([]event(nil), (*events)...)
|
||||
sort.SliceStable(result, func(left int, right int) bool {
|
||||
if descending {
|
||||
return result[left].Time > result[right].Time
|
||||
}
|
||||
return result[left].Time < result[right].Time
|
||||
})
|
||||
return result
|
||||
}
|
||||
|
||||
table := widget.NewTable(
|
||||
func() (int, int) {
|
||||
return len(*events), 6
|
||||
},
|
||||
func() fyne.CanvasObject {
|
||||
label := widget.NewLabel("")
|
||||
label.Wrapping = fyne.TextTruncate
|
||||
return label
|
||||
},
|
||||
func(id widget.TableCellID, item fyne.CanvasObject) {
|
||||
label := item.(*widget.Label)
|
||||
label.SetText(historyCellText(id, sortedEvents()))
|
||||
label.TextStyle = fyne.TextStyle{}
|
||||
label.Refresh()
|
||||
},
|
||||
)
|
||||
table.ShowHeaderRow = true
|
||||
table.CreateHeader = func() fyne.CanvasObject {
|
||||
label := widget.NewLabel("")
|
||||
label.Wrapping = fyne.TextTruncate
|
||||
return label
|
||||
}
|
||||
table.UpdateHeader = func(id widget.TableCellID, item fyne.CanvasObject) {
|
||||
label := item.(*widget.Label)
|
||||
label.SetText(headerText(id))
|
||||
label.TextStyle = fyne.TextStyle{Bold: true}
|
||||
label.Refresh()
|
||||
}
|
||||
table.OnSelected = func(id widget.TableCellID) {
|
||||
if id.Row < 0 && id.Col == 0 {
|
||||
descending = !descending
|
||||
table.Refresh()
|
||||
}
|
||||
table.Unselect(id)
|
||||
}
|
||||
table.SetColumnWidth(0, 150)
|
||||
table.SetColumnWidth(1, 90)
|
||||
table.SetColumnWidth(2, 170)
|
||||
table.SetColumnWidth(3, 90)
|
||||
table.SetColumnWidth(4, 260)
|
||||
table.SetColumnWidth(5, 240)
|
||||
return container.NewPadded(table)
|
||||
}
|
||||
|
||||
func historyCellText(id widget.TableCellID, events []event) string {
|
||||
if id.Row < 0 || id.Row >= len(events) {
|
||||
return ""
|
||||
}
|
||||
current := events[id.Row]
|
||||
trigger := current.Trigger
|
||||
if trigger == "" {
|
||||
trigger = "Unknown"
|
||||
}
|
||||
switch id.Col {
|
||||
case 0:
|
||||
return current.Time
|
||||
case 1:
|
||||
return trigger
|
||||
case 2:
|
||||
return current.JobName
|
||||
case 3:
|
||||
return current.State
|
||||
case 4:
|
||||
return current.Detail
|
||||
case 5:
|
||||
return logFileName(current.LogFile)
|
||||
default:
|
||||
return ""
|
||||
}
|
||||
}
|
||||
|
||||
func logFileName(path string) string {
|
||||
path = strings.TrimSpace(path)
|
||||
if path == "" {
|
||||
return ""
|
||||
}
|
||||
path = strings.ReplaceAll(path, "\\", "/")
|
||||
if slash := strings.LastIndex(path, "/"); slash >= 0 {
|
||||
return path[slash+1:]
|
||||
}
|
||||
return path
|
||||
}
|
||||
@@ -0,0 +1,90 @@
|
||||
package ui
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"strings"
|
||||
|
||||
"gitea.mixdep.ru/mix/gosentry/src/app"
|
||||
"gitea.mixdep.ru/mix/gosentry/src/domain"
|
||||
|
||||
"fyne.io/fyne/v2"
|
||||
"fyne.io/fyne/v2/dialog"
|
||||
"fyne.io/fyne/v2/widget"
|
||||
)
|
||||
|
||||
// showJobDialog opens a create/edit form for a single job. onSave is called
|
||||
// with the populated job only when the user clicks Save and all fields pass
|
||||
// validation.
|
||||
func showJobDialog(w fyne.Window, title string, current job, onSave func(job)) {
|
||||
name := widget.NewEntry()
|
||||
name.SetPlaceHolder("Nightly backup")
|
||||
name.SetText(current.Name)
|
||||
folderEntry := widget.NewEntry()
|
||||
folderEntry.SetPlaceHolder("Maintenance")
|
||||
folderEntry.SetText(current.Folder)
|
||||
scheduleEntry := widget.NewEntry()
|
||||
scheduleEntry.SetPlaceHolder("@every 1m")
|
||||
scheduleEntry.SetText(current.Schedule)
|
||||
commandEntry := widget.NewEntry()
|
||||
commandEntry.SetPlaceHolder(`C:\Program Files\App\App.exe`)
|
||||
commandEntry.SetText(current.Command)
|
||||
argumentsEntry := widget.NewMultiLineEntry()
|
||||
argumentsEntry.SetPlaceHolder(`D:\Local\Jobs\Auto.ffs_batch`)
|
||||
argumentsEntry.SetText(current.Arguments)
|
||||
successExitCodesEntry := widget.NewEntry()
|
||||
successExitCodesEntry.SetPlaceHolder("0")
|
||||
successExitCodesEntry.SetText(app.DisplaySuccessExitCodes(current.SuccessExitCodes))
|
||||
startOnly := widget.NewCheck("Start only, do not wait for exit", nil)
|
||||
startOnly.SetChecked(current.StartOnly)
|
||||
enabled := widget.NewCheck("Enabled", nil)
|
||||
enabled.SetChecked(current.Enabled)
|
||||
|
||||
form := dialog.NewForm(
|
||||
title,
|
||||
"Save",
|
||||
"Cancel",
|
||||
[]*widget.FormItem{
|
||||
widget.NewFormItem("Name", name),
|
||||
widget.NewFormItem("Folder", folderEntry),
|
||||
widget.NewFormItem("Schedule", scheduleEntry),
|
||||
widget.NewFormItem("Command", commandEntry),
|
||||
widget.NewFormItem("Arguments", argumentsEntry),
|
||||
widget.NewFormItem("Success exit codes", successExitCodesEntry),
|
||||
widget.NewFormItem("", startOnly),
|
||||
widget.NewFormItem("", enabled),
|
||||
},
|
||||
func(saved bool) {
|
||||
if !saved {
|
||||
return
|
||||
}
|
||||
if strings.TrimSpace(name.Text) == "" || strings.TrimSpace(scheduleEntry.Text) == "" || strings.TrimSpace(commandEntry.Text) == "" {
|
||||
// These three fields are the minimum executable job definition.
|
||||
// Folder is optional because ungrouped jobs are a supported workflow.
|
||||
dialog.ShowError(fmt.Errorf("name, schedule, and command are required"), w)
|
||||
return
|
||||
}
|
||||
if err := domain.Validate(strings.TrimSpace(scheduleEntry.Text)); err != nil {
|
||||
dialog.ShowError(fmt.Errorf("invalid schedule: %w", err), w)
|
||||
return
|
||||
}
|
||||
current.Name = strings.TrimSpace(name.Text)
|
||||
current.Folder = strings.TrimSpace(folderEntry.Text)
|
||||
current.Schedule = strings.TrimSpace(scheduleEntry.Text)
|
||||
current.Command = strings.TrimSpace(commandEntry.Text)
|
||||
current.Arguments = strings.TrimSpace(argumentsEntry.Text)
|
||||
current.SuccessExitCodes = strings.TrimSpace(successExitCodesEntry.Text)
|
||||
if current.SuccessExitCodes == "" {
|
||||
current.SuccessExitCodes = "0"
|
||||
}
|
||||
current.StartOnly = startOnly.Checked
|
||||
current.Enabled = enabled.Checked
|
||||
// The dialog only edits durable configuration. Runtime status is
|
||||
// initialized (new jobs) or updated (edits) by the caller against the
|
||||
// runtime map, keyed by job ID.
|
||||
onSave(current)
|
||||
},
|
||||
w,
|
||||
)
|
||||
form.Resize(fyne.NewSize(640, 460))
|
||||
form.Show()
|
||||
}
|
||||
@@ -0,0 +1,415 @@
|
||||
package ui
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"strings"
|
||||
|
||||
"gitea.mixdep.ru/mix/gosentry/src/app"
|
||||
"gitea.mixdep.ru/mix/gosentry/src/domain"
|
||||
|
||||
"fyne.io/fyne/v2"
|
||||
"fyne.io/fyne/v2/container"
|
||||
"fyne.io/fyne/v2/dialog"
|
||||
"fyne.io/fyne/v2/layout"
|
||||
"fyne.io/fyne/v2/theme"
|
||||
"fyne.io/fyne/v2/widget"
|
||||
)
|
||||
|
||||
const allFolders = "All"
|
||||
const noFolder = "No folder"
|
||||
const minJobsSidebarWidth float32 = 480
|
||||
|
||||
// newJobsView builds the Jobs tab: list sidebar, details panel, and toolbar.
|
||||
// It returns the assembled panel and a refresh function the caller invokes
|
||||
// whenever the service state may have changed (e.g., from the event subscriber
|
||||
// in mainwindow.go). The refresh function re-reads the service snapshot and
|
||||
// redraws all widgets in the jobs view; it does NOT touch history or settings.
|
||||
func newJobsView(w fyne.Window, svc *app.Service) (fyne.CanvasObject, func()) {
|
||||
jobs := svc.Jobs()
|
||||
runtimes := make(map[int]*domain.JobRuntime, len(jobs))
|
||||
syncFromService := func() {
|
||||
jobs = svc.Jobs()
|
||||
for id := range runtimes {
|
||||
delete(runtimes, id)
|
||||
}
|
||||
for _, current := range jobs {
|
||||
if rt := svc.Runtime(current.ID); rt != nil {
|
||||
runtimes[current.ID] = rt
|
||||
}
|
||||
}
|
||||
}
|
||||
syncFromService()
|
||||
runtimeFor := func(index int) *domain.JobRuntime {
|
||||
if index < 0 || index >= len(jobs) {
|
||||
return &domain.JobRuntime{}
|
||||
}
|
||||
if rt := runtimes[jobs[index].ID]; rt != nil {
|
||||
return rt
|
||||
}
|
||||
return &domain.JobRuntime{}
|
||||
}
|
||||
|
||||
selected := 0
|
||||
selectedFolder := allFolders
|
||||
schedulerPaused := false
|
||||
filteredJobs := filteredJobIndexes(jobs, selectedFolder)
|
||||
|
||||
title := widget.NewLabelWithStyle(jobs[selected].Name, fyne.TextAlignLeading, fyne.TextStyle{Bold: true})
|
||||
title.Wrapping = fyne.TextWrapBreak
|
||||
folderLabel := newJobDetailLabel(jobs[selected].Folder)
|
||||
scheduleLabel := newJobDetailLabel(jobs[selected].Schedule)
|
||||
commandLabel := newJobDetailLabel(jobs[selected].Command)
|
||||
argumentsLabel := newJobDetailLabel(jobs[selected].Arguments)
|
||||
successExitCodesLabel := newJobDetailLabel(app.DisplaySuccessExitCodes(jobs[selected].SuccessExitCodes))
|
||||
runModeLabel := newJobDetailLabel(app.DisplayRunMode(jobs[selected]))
|
||||
selectedRuntime := runtimeFor(selected)
|
||||
lastRunLabel := newJobDetailLabel(selectedRuntime.LastRun)
|
||||
nextRunLabel := newJobDetailLabel(selectedRuntime.NextRun)
|
||||
stateLabel := newJobDetailLabel(selectedRuntime.LastState)
|
||||
schedulerState := widget.NewLabel("Scheduler running")
|
||||
commandOutput := widget.NewTextGrid()
|
||||
commandOutput.SetText(selectedRuntime.Output)
|
||||
commandOutputScroll := container.NewScroll(commandOutput)
|
||||
// Command output can contain long lines and preserved whitespace. TextGrid is
|
||||
// used instead of Label so stdout/stderr remains readable and does not vanish
|
||||
// against the theme when it is placed inside a scroll container.
|
||||
commandOutputScroll.SetMinSize(fyne.NewSize(520, 160))
|
||||
|
||||
selectedLogs := append([]event(nil), selectedRuntime.Logs...)
|
||||
jobLogs := widget.NewList(
|
||||
func() int { return len(selectedLogs) },
|
||||
func() fyne.CanvasObject { return widget.NewLabel("log") },
|
||||
func(id widget.ListItemID, item fyne.CanvasObject) {
|
||||
item.(*widget.Label).SetText(app.EventText(selectedLogs[id]))
|
||||
},
|
||||
)
|
||||
|
||||
updateDetails := func(index int) {
|
||||
if index < 0 || index >= len(jobs) {
|
||||
// A folder filter can temporarily leave no selectable rows. Clearing
|
||||
// the details panel avoids showing stale information for a hidden job.
|
||||
title.SetText("No job selected")
|
||||
folderLabel.SetText("")
|
||||
scheduleLabel.SetText("")
|
||||
commandLabel.SetText("")
|
||||
argumentsLabel.SetText("")
|
||||
successExitCodesLabel.SetText("")
|
||||
runModeLabel.SetText("")
|
||||
lastRunLabel.SetText("")
|
||||
nextRunLabel.SetText("")
|
||||
stateLabel.SetText("")
|
||||
commandOutput.SetText("")
|
||||
selectedLogs = nil
|
||||
return
|
||||
}
|
||||
selected = index
|
||||
current := jobs[selected]
|
||||
rt := runtimeFor(selected)
|
||||
title.SetText(current.Name)
|
||||
folderLabel.SetText(app.DisplayFolder(current.Folder))
|
||||
scheduleLabel.SetText(current.Schedule)
|
||||
commandLabel.SetText(current.Command)
|
||||
argumentsLabel.SetText(app.DisplayArguments(current.Arguments))
|
||||
successExitCodesLabel.SetText(app.DisplaySuccessExitCodes(current.SuccessExitCodes))
|
||||
runModeLabel.SetText(app.DisplayRunMode(current))
|
||||
lastRunLabel.SetText(rt.LastRun)
|
||||
nextRunLabel.SetText(rt.NextRun)
|
||||
stateLabel.SetText(rt.LastState)
|
||||
commandOutput.SetText(rt.Output)
|
||||
selectedLogs = append(selectedLogs[:0], rt.Logs...)
|
||||
}
|
||||
|
||||
// list and folderSelect are declared early so closures below can reference
|
||||
// them before the widget.NewList / widget.NewSelect calls assign the values.
|
||||
var list *widget.List
|
||||
var folderSelect *widget.Select
|
||||
|
||||
refreshView := func() {
|
||||
syncFromService()
|
||||
filteredJobs = filteredJobIndexes(jobs, selectedFolder)
|
||||
updateDetails(selected)
|
||||
jobLogs.Refresh()
|
||||
if list != nil {
|
||||
list.Refresh()
|
||||
}
|
||||
}
|
||||
|
||||
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)
|
||||
// Keep each row compact: folder, schedule, and command are shown in one
|
||||
// metadata line so the left pane stays useful even with many jobs.
|
||||
meta.SetText(app.DisplayFolder(current.Folder) + " " + current.Schedule + " " + app.DisplayInvocation(current))
|
||||
status.SetText(app.StatusText(current, runtimes[current.ID]))
|
||||
},
|
||||
)
|
||||
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 {
|
||||
// The "No folder" filter is intentionally allowed to be empty. It is a
|
||||
// real filter choice, not an error state, so the selection is cleared.
|
||||
selected = -1
|
||||
updateDetails(-1)
|
||||
return
|
||||
}
|
||||
selected = filteredJobs[0]
|
||||
list.Select(0)
|
||||
refreshView()
|
||||
})
|
||||
folderSelect.SetSelected(selectedFolder)
|
||||
|
||||
addButton := widget.NewButtonWithIcon("New job", theme.ContentAddIcon(), func() {
|
||||
showJobDialog(w, "New job", job{Schedule: "@every 1m", Command: "echo GoSentry job ran", Enabled: true}, func(saved job) {
|
||||
// The Service assigns the ID, stores the job, records the "Created"
|
||||
// activity, and emits events. The observer appends those to History; we
|
||||
// only refresh the snapshot and move the selection to the new job.
|
||||
created, err := svc.CreateJob(saved)
|
||||
if err != nil {
|
||||
dialog.ShowError(err, w)
|
||||
return
|
||||
}
|
||||
syncFromService()
|
||||
folderSelect.Options = folderOptions(jobs)
|
||||
folderSelect.Refresh()
|
||||
targetFolder := filterValue(created.Folder)
|
||||
if selectedFolder != allFolders && selectedFolder != targetFolder {
|
||||
selectedFolder = targetFolder
|
||||
folderSelect.SetSelected(targetFolder)
|
||||
}
|
||||
selected = indexOfID(jobs, created.ID)
|
||||
filteredJobs = filteredJobIndexes(jobs, selectedFolder)
|
||||
list.Refresh()
|
||||
list.Select(app.DisplayIndex(filteredJobs, selected))
|
||||
refreshView()
|
||||
})
|
||||
})
|
||||
editButton := widget.NewButtonWithIcon("Edit", theme.DocumentCreateIcon(), func() {
|
||||
if selected < 0 || selected >= len(jobs) {
|
||||
return
|
||||
}
|
||||
showJobDialog(w, "Edit job", jobs[selected], func(saved job) {
|
||||
// The job keeps its ID, so the Service preserves the runtime (keyed by
|
||||
// ID), reflects any enabled/disabled change, recomputes the next run, and
|
||||
// emits the "Updated" activity the observer records.
|
||||
saved.ID = jobs[selected].ID
|
||||
if err := svc.UpdateJob(saved); err != nil {
|
||||
dialog.ShowError(err, w)
|
||||
return
|
||||
}
|
||||
syncFromService()
|
||||
folderSelect.Options = folderOptions(jobs)
|
||||
folderSelect.Refresh()
|
||||
list.Refresh()
|
||||
refreshView()
|
||||
})
|
||||
})
|
||||
runButton := widget.NewButtonWithIcon("Run now", theme.MediaPlayIcon(), func() {
|
||||
if selected < 0 || selected >= len(jobs) {
|
||||
return
|
||||
}
|
||||
if schedulerPaused {
|
||||
// The global pause is treated as an emergency stop for all execution,
|
||||
// including manual "Run now", so the user has one reliable switch.
|
||||
dialog.ShowInformation("Scheduler paused", "Global pause is active. Resume the scheduler before running jobs.", w)
|
||||
return
|
||||
}
|
||||
// RunNow refuses an already-running job (it returns an error); the UI has
|
||||
// always ignored that case silently, so the run simply does not start.
|
||||
if err := svc.RunNow(jobs[selected].ID); err != nil {
|
||||
return
|
||||
}
|
||||
list.Refresh()
|
||||
refreshView()
|
||||
})
|
||||
stopAllButton := widget.NewButtonWithIcon("Pause all", theme.MediaStopIcon(), nil)
|
||||
stopAllButton.OnTapped = func() {
|
||||
// SetGlobalPause flips the Service's pause flag, updates every job's
|
||||
// next-run text, and emits the activity record the observer logs. Mirror the
|
||||
// new state into the local flag and the controls; revert it if the save fails.
|
||||
schedulerPaused = !schedulerPaused
|
||||
if err := svc.SetGlobalPause(schedulerPaused); err != nil {
|
||||
schedulerPaused = !schedulerPaused
|
||||
dialog.ShowError(err, w)
|
||||
return
|
||||
}
|
||||
if schedulerPaused {
|
||||
schedulerState.SetText("Scheduler paused")
|
||||
stopAllButton.SetText("Resume all")
|
||||
stopAllButton.SetIcon(theme.MediaPlayIcon())
|
||||
} else {
|
||||
schedulerState.SetText("Scheduler running")
|
||||
stopAllButton.SetText("Pause all")
|
||||
stopAllButton.SetIcon(theme.MediaStopIcon())
|
||||
}
|
||||
list.Refresh()
|
||||
refreshView()
|
||||
}
|
||||
pauseButton := widget.NewButtonWithIcon("Pause", theme.MediaPauseIcon(), func() {
|
||||
if selected < 0 || selected >= len(jobs) {
|
||||
return
|
||||
}
|
||||
// SetEnabled toggles the job, updates its runtime/next-run, and records the
|
||||
// "Resumed"/"Paused" activity the observer logs.
|
||||
current := jobs[selected]
|
||||
if err := svc.SetEnabled(current.ID, !current.Enabled); err != nil {
|
||||
dialog.ShowError(err, w)
|
||||
return
|
||||
}
|
||||
syncFromService()
|
||||
list.Refresh()
|
||||
refreshView()
|
||||
})
|
||||
deleteButton := widget.NewButtonWithIcon("Delete", theme.DeleteIcon(), func() {
|
||||
if selected < 0 || selected >= len(jobs) {
|
||||
return
|
||||
}
|
||||
deleted := jobs[selected]
|
||||
// Deletion is confirmed because jobs can represent real system actions.
|
||||
// There is no undo yet, so accidental removal should require one more click.
|
||||
dialog.ShowConfirm("Delete job", fmt.Sprintf("Delete %q?", deleted.Name), func(confirm bool) {
|
||||
if !confirm {
|
||||
return
|
||||
}
|
||||
// The Service removes the job and its runtime, persists, and records the
|
||||
// "Deleted" activity the observer logs; the UI re-reads the snapshot and
|
||||
// fixes up the folder filter and selection.
|
||||
if err := svc.DeleteJob(deleted.ID); err != nil {
|
||||
dialog.ShowError(err, w)
|
||||
return
|
||||
}
|
||||
syncFromService()
|
||||
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]
|
||||
}
|
||||
list.Refresh()
|
||||
if selected >= 0 {
|
||||
list.Select(app.DisplayIndex(filteredJobs, selected))
|
||||
}
|
||||
refreshView()
|
||||
}, 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", folderLabel),
|
||||
detailRow("Schedule", scheduleLabel),
|
||||
detailRow("Command", commandLabel),
|
||||
detailRow("Arguments", argumentsLabel),
|
||||
detailRow("Success exit codes", successExitCodesLabel),
|
||||
detailRow("Run mode", runModeLabel),
|
||||
detailRow("Last run", lastRunLabel),
|
||||
detailRow("Next run", nextRunLabel),
|
||||
detailRow("State", stateLabel),
|
||||
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,
|
||||
)
|
||||
|
||||
fixedSidebar := container.New(minWidthLayout{width: minJobsSidebarWidth}, sidebar)
|
||||
panel := container.NewBorder(nil, nil, fixedSidebar, nil, container.NewPadded(details))
|
||||
return panel, refreshView
|
||||
}
|
||||
|
||||
|
||||
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 {
|
||||
// "All" and "No folder" are always present so the filter UI is stable even
|
||||
// before the user creates folders.
|
||||
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 indexOfID(jobs []job, id int) int {
|
||||
for index, current := range jobs {
|
||||
if current.ID == id {
|
||||
return index
|
||||
}
|
||||
}
|
||||
return 0
|
||||
}
|
||||
|
||||
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 newJobDetailLabel(text string) *widget.Label {
|
||||
label := widget.NewLabel(text)
|
||||
// Job names, commands, and paths can be much wider than the details panel.
|
||||
// Breaking long runs of text keeps Label.MinSize stable when the selection
|
||||
// changes, so the right panel does not force the whole window to resize.
|
||||
label.Wrapping = fyne.TextWrapBreak
|
||||
return label
|
||||
}
|
||||
@@ -0,0 +1,95 @@
|
||||
package ui
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"gitea.mixdep.ru/mix/gosentry/src/domain"
|
||||
)
|
||||
|
||||
func TestFilterValue(t *testing.T) {
|
||||
cases := []struct{ input, want string }{
|
||||
{"", noFolder},
|
||||
{" ", noFolder},
|
||||
{"Maintenance", "Maintenance"},
|
||||
{" Reports ", "Reports"},
|
||||
}
|
||||
for _, tc := range cases {
|
||||
if got := filterValue(tc.input); got != tc.want {
|
||||
t.Errorf("filterValue(%q) = %q, want %q", tc.input, got, tc.want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestFolderOptionsAlwaysIncludesSentinels(t *testing.T) {
|
||||
opts := folderOptions(nil)
|
||||
if len(opts) < 2 || opts[0] != allFolders || opts[1] != noFolder {
|
||||
t.Errorf("folderOptions(nil) = %v, want [%q %q ...]", opts, allFolders, noFolder)
|
||||
}
|
||||
}
|
||||
|
||||
func TestFolderOptionsAppendsUniqueFolders(t *testing.T) {
|
||||
jobs := []domain.Job{
|
||||
{Folder: "Maintenance"},
|
||||
{Folder: ""}, // no folder → not a named folder
|
||||
{Folder: " Backups "}, // trimmed to "Backups"
|
||||
{Folder: "Maintenance"}, // duplicate → not added again
|
||||
}
|
||||
opts := folderOptions(jobs)
|
||||
// Expected: All, No folder, Maintenance, Backups — 4 entries, no duplicates.
|
||||
if len(opts) != 4 {
|
||||
t.Errorf("expected 4 options, got %v", opts)
|
||||
}
|
||||
has := map[string]bool{}
|
||||
for _, o := range opts {
|
||||
has[o] = true
|
||||
}
|
||||
for _, want := range []string{allFolders, noFolder, "Maintenance", "Backups"} {
|
||||
if !has[want] {
|
||||
t.Errorf("expected option %q in %v", want, opts)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestFilteredJobIndexesAll(t *testing.T) {
|
||||
jobs := []domain.Job{
|
||||
{Folder: "Maintenance"},
|
||||
{Folder: ""},
|
||||
{Folder: "Reports"},
|
||||
}
|
||||
got := filteredJobIndexes(jobs, allFolders)
|
||||
if len(got) != 3 {
|
||||
t.Errorf("allFolders filter: got %d indexes, want 3", len(got))
|
||||
}
|
||||
}
|
||||
|
||||
func TestFilteredJobIndexesByNamedFolder(t *testing.T) {
|
||||
jobs := []domain.Job{
|
||||
{Folder: "Maintenance"}, // index 0
|
||||
{Folder: ""}, // index 1
|
||||
{Folder: "Maintenance"}, // index 2
|
||||
{Folder: "Reports"}, // index 3
|
||||
}
|
||||
got := filteredJobIndexes(jobs, "Maintenance")
|
||||
if len(got) != 2 || got[0] != 0 || got[1] != 2 {
|
||||
t.Errorf("Maintenance filter: got %v, want [0 2]", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestFilteredJobIndexesNoFolder(t *testing.T) {
|
||||
jobs := []domain.Job{
|
||||
{Folder: "Maintenance"}, // index 0 — excluded
|
||||
{Folder: ""}, // index 1 — no folder → included
|
||||
{Folder: " "}, // index 2 — blank → included
|
||||
}
|
||||
got := filteredJobIndexes(jobs, noFolder)
|
||||
if len(got) != 2 || got[0] != 1 || got[1] != 2 {
|
||||
t.Errorf("noFolder filter: got %v, want [1 2]", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestFilteredJobIndexesEmptySlice(t *testing.T) {
|
||||
got := filteredJobIndexes(nil, allFolders)
|
||||
if len(got) != 0 {
|
||||
t.Errorf("empty job list should return empty indexes, got %v", got)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,37 @@
|
||||
package ui
|
||||
|
||||
import (
|
||||
"fyne.io/fyne/v2"
|
||||
)
|
||||
|
||||
type minWidthLayout struct {
|
||||
width float32
|
||||
}
|
||||
|
||||
func (l minWidthLayout) MinSize(objects []fyne.CanvasObject) fyne.Size {
|
||||
width := l.width
|
||||
var height float32
|
||||
for _, object := range objects {
|
||||
if !object.Visible() {
|
||||
continue
|
||||
}
|
||||
min := object.MinSize()
|
||||
if min.Width > width {
|
||||
width = min.Width
|
||||
}
|
||||
if min.Height > height {
|
||||
height = min.Height
|
||||
}
|
||||
}
|
||||
return fyne.NewSize(width, height)
|
||||
}
|
||||
|
||||
func (l minWidthLayout) Layout(objects []fyne.CanvasObject, size fyne.Size) {
|
||||
for _, object := range objects {
|
||||
if !object.Visible() {
|
||||
continue
|
||||
}
|
||||
object.Move(fyne.NewPos(0, 0))
|
||||
object.Resize(size)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,92 @@
|
||||
package ui
|
||||
|
||||
import (
|
||||
"time"
|
||||
|
||||
"gitea.mixdep.ru/mix/gosentry/assets"
|
||||
"gitea.mixdep.ru/mix/gosentry/src/app"
|
||||
"gitea.mixdep.ru/mix/gosentry/src/domain"
|
||||
|
||||
"fyne.io/fyne/v2"
|
||||
"fyne.io/fyne/v2/container"
|
||||
"fyne.io/fyne/v2/theme"
|
||||
"fyne.io/fyne/v2/widget"
|
||||
)
|
||||
|
||||
// The UI package aliases domain types to keep widget callbacks short. The actual
|
||||
// durable model still lives in src/domain, so UI code does not define a second
|
||||
// copy of the scheduler data.
|
||||
type job = domain.Job
|
||||
type event = domain.RunRecord
|
||||
|
||||
func newMainView(w fyne.Window) (fyne.CanvasObject, func(time.Duration, bool)) {
|
||||
svc, err := app.Open()
|
||||
if err != nil {
|
||||
return container.NewPadded(widget.NewLabel("Failed to load GoSentry configuration: " + err.Error())), func(time.Duration, bool) {}
|
||||
}
|
||||
svc.InstallDesktopIcon(appID, assets.IconBytes())
|
||||
|
||||
// Build the initial event history from the current runtime state. Jobs and
|
||||
// runtimes are read here only for this one-time initialization; the jobs view
|
||||
// owns all subsequent state via its own syncFromService closure.
|
||||
initialJobs := svc.Jobs()
|
||||
initialRuntimes := make(map[int]*domain.JobRuntime, len(initialJobs))
|
||||
for _, j := range initialJobs {
|
||||
if rt := svc.Runtime(j.ID); rt != nil {
|
||||
initialRuntimes[j.ID] = rt
|
||||
}
|
||||
}
|
||||
events := collectActivity(initialJobs, initialRuntimes)
|
||||
|
||||
jobsPanel, refreshJobsView := newJobsView(w, svc)
|
||||
|
||||
history := newHistoryView(&events)
|
||||
recordStartup := func(duration time.Duration, windowShown bool) {
|
||||
// Startup is recorded as an in-memory History event instead of being
|
||||
// persisted into jobs.yaml. It is session diagnostics, not durable job
|
||||
// state, and keeping it ephemeral avoids polluting the human-editable YAML
|
||||
// file with process-lifetime bookkeeping.
|
||||
detail := "Window shown in " + duration.Round(time.Millisecond).String()
|
||||
if !windowShown {
|
||||
detail = "Started in tray in " + duration.Round(time.Millisecond).String()
|
||||
}
|
||||
events = append(events, newEvent(0, "Application", "Started", detail))
|
||||
history.Refresh()
|
||||
}
|
||||
|
||||
refresh := func() {
|
||||
refreshJobsView()
|
||||
history.Refresh()
|
||||
}
|
||||
|
||||
// The Service announces every change through events. This single listener is
|
||||
// where the UI reacts: it appends run/activity records to History and redraws.
|
||||
// Events fire from two contexts — UI button handlers call into the Service
|
||||
// synchronously (main goroutine), while scheduled and manual run completions
|
||||
// emit from the run goroutine. fyne.Do marshals all of this widget work onto
|
||||
// the main thread in both cases, so the engine never mutates Fyne state off
|
||||
// the UI thread. This is the sole place events touch widgets. (Resolves #4.)
|
||||
svc.Subscribe(app.ObserverFunc(func(ev app.Event) {
|
||||
recorded, isRecorded := ev.(app.RunRecorded)
|
||||
errOccurred, isError := ev.(app.ErrorOccurred)
|
||||
fyne.Do(func() {
|
||||
if isRecorded {
|
||||
events = append(events, recorded.Record)
|
||||
}
|
||||
if isError {
|
||||
events = append(events, newEvent(0, "Service", "Error", errOccurred.Err.Error()))
|
||||
}
|
||||
refresh()
|
||||
})
|
||||
}))
|
||||
svc.Start()
|
||||
|
||||
tabs := container.NewAppTabs(
|
||||
container.NewTabItemWithIcon("Jobs", theme.ListIcon(), jobsPanel),
|
||||
container.NewTabItemWithIcon("History", theme.HistoryIcon(), history),
|
||||
container.NewTabItemWithIcon("Settings", theme.SettingsIcon(), settingsView(w, svc)),
|
||||
)
|
||||
tabs.SetTabLocation(container.TabLocationTop)
|
||||
|
||||
return tabs, recordStartup
|
||||
}
|
||||
@@ -0,0 +1,60 @@
|
||||
package ui
|
||||
|
||||
import (
|
||||
"time"
|
||||
|
||||
"gitea.mixdep.ru/mix/gosentry/assets"
|
||||
"gitea.mixdep.ru/mix/gosentry/src/app"
|
||||
|
||||
"fyne.io/fyne/v2"
|
||||
fyneapp "fyne.io/fyne/v2/app"
|
||||
)
|
||||
|
||||
const appID = "ru.mixdep.gosentry.desktop"
|
||||
|
||||
// Run is the application entry point. It owns the process lifecycle — single
|
||||
// instance arbitration, Fyne app + window construction, tray wiring, and the
|
||||
// startup-timing record — and delegates all view construction to newMainView in
|
||||
// mainwindow.go. Keeping lifecycle here and the view there is the run.go /
|
||||
// mainwindow.go split introduced in T4.1.
|
||||
func Run(startInTray bool) {
|
||||
started := time.Now()
|
||||
instanceListener, primary := acquireSingleInstance(!startInTray)
|
||||
if !primary {
|
||||
return
|
||||
}
|
||||
if instanceListener != nil {
|
||||
defer instanceListener.Close()
|
||||
}
|
||||
|
||||
// A stable app ID lets Fyne persist desktop preferences consistently across
|
||||
// launches and gives tray/window integration a predictable identity.
|
||||
a := fyneapp.NewWithID(appID)
|
||||
a.SetIcon(loadAppIcon())
|
||||
|
||||
w := a.NewWindow("GoSentry " + app.Version)
|
||||
configureSystemTray(a, w)
|
||||
w.Resize(fyne.NewSize(1120, 720))
|
||||
content, recordStartup := newMainView(w)
|
||||
w.SetContent(content)
|
||||
serveSingleInstance(instanceListener, w)
|
||||
if startInTray {
|
||||
// Autostart launches intentionally stay hidden, so "window shown" would be
|
||||
// a misleading metric. Record a separate startup event for the tray path
|
||||
// instead of forcing one timing definition onto two different UX flows.
|
||||
recordStartup(time.Since(started), false)
|
||||
a.Run()
|
||||
return
|
||||
}
|
||||
// Show the window before recording startup time. Measuring earlier, during
|
||||
// widget construction, looked cheaper in History than the user-perceived
|
||||
// startup really was. The current point is less abstract: it ends when the
|
||||
// window has actually been handed to the desktop for display.
|
||||
w.Show()
|
||||
recordStartup(time.Since(started), true)
|
||||
a.Run()
|
||||
}
|
||||
|
||||
func loadAppIcon() fyne.Resource {
|
||||
return assets.Icon()
|
||||
}
|
||||
@@ -0,0 +1,182 @@
|
||||
package ui
|
||||
|
||||
import (
|
||||
"net/url"
|
||||
"runtime"
|
||||
"runtime/debug"
|
||||
"strconv"
|
||||
"strings"
|
||||
|
||||
"gitea.mixdep.ru/mix/gosentry/src/app"
|
||||
|
||||
"fyne.io/fyne/v2"
|
||||
"fyne.io/fyne/v2/container"
|
||||
"fyne.io/fyne/v2/dialog"
|
||||
"fyne.io/fyne/v2/theme"
|
||||
"fyne.io/fyne/v2/widget"
|
||||
)
|
||||
|
||||
const settingsLabelWidth float32 = 140
|
||||
const settingsControlWidth float32 = 330
|
||||
const settingsStatusWidth float32 = 280
|
||||
const projectRepositoryURL = "https://gitea.mixdep.ru/mix/gosentry"
|
||||
|
||||
func settingsView(w fyne.Window, svc *app.Service) fyne.CanvasObject {
|
||||
store := svc.Store()
|
||||
startOnLogin := widget.NewCheck("Start on login", nil)
|
||||
startOnLogin.SetChecked(store.Config.StartOnLogin)
|
||||
autostartStatus := widget.NewLabel("")
|
||||
refreshAutostartStatus := func() {
|
||||
ok, message := svc.AutostartStatus()
|
||||
if ok {
|
||||
autostartStatus.SetText("OK: " + message)
|
||||
return
|
||||
}
|
||||
autostartStatus.SetText("Problem: " + message)
|
||||
}
|
||||
startOnLogin.OnChanged = func(bool) {
|
||||
if startOnLogin.Checked != store.Config.StartOnLogin {
|
||||
autostartStatus.SetText("Pending: save settings to apply")
|
||||
return
|
||||
}
|
||||
refreshAutostartStatus()
|
||||
}
|
||||
refreshAutostartStatus()
|
||||
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
|
||||
}
|
||||
if strings.TrimSpace(jobsDir.Text) == "" {
|
||||
settingsStatus.SetText("Jobs directory is required")
|
||||
return
|
||||
}
|
||||
if strings.TrimSpace(logsDir.Text) == "" {
|
||||
settingsStatus.SetText("Logs directory is required")
|
||||
return
|
||||
}
|
||||
// Build the new config from the form and hand it to the Service, which
|
||||
// validates it, persists config and jobs to the (possibly new) directory,
|
||||
// and runs log cleanup so tightened retention limits take effect at once.
|
||||
config := store.Config
|
||||
config.JobsDir = strings.TrimSpace(jobsDir.Text)
|
||||
config.LogsDir = strings.TrimSpace(logsDir.Text)
|
||||
config.MaxLogFiles = files
|
||||
config.MaxLogAgeDays = days
|
||||
config.StartOnLogin = startOnLogin.Checked
|
||||
config.KeepRunningInTray = minimizeToTray.Checked
|
||||
config.NotifyOnFailure = notifications.Checked
|
||||
if err := svc.UpdateSettings(config); err != nil {
|
||||
settingsStatus.SetText("Save failed: " + err.Error())
|
||||
return
|
||||
}
|
||||
if err := svc.ApplyAutostart(); err != nil {
|
||||
refreshAutostartStatus()
|
||||
settingsStatus.SetText("Saved, autostart failed: " + err.Error())
|
||||
return
|
||||
}
|
||||
refreshAutostartStatus()
|
||||
settingsStatus.SetText("Saved")
|
||||
})
|
||||
|
||||
return container.NewPadded(container.NewVBox(
|
||||
widget.NewLabelWithStyle("Application", fyne.TextAlignLeading, fyne.TextStyle{Bold: true}),
|
||||
settingsRowWithStatus("Autostart", startOnLogin, autostartStatus),
|
||||
settingsRow("Tray", container.New(minWidthLayout{width: settingsControlWidth}, minimizeToTray)),
|
||||
settingsRow("Notifications", container.New(minWidthLayout{width: settingsControlWidth}, notifications)),
|
||||
widget.NewSeparator(),
|
||||
widget.NewLabelWithStyle("Storage", fyne.TextAlignLeading, fyne.TextStyle{Bold: true}),
|
||||
settingsRow("Config YAML", widget.NewLabel(store.Paths.ConfigPath)),
|
||||
settingsRow("Jobs directory", container.NewBorder(nil, nil, nil, jobsDirBrowse, jobsDir)),
|
||||
settingsRow("Logs directory", container.NewBorder(nil, nil, nil, logsDirBrowse, logsDir)),
|
||||
settingsRow("Max log files", maxLogFiles),
|
||||
settingsRow("Max log age days", maxLogAgeDays),
|
||||
saveSettings,
|
||||
settingsStatus,
|
||||
widget.NewSeparator(),
|
||||
widget.NewLabelWithStyle("About", fyne.TextAlignLeading, fyne.TextStyle{Bold: true}),
|
||||
settingsRow("GoSentry", widget.NewLabel(app.Version)),
|
||||
settingsRow("Go", widget.NewLabel(runtime.Version())),
|
||||
settingsRow("Fyne", widget.NewLabel(fyneVersion())),
|
||||
settingsRow("Repository", widget.NewHyperlink(projectRepositoryURL, mustParseURL(projectRepositoryURL))),
|
||||
))
|
||||
}
|
||||
|
||||
func fyneVersion() string {
|
||||
info, ok := debug.ReadBuildInfo()
|
||||
if !ok {
|
||||
return "unknown"
|
||||
}
|
||||
for _, dependency := range info.Deps {
|
||||
if dependency.Path == "fyne.io/fyne/v2" {
|
||||
if dependency.Replace != nil && dependency.Replace.Version != "" {
|
||||
return dependency.Replace.Version
|
||||
}
|
||||
if dependency.Version != "" {
|
||||
return dependency.Version
|
||||
}
|
||||
return "local"
|
||||
}
|
||||
}
|
||||
return "unknown"
|
||||
}
|
||||
|
||||
func mustParseURL(raw string) *url.URL {
|
||||
parsed, err := url.Parse(raw)
|
||||
if err != nil {
|
||||
return &url.URL{}
|
||||
}
|
||||
return parsed
|
||||
}
|
||||
|
||||
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)
|
||||
// The default folder picker can be cramped on Windows. A larger size makes
|
||||
// long paths readable and avoids forcing the user to resize it every time.
|
||||
folderDialog.Resize(fyne.NewSize(900, 640))
|
||||
folderDialog.Show()
|
||||
}
|
||||
|
||||
func settingsRow(label string, value fyne.CanvasObject) fyne.CanvasObject {
|
||||
caption := widget.NewLabelWithStyle(label, fyne.TextAlignLeading, fyne.TextStyle{Bold: true})
|
||||
caption.Wrapping = fyne.TextTruncate
|
||||
captionBox := container.New(minWidthLayout{width: settingsLabelWidth}, caption)
|
||||
return container.NewBorder(nil, nil, captionBox, nil, value)
|
||||
}
|
||||
|
||||
func settingsRowWithStatus(label string, value fyne.CanvasObject, status fyne.CanvasObject) fyne.CanvasObject {
|
||||
valueBox := container.New(minWidthLayout{width: settingsControlWidth}, value)
|
||||
statusBox := container.New(minWidthLayout{width: settingsStatusWidth}, status)
|
||||
return settingsRow(label, container.NewBorder(nil, nil, valueBox, nil, statusBox))
|
||||
}
|
||||
@@ -0,0 +1,64 @@
|
||||
package ui
|
||||
|
||||
import (
|
||||
"io"
|
||||
"net"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"fyne.io/fyne/v2"
|
||||
)
|
||||
|
||||
const singleInstanceAddress = "127.0.0.1:37653"
|
||||
const singleInstanceShowCommand = "show"
|
||||
|
||||
func acquireSingleInstance(showExisting bool) (net.Listener, bool) {
|
||||
listener, err := net.Listen("tcp", singleInstanceAddress)
|
||||
if err == nil {
|
||||
return listener, true
|
||||
}
|
||||
|
||||
connection, dialErr := net.DialTimeout("tcp", singleInstanceAddress, time.Second)
|
||||
if dialErr == nil {
|
||||
// The first instance listens only on localhost and understands one tiny
|
||||
// command: "show". That keeps the implementation dependency-free and easy
|
||||
// to inspect, which matters more here than introducing a named-pipe or
|
||||
// platform-specific IPC abstraction just to focus an existing window.
|
||||
if showExisting {
|
||||
_, _ = io.WriteString(connection, singleInstanceShowCommand)
|
||||
}
|
||||
_ = connection.Close()
|
||||
return nil, false
|
||||
}
|
||||
|
||||
// If the port is unavailable but does not answer as GoSentry, continue
|
||||
// startup instead of making the application impossible to open because of an
|
||||
// unrelated local listener. In the normal duplicate-start case the dial above
|
||||
// succeeds and this process exits after waking the first instance.
|
||||
return nil, true
|
||||
}
|
||||
|
||||
func serveSingleInstance(listener net.Listener, w fyne.Window) {
|
||||
if listener == nil {
|
||||
return
|
||||
}
|
||||
go func() {
|
||||
for {
|
||||
connection, err := listener.Accept()
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
command, _ := io.ReadAll(io.LimitReader(connection, 32))
|
||||
_ = connection.Close()
|
||||
if strings.TrimSpace(string(command)) != singleInstanceShowCommand {
|
||||
continue
|
||||
}
|
||||
// Accept runs on its own goroutine, so focusing the window must be
|
||||
// marshaled onto the main thread like every other widget update.
|
||||
fyne.Do(func() {
|
||||
w.Show()
|
||||
w.RequestFocus()
|
||||
})
|
||||
}
|
||||
}()
|
||||
}
|
||||
@@ -0,0 +1,40 @@
|
||||
package ui
|
||||
|
||||
import (
|
||||
"fyne.io/fyne/v2"
|
||||
fynedesktop "fyne.io/fyne/v2/driver/desktop"
|
||||
)
|
||||
|
||||
func configureSystemTray(a fyne.App, w fyne.Window) {
|
||||
desk, ok := a.(fynedesktop.App)
|
||||
if !ok {
|
||||
// Not every Fyne driver exposes desktop tray features. Returning silently
|
||||
// keeps the same binary usable on platforms or sessions without a tray.
|
||||
return
|
||||
}
|
||||
|
||||
// IsQuit marks this as the tray's quit item. Without it Fyne's
|
||||
// addMissingQuitForMenu appends a second, localized Quit (e.g. "Выход" on a
|
||||
// Russian system) because it only recognizes an existing quit by matching the
|
||||
// localized label — which our literal "Quit" does not. Setting IsQuit makes
|
||||
// Fyne reuse this item instead of adding a duplicate, regardless of locale.
|
||||
quit := fyne.NewMenuItem("Quit", func() {
|
||||
a.Quit()
|
||||
})
|
||||
quit.IsQuit = true
|
||||
menu := fyne.NewMenu("GoSentry",
|
||||
fyne.NewMenuItem("Show", func() {
|
||||
w.Show()
|
||||
w.RequestFocus()
|
||||
}),
|
||||
fyne.NewMenuItemSeparator(),
|
||||
quit,
|
||||
)
|
||||
desk.SetSystemTrayMenu(menu)
|
||||
w.SetCloseIntercept(func() {
|
||||
// Closing hides the window instead of quitting because scheduler tools are
|
||||
// expected to keep working in the background. The explicit Quit tray item
|
||||
// remains the way to stop the process.
|
||||
w.Hide()
|
||||
})
|
||||
}
|
||||
Reference in New Issue
Block a user