Compare commits

...

9 Commits

Author SHA1 Message Date
mixeme 80d13d0ac6 app/operations_test.go: persist-pause + rebuilt-service tests (T4.4)
TestSetGlobalPausePersistsToConfigFile reads gosentry.json back from disk
after each SetGlobalPause call and asserts the Paused flag is correctly
written and cleared. TestServiceRebuiltFromPausedStoreStartsPaused builds
a second Service from the already-updated store and confirms both RunDue
and RunNow are refused while the service is paused.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-24 22:21:24 +03:00
mixeme 7e868d6f5b ui/jobs_view: init pause controls from persisted state (T4.3)
schedulerPaused, the scheduler-state label, and the Pause-all/Resume-all
button now read Config.Paused at construction time so they correctly reflect
a paused install on restart instead of always starting in the running state.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-24 22:18:36 +03:00
mixeme b9505e83ab domain/config.go + app: persist global pause state across restarts (T4.1, T4.2)
Add Config.Paused bool so the scheduler's paused flag survives a restart.
SetGlobalPause now writes the flag into store.Config and calls SaveConfig;
NewService seeds s.paused from Config.Paused before computing first next-run
times, so jobs show "Scheduler paused" immediately at startup when paused.
Also add Paused to the yamlConfig shadow struct to keep the direct conversion
from domain.Config valid until the YAML import path is dropped in T6.3.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-24 22:16:43 +03:00
mixeme b0d9883627 app/run_test.go: per-job overlap policy override tests (T3.4)
Adds three tests for the effective-policy resolution:
- per-job "queue" overrides global "skip" (job marked Pending, re-runs)
- per-job "skip" overrides global "queue" (overlap dropped, no re-run)
- empty Job.OverlapPolicy inherits the global default

Also ticks Phase 3 complete in docs/RELEASE-0.10-TASKS.md.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-24 21:33:06 +03:00
mixeme 554ce2b93a ui/app: per-job overlap policy in job dialog and details panel
- job_dialog.go: add overlap-policy widget.Select with "(Use global
  default)" → empty, "skip", and "queue" options; pre-selects the job's
  current value; on save maps the inherit label back to empty string.
- app/format.go: add DisplayOverlapPolicy(job, globalPolicy) — returns
  the policy name when overridden, else "skip/queue (global default)".
- ui/jobs_view.go: add "Overlap policy" detail row updated in
  updateDetails using DisplayOverlapPolicy.
- settings_view.go: rename "Overlap policy" label to "Default overlap
  policy" to clarify it is the global default jobs can override.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-24 21:05:50 +03:00
mixeme 3f3b977fb0 app/run.go: resolve effective overlap policy per job in RunDue
RunDue now resolves each job's effective overlap policy via
effectiveOverlapPolicy (job value when set, else Config.OverlapPolicy)
instead of reading the global policy once per tick. An empty
Job.OverlapPolicy inherits the global default, so normalizeJobs continues
to leave the field untouched rather than backfilling it.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-24 20:58:51 +03:00
mixeme 8f75318e8c domain/job.go: add OverlapPolicy field for per-job run policy
Adds OverlapPolicy field to Job struct with json:"overlap_policy,omitempty"
tag. When empty, the field signals that the job should inherit the global
overlap policy setting from Config. Also updates yamlJob shadow struct to
maintain field-layout equivalence for YAML→JSON import compatibility.

Co-Authored-By: Claude Haiku 4.5 <noreply@anthropic.com>
2026-06-24 20:57:00 +03:00
mixeme ed81443d29 ui/history_view: size Log column to content so file names aren't truncated
The Log column was fixed at 240px with TextTruncate, clipping log file
names like 20260601-100000_<JobName>.log. Measure the widest value and
size the column to fit (bounded 240–520px), recomputing on refresh so
newly recorded events widen the column instead of being cut off.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-24 20:54:23 +03:00
mixeme cf276fc8ec ui/jobs_view: cap Selected job activity to 3 recent rows, anchor at bottom
Show only the latest 3 activity entries for the selected job instead of the
full in-memory list, and pin the panel to the bottom of the details pane via a
Border so the command output absorbs the remaining vertical space.

Adds fixedHeightLayout (mirroring minWidthLayout) to reserve a stable height
for the activity list. The full per-job history (maxJobLogs) is untouched and
still available in the History view.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-24 20:36:41 +03:00
16 changed files with 408 additions and 38 deletions
+4 -4
View File
@@ -97,10 +97,10 @@ Done first because both share a compact, single-line record formatter.
- [x] T2.7 — stats tests - [x] T2.7 — stats tests
### Phase 3 — Per-job run policy ### Phase 3 — Per-job run policy
- [ ] T3.1 — `Job.OverlapPolicy` field - [x] T3.1 — `Job.OverlapPolicy` field
- [ ] T3.2 — effective-policy dispatch + inherit - [x] T3.2 — effective-policy dispatch + inherit
- [ ] T3.3 — dialog select + settings/format wording - [x] T3.3 — dialog select + settings/format wording
- [ ] T3.4 — per-job override tests - [x] T3.4 — per-job override tests
### Phase 4 — Persist global pause state ### Phase 4 — Persist global pause state
- [ ] T4.1 — `Config.Paused` field - [ ] T4.1 — `Config.Paused` field
+10
View File
@@ -92,6 +92,16 @@ func DisplayStats(rt *domain.JobRuntime) string {
rt.RunCount, rt.FailCount, rt.LastDurationMS, rt.AvgDurationMS, rt.MaxDurationMS) rt.RunCount, rt.FailCount, rt.LastDurationMS, rt.AvgDurationMS, rt.MaxDurationMS)
} }
// DisplayOverlapPolicy formats a job's effective overlap policy for the details
// panel. When the job has its own policy it is shown as-is; when empty (inherit
// global), the global default is shown with "(global default)" appended.
func DisplayOverlapPolicy(job domain.Job, globalPolicy domain.OverlapPolicy) string {
if p := domain.OverlapPolicy(strings.TrimSpace(job.OverlapPolicy)); p != "" {
return string(p)
}
return string(globalPolicy) + " (global default)"
}
// DisplayIndex returns the position of jobIndex in the given slice of indexes, // DisplayIndex returns the position of jobIndex in the given slice of indexes,
// or 0 if not found. // or 0 if not found.
func DisplayIndex(indexes []int, jobIndex int) int { func DisplayIndex(indexes []int, jobIndex int) int {
+5 -1
View File
@@ -152,13 +152,17 @@ func (s *Service) SetEnabled(id int, enabled bool) error {
func (s *Service) SetGlobalPause(paused bool) error { func (s *Service) SetGlobalPause(paused bool) error {
s.mu.Lock() s.mu.Lock()
s.paused = paused s.paused = paused
s.store.Config.Paused = paused
now := time.Now() now := time.Now()
for index := range s.jobs { for index := range s.jobs {
job := &s.jobs[index] job := &s.jobs[index]
runtime := s.runtimeForLocked(job) runtime := s.runtimeForLocked(job)
s.refreshNextRunFromLocked(job, runtime, now) s.refreshNextRunFromLocked(job, runtime, now)
} }
err := s.store.SaveJobs(s.jobs) err := s.store.SaveConfig()
if err == nil {
err = s.store.SaveJobs(s.jobs)
}
s.mu.Unlock() s.mu.Unlock()
state, detail := "Resumed", "All job execution resumed" state, detail := "Resumed", "All job execution resumed"
+72
View File
@@ -2,6 +2,8 @@ package app
import ( import (
"context" "context"
"encoding/json"
"os"
"path/filepath" "path/filepath"
"sync/atomic" "sync/atomic"
"testing" "testing"
@@ -534,3 +536,73 @@ func TestPrependLogCapsActivityList(t *testing.T) {
t.Errorf("activity list len = %d, want capped at %d", len(runtime.Logs), maxJobLogs) t.Errorf("activity list len = %d, want capped at %d", len(runtime.Logs), maxJobLogs)
} }
} }
func TestSetGlobalPausePersistsToConfigFile(t *testing.T) {
svc := newTempService(t, nil)
if err := svc.SetGlobalPause(true); err != nil {
t.Fatalf("SetGlobalPause: %v", err)
}
data, err := os.ReadFile(svc.store.Paths.ConfigPath)
if err != nil {
t.Fatalf("reading config file: %v", err)
}
var cfg domain.Config
if err := json.Unmarshal(data, &cfg); err != nil {
t.Fatalf("unmarshalling config: %v", err)
}
if !cfg.Paused {
t.Error("persisted config does not have Paused=true after SetGlobalPause(true)")
}
// Resuming clears the flag on disk.
if err := svc.SetGlobalPause(false); err != nil {
t.Fatalf("SetGlobalPause(false): %v", err)
}
data, err = os.ReadFile(svc.store.Paths.ConfigPath)
if err != nil {
t.Fatalf("reading config file after resume: %v", err)
}
var cfg2 domain.Config
if err := json.Unmarshal(data, &cfg2); err != nil {
t.Fatalf("unmarshalling config after resume: %v", err)
}
if cfg2.Paused {
t.Error("persisted config still has Paused=true after SetGlobalPause(false)")
}
}
func TestServiceRebuiltFromPausedStoreStartsPaused(t *testing.T) {
jobs := []domain.Job{{ID: 1, Name: "A", Schedule: "@every 1m", Command: "echo", Enabled: true}}
svc := newTempService(t, jobs)
if err := svc.SetGlobalPause(true); err != nil {
t.Fatalf("SetGlobalPause: %v", err)
}
// Simulate a restart: NewService reads Config.Paused from the store that
// SetGlobalPause already updated (both in memory and on disk).
svc2 := NewService(svc.store, svc.Jobs())
var ran int32
svc2.runJob = func(context.Context, *domain.Job, string, string) domain.RunRecord {
atomic.AddInt32(&ran, 1)
return domain.RunRecord{}
}
// RunDue must not start any job while paused.
svc2.RunDue(time.Now().Add(2 * time.Minute))
time.Sleep(50 * time.Millisecond)
if atomic.LoadInt32(&ran) != 0 {
t.Error("RunDue ran a job on a service rebuilt from a paused store")
}
// RunNow must be refused.
if err := svc2.RunNow(1); err == nil {
t.Error("RunNow should be refused on a service rebuilt from a paused store")
}
if atomic.LoadInt32(&ran) != 0 {
t.Error("runner was invoked despite global pause")
}
}
+14 -3
View File
@@ -67,7 +67,6 @@ func (s *Service) RunDue(now time.Time) {
var startErr error var startErr error
if !s.paused { if !s.paused {
sequential := s.store.Config.ExecutionMode == domain.ExecutionModeSequential sequential := s.store.Config.ExecutionMode == domain.ExecutionModeSequential
queue := s.store.Config.OverlapPolicy == domain.OverlapPolicyQueue
running := s.anyRunningLocked() running := s.anyRunningLocked()
for index := range s.jobs { for index := range s.jobs {
job := &s.jobs[index] job := &s.jobs[index]
@@ -77,8 +76,9 @@ func (s *Service) RunDue(now time.Time) {
} }
if runtime.LastState == "Running" { if runtime.LastState == "Running" {
// The job came due again while its own run is still in flight. // The job came due again while its own run is still in flight.
// Apply the overlap policy and step past this occurrence. // Apply the effective overlap policy and step past this
if queue { // occurrence.
if s.effectiveOverlapPolicy(job) == domain.OverlapPolicyQueue {
runtime.Pending = true runtime.Pending = true
} }
s.advanceNextDueLocked(job, runtime, now) s.advanceNextDueLocked(job, runtime, now)
@@ -165,6 +165,17 @@ func (s *Service) executeRun(ctx context.Context, jobCopy domain.Job, trigger st
s.emit(JobChanged{JobID: jobCopy.ID}) s.emit(JobChanged{JobID: jobCopy.ID})
} }
// effectiveOverlapPolicy resolves the overlap policy that actually governs a
// job: the job's own value when set, otherwise the global Config default. An
// empty Job.OverlapPolicy means "inherit the global default", which is why
// normalizeJobs leaves it empty rather than backfilling the configured value.
func (s *Service) effectiveOverlapPolicy(job *domain.Job) domain.OverlapPolicy {
if policy := domain.OverlapPolicy(strings.TrimSpace(job.OverlapPolicy)); policy != "" {
return policy
}
return s.store.Config.OverlapPolicy
}
// anyRunningLocked reports whether any loaded job is currently in the "Running" // anyRunningLocked reports whether any loaded job is currently in the "Running"
// state. It backs the sequential-mode guards in RunNow and RunDue. The caller // state. It backs the sequential-mode guards in RunNow and RunDue. The caller
// must hold mu. // must hold mu.
+141
View File
@@ -286,6 +286,147 @@ func TestRunDueQueueRerunsAfterFinish(t *testing.T) {
} }
} }
// TestRunDuePerJobQueueOverridesGlobalSkip verifies that a job carrying its own
// "queue" policy queues a re-run even though the global default is "skip": the
// effective policy is resolved per job, so the job-level value wins.
func TestRunDuePerJobQueueOverridesGlobalSkip(t *testing.T) {
svc := newQueueService(t, domain.ExecutionModeParallel, domain.OverlapPolicySkip, []domain.Job{
{ID: 1, Name: "A", Schedule: "@every 1h", Command: "echo", Enabled: true, OverlapPolicy: string(domain.OverlapPolicyQueue)},
})
entered := make(chan int, 2)
release := make(chan struct{})
var calls int32
svc.runJob = func(_ context.Context, job *domain.Job, _ string, _ string) domain.RunRecord {
atomic.AddInt32(&calls, 1)
entered <- job.ID
<-release
return domain.RunRecord{Time: "t", JobID: job.ID, JobName: job.Name, State: "Success"}
}
done := completions(svc)
primeDue(t, svc, 1)
svc.RunDue(time.Now())
if id := <-entered; id != 1 {
t.Fatalf("started job = %d, want 1", id)
}
// Re-due the running job and tick. Despite the global "skip", the job's own
// "queue" policy must mark it Pending.
primeDue(t, svc, 1)
svc.RunDue(time.Now())
expectNoEntry(t, entered)
svc.mu.Lock()
pending := svc.runtimes[1].Pending
svc.mu.Unlock()
if !pending {
t.Fatal("per-job queue policy must mark the job Pending despite global skip")
}
// Releasing the first run lets executeRun start the deferred re-run.
close(release)
waitRecord(t, done)
if id := <-entered; id != 1 {
t.Fatalf("re-run job = %d, want 1", id)
}
waitRecord(t, done)
if got := atomic.LoadInt32(&calls); got != 2 {
t.Errorf("runner called %d time(s), want 2 (original + queued re-run)", got)
}
}
// TestRunDuePerJobSkipOverridesGlobalQueue verifies the reverse override: a job
// carrying its own "skip" policy drops an overlapping run even though the global
// default is "queue".
func TestRunDuePerJobSkipOverridesGlobalQueue(t *testing.T) {
svc := newQueueService(t, domain.ExecutionModeParallel, domain.OverlapPolicyQueue, []domain.Job{
{ID: 1, Name: "A", Schedule: "@every 1h", Command: "echo", Enabled: true, OverlapPolicy: string(domain.OverlapPolicySkip)},
})
entered := make(chan int, 2)
release := make(chan struct{})
var calls int32
svc.runJob = func(_ context.Context, job *domain.Job, _ string, _ string) domain.RunRecord {
atomic.AddInt32(&calls, 1)
entered <- job.ID
<-release
return domain.RunRecord{Time: "t", JobID: job.ID, JobName: job.Name, State: "Success"}
}
done := completions(svc)
primeDue(t, svc, 1)
svc.RunDue(time.Now())
if id := <-entered; id != 1 {
t.Fatalf("started job = %d, want 1", id)
}
// Re-due the running job and tick. Despite the global "queue", the job's own
// "skip" policy must drop it without marking Pending.
primeDue(t, svc, 1)
svc.RunDue(time.Now())
expectNoEntry(t, entered)
svc.mu.Lock()
pending := svc.runtimes[1].Pending
svc.mu.Unlock()
if pending {
t.Error("per-job skip policy must not mark the job Pending despite global queue")
}
close(release)
waitRecord(t, done)
expectNoEntry(t, entered)
if got := atomic.LoadInt32(&calls); got != 1 {
t.Errorf("runner called %d time(s), want 1", got)
}
}
// TestRunDueEmptyOverlapInheritsGlobal verifies that a job with no own policy
// inherits the global default: with global "queue" and an empty Job.OverlapPolicy
// the job queues a re-run.
func TestRunDueEmptyOverlapInheritsGlobal(t *testing.T) {
svc := newQueueService(t, domain.ExecutionModeParallel, domain.OverlapPolicyQueue, []domain.Job{
{ID: 1, Name: "A", Schedule: "@every 1h", Command: "echo", Enabled: true},
})
if svc.jobs[0].OverlapPolicy != "" {
t.Fatalf("test setup: job OverlapPolicy = %q, want empty (inherit)", svc.jobs[0].OverlapPolicy)
}
entered := make(chan int, 2)
release := make(chan struct{})
svc.runJob = func(_ context.Context, job *domain.Job, _ string, _ string) domain.RunRecord {
entered <- job.ID
<-release
return domain.RunRecord{Time: "t", JobID: job.ID, JobName: job.Name, State: "Success"}
}
done := completions(svc)
primeDue(t, svc, 1)
svc.RunDue(time.Now())
if id := <-entered; id != 1 {
t.Fatalf("started job = %d, want 1", id)
}
primeDue(t, svc, 1)
svc.RunDue(time.Now())
expectNoEntry(t, entered)
svc.mu.Lock()
pending := svc.runtimes[1].Pending
svc.mu.Unlock()
if !pending {
t.Fatal("empty per-job policy must inherit the global queue and mark Pending")
}
close(release)
waitRecord(t, done)
if id := <-entered; id != 1 {
t.Fatalf("inherited-queue re-run job = %d, want 1", id)
}
waitRecord(t, done)
}
// TestRunNowSequentialGuard verifies the sequential-mode guard in RunNow: a manual // TestRunNowSequentialGuard verifies the sequential-mode guard in RunNow: a manual
// run is refused while another job is running, and allowed once nothing is. // run is refused while another job is running, and allowed once nothing is.
func TestRunNowSequentialGuard(t *testing.T) { func TestRunNowSequentialGuard(t *testing.T) {
+1
View File
@@ -82,6 +82,7 @@ func NewService(store *storage.Store, jobs []domain.Job) *Service {
schedules: make(map[int]domain.Schedule, len(jobs)), schedules: make(map[int]domain.Schedule, len(jobs)),
runJob: runner.RunJob, runJob: runner.RunJob,
ctx: context.Background(), ctx: context.Background(),
paused: store.Config.Paused,
} }
// Parse every schedule once, then compute each job's first next-run so the // 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 // Service is ready to schedule the moment it exists — mirroring the old
+1
View File
@@ -40,6 +40,7 @@ type Config struct {
NotifyOnFailure bool `json:"notify_on_failure,omitempty"` NotifyOnFailure bool `json:"notify_on_failure,omitempty"`
ExecutionMode ExecutionMode `json:"execution_mode,omitempty"` ExecutionMode ExecutionMode `json:"execution_mode,omitempty"`
OverlapPolicy OverlapPolicy `json:"overlap_policy,omitempty"` OverlapPolicy OverlapPolicy `json:"overlap_policy,omitempty"`
Paused bool `json:"paused,omitempty"`
} }
// JobsFile is the on-disk shape of jobs.json. Wrapping the slice in a top-level // JobsFile is the on-disk shape of jobs.json. Wrapping the slice in a top-level
+1
View File
@@ -14,4 +14,5 @@ type Job struct {
Arguments string `json:"arguments,omitempty"` Arguments string `json:"arguments,omitempty"`
StartOnly bool `json:"start_only,omitempty"` StartOnly bool `json:"start_only,omitempty"`
Enabled bool `json:"enabled"` Enabled bool `json:"enabled"`
OverlapPolicy string `json:"overlap_policy,omitempty"`
} }
+2
View File
@@ -33,6 +33,7 @@ type yamlConfig struct {
NotifyOnFailure bool `yaml:"notify_on_failure,omitempty"` NotifyOnFailure bool `yaml:"notify_on_failure,omitempty"`
ExecutionMode domain.ExecutionMode `yaml:"execution_mode,omitempty"` ExecutionMode domain.ExecutionMode `yaml:"execution_mode,omitempty"`
OverlapPolicy domain.OverlapPolicy `yaml:"overlap_policy,omitempty"` OverlapPolicy domain.OverlapPolicy `yaml:"overlap_policy,omitempty"`
Paused bool `yaml:"paused,omitempty"`
} }
type yamlJob struct { type yamlJob struct {
@@ -44,6 +45,7 @@ type yamlJob struct {
Arguments string `yaml:"arguments,omitempty"` Arguments string `yaml:"arguments,omitempty"`
StartOnly bool `yaml:"start_only,omitempty"` StartOnly bool `yaml:"start_only,omitempty"`
Enabled bool `yaml:"enabled"` Enabled bool `yaml:"enabled"`
OverlapPolicy string `yaml:"overlap_policy,omitempty"`
} }
type yamlJobsFile struct { type yamlJobsFile struct {
+43 -3
View File
@@ -9,6 +9,7 @@ import (
"fyne.io/fyne/v2" "fyne.io/fyne/v2"
"fyne.io/fyne/v2/container" "fyne.io/fyne/v2/container"
"fyne.io/fyne/v2/theme"
"fyne.io/fyne/v2/widget" "fyne.io/fyne/v2/widget"
) )
@@ -41,7 +42,38 @@ func collectActivity(jobs []job, runtimes map[int]*domain.JobRuntime) []event {
return events return events
} }
func newHistoryView(events *[]event) *fyne.Container { // logColumnMinWidth/logColumnMaxWidth bound the dynamically sized Log column.
// The minimum keeps the column readable when names are short or absent; the
// maximum stops a single very long file name from dominating the table (the
// table still scrolls horizontally past it).
const (
logColumnMinWidth = 240
logColumnMaxWidth = 520
logColumnPadding = 24
)
// logColumnWidth measures the widest Log cell value so the column can be sized
// to fit its content. Fyne tables do not auto-size columns, so without this the
// fixed width clips file names like "20260601-100000_SomeJobName.log".
func logColumnWidth(events []event) float32 {
width := float32(logColumnMinWidth)
for _, current := range events {
text := logFileName(current.LogFile)
if text == "" {
continue
}
w := fyne.MeasureText(text, theme.TextSize(), fyne.TextStyle{}).Width + logColumnPadding
if w > width {
width = w
}
}
if width > logColumnMaxWidth {
width = logColumnMaxWidth
}
return width
}
func newHistoryView(events *[]event) (*fyne.Container, func()) {
descending := false descending := false
headerText := func(id widget.TableCellID) string { headerText := func(id widget.TableCellID) string {
headers := []string{"Time", "Trigger", "Job", "State", "Detail", "Log"} headers := []string{"Time", "Trigger", "Job", "State", "Detail", "Log"}
@@ -107,8 +139,16 @@ func newHistoryView(events *[]event) *fyne.Container {
table.SetColumnWidth(2, 170) table.SetColumnWidth(2, 170)
table.SetColumnWidth(3, 90) table.SetColumnWidth(3, 90)
table.SetColumnWidth(4, 260) table.SetColumnWidth(4, 260)
table.SetColumnWidth(5, 240) table.SetColumnWidth(5, logColumnWidth(*events))
return container.NewPadded(table)
// refresh recomputes the content-fit Log column width before redrawing, so
// newly recorded events with longer file names widen the column instead of
// being truncated.
refresh := func() {
table.SetColumnWidth(5, logColumnWidth(*events))
table.Refresh()
}
return container.NewPadded(table), refresh
} }
func historyCellText(id widget.TableCellID, events []event) string { func historyCellText(id widget.TableCellID, events []event) string {
+18
View File
@@ -13,6 +13,10 @@ import (
"fyne.io/fyne/v2/widget" "fyne.io/fyne/v2/widget"
) )
// overlapPolicyInherit is the display label used when a job should inherit the
// global overlap policy. It maps to an empty Job.OverlapPolicy on save.
const overlapPolicyInherit = "(Use global default)"
// showJobDialog opens a create/edit form for a single job. onSave is called // 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 // with the populated job only when the user clicks Save and all fields pass
// validation. // validation.
@@ -40,6 +44,15 @@ func showJobDialog(w fyne.Window, title string, current job, onSave func(job)) {
startOnly.SetChecked(current.StartOnly) startOnly.SetChecked(current.StartOnly)
enabled := widget.NewCheck("Enabled", nil) enabled := widget.NewCheck("Enabled", nil)
enabled.SetChecked(current.Enabled) enabled.SetChecked(current.Enabled)
overlapSelect := widget.NewSelect(
[]string{overlapPolicyInherit, string(domain.OverlapPolicySkip), string(domain.OverlapPolicyQueue)},
nil,
)
overlapSelected := overlapPolicyInherit
if current.OverlapPolicy != "" {
overlapSelected = current.OverlapPolicy
}
overlapSelect.SetSelected(overlapSelected)
form := dialog.NewForm( form := dialog.NewForm(
title, title,
@@ -52,6 +65,7 @@ func showJobDialog(w fyne.Window, title string, current job, onSave func(job)) {
widget.NewFormItem("Command", commandRow), widget.NewFormItem("Command", commandRow),
widget.NewFormItem("Arguments", argumentsEntry), widget.NewFormItem("Arguments", argumentsEntry),
widget.NewFormItem("", startOnly), widget.NewFormItem("", startOnly),
widget.NewFormItem("Overlap policy", overlapSelect),
widget.NewFormItem("", enabled), widget.NewFormItem("", enabled),
}, },
func(saved bool) { func(saved bool) {
@@ -75,6 +89,10 @@ func showJobDialog(w fyne.Window, title string, current job, onSave func(job)) {
current.Arguments = strings.TrimSpace(argumentsEntry.Text) current.Arguments = strings.TrimSpace(argumentsEntry.Text)
current.StartOnly = startOnly.Checked current.StartOnly = startOnly.Checked
current.Enabled = enabled.Checked current.Enabled = enabled.Checked
current.OverlapPolicy = overlapSelect.Selected
if current.OverlapPolicy == overlapPolicyInherit {
current.OverlapPolicy = ""
}
// The dialog only edits durable configuration. Runtime status is // The dialog only edits durable configuration. Runtime status is
// initialized (new jobs) or updated (edits) by the caller against the // initialized (new jobs) or updated (edits) by the caller against the
// runtime map, keyed by job ID. // runtime map, keyed by job ID.
+47 -8
View File
@@ -19,6 +19,16 @@ const allFolders = "All"
const noFolder = "No folder" const noFolder = "No folder"
const minJobsSidebarWidth float32 = 480 const minJobsSidebarWidth float32 = 480
// maxJobActivityRows caps the "Selected job activity" panel to the most recent
// entries. The full per-job history (up to maxJobLogs) remains in the History
// view; this panel is a quick at-a-glance summary anchored below the output.
const maxJobActivityRows = 3
// jobActivityHeight is the fixed height reserved for the activity panel at the
// bottom of the details pane, sized for maxJobActivityRows rows so the command
// output above it can claim the remaining vertical space.
const jobActivityHeight float32 = 120
// newJobsView builds the Jobs tab: list sidebar, details panel, and toolbar. // newJobsView builds the Jobs tab: list sidebar, details panel, and toolbar.
// It returns the assembled panel and a refresh function the caller invokes // 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 // whenever the service state may have changed (e.g., from the event subscriber
@@ -51,7 +61,7 @@ func newJobsView(w fyne.Window, svc *app.Service) (fyne.CanvasObject, func()) {
selected := 0 selected := 0
selectedFolder := allFolders selectedFolder := allFolders
schedulerPaused := false schedulerPaused := svc.Store().Config.Paused
filteredJobs := filteredJobIndexes(jobs, selectedFolder) filteredJobs := filteredJobIndexes(jobs, selectedFolder)
title := widget.NewLabelWithStyle(jobs[selected].Name, fyne.TextAlignLeading, fyne.TextStyle{Bold: true}) title := widget.NewLabelWithStyle(jobs[selected].Name, fyne.TextAlignLeading, fyne.TextStyle{Bold: true})
@@ -66,7 +76,12 @@ func newJobsView(w fyne.Window, svc *app.Service) (fyne.CanvasObject, func()) {
nextRunLabel := newJobDetailLabel(selectedRuntime.NextRun) nextRunLabel := newJobDetailLabel(selectedRuntime.NextRun)
stateLabel := newJobDetailLabel(selectedRuntime.LastState) stateLabel := newJobDetailLabel(selectedRuntime.LastState)
statsLabel := newJobDetailLabel(app.DisplayStats(selectedRuntime)) statsLabel := newJobDetailLabel(app.DisplayStats(selectedRuntime))
schedulerState := widget.NewLabel("Scheduler running") overlapPolicyLabel := newJobDetailLabel(app.DisplayOverlapPolicy(jobs[selected], svc.Store().Config.OverlapPolicy))
schedulerStateText := "Scheduler running"
if schedulerPaused {
schedulerStateText = "Scheduler paused"
}
schedulerState := widget.NewLabel(schedulerStateText)
commandOutput := widget.NewTextGrid() commandOutput := widget.NewTextGrid()
commandOutput.SetText(selectedRuntime.Output) commandOutput.SetText(selectedRuntime.Output)
commandOutputScroll := container.NewScroll(commandOutput) commandOutputScroll := container.NewScroll(commandOutput)
@@ -75,7 +90,7 @@ func newJobsView(w fyne.Window, svc *app.Service) (fyne.CanvasObject, func()) {
// against the theme when it is placed inside a scroll container. // against the theme when it is placed inside a scroll container.
commandOutputScroll.SetMinSize(fyne.NewSize(520, 160)) commandOutputScroll.SetMinSize(fyne.NewSize(520, 160))
selectedLogs := append([]event(nil), selectedRuntime.Logs...) selectedLogs := lastJobLogs(selectedRuntime.Logs)
jobLogs := widget.NewList( jobLogs := widget.NewList(
func() int { return len(selectedLogs) }, func() int { return len(selectedLogs) },
func() fyne.CanvasObject { func() fyne.CanvasObject {
@@ -102,6 +117,7 @@ func newJobsView(w fyne.Window, svc *app.Service) (fyne.CanvasObject, func()) {
nextRunLabel.SetText("") nextRunLabel.SetText("")
stateLabel.SetText("") stateLabel.SetText("")
statsLabel.SetText("") statsLabel.SetText("")
overlapPolicyLabel.SetText("")
commandOutput.SetText("") commandOutput.SetText("")
selectedLogs = nil selectedLogs = nil
return return
@@ -115,12 +131,13 @@ func newJobsView(w fyne.Window, svc *app.Service) (fyne.CanvasObject, func()) {
commandLabel.SetText(current.Command) commandLabel.SetText(current.Command)
argumentsLabel.SetText(app.DisplayArguments(current.Arguments)) argumentsLabel.SetText(app.DisplayArguments(current.Arguments))
runModeLabel.SetText(app.DisplayRunMode(current)) runModeLabel.SetText(app.DisplayRunMode(current))
overlapPolicyLabel.SetText(app.DisplayOverlapPolicy(current, svc.Store().Config.OverlapPolicy))
lastRunLabel.SetText(rt.LastRun) lastRunLabel.SetText(rt.LastRun)
nextRunLabel.SetText(rt.NextRun) nextRunLabel.SetText(rt.NextRun)
stateLabel.SetText(rt.LastState) stateLabel.SetText(rt.LastState)
statsLabel.SetText(app.DisplayStats(rt)) statsLabel.SetText(app.DisplayStats(rt))
commandOutput.SetText(rt.Output) commandOutput.SetText(rt.Output)
selectedLogs = append(selectedLogs[:0], rt.Logs...) selectedLogs = lastJobLogs(rt.Logs)
} }
// list and folderSelect are declared early so closures below can reference // list and folderSelect are declared early so closures below can reference
@@ -252,7 +269,11 @@ func newJobsView(w fyne.Window, svc *app.Service) (fyne.CanvasObject, func()) {
list.Refresh() list.Refresh()
refreshView() refreshView()
}) })
stopAllButton := widget.NewButtonWithIcon("Pause all", theme.MediaStopIcon(), nil) stopAllText, stopAllIcon := "Pause all", theme.MediaStopIcon()
if schedulerPaused {
stopAllText, stopAllIcon = "Resume all", theme.MediaPlayIcon()
}
stopAllButton := widget.NewButtonWithIcon(stopAllText, stopAllIcon, nil)
stopAllButton.OnTapped = func() { stopAllButton.OnTapped = func() {
// SetGlobalPause flips the Service's pause flag, updates every job's // SetGlobalPause flips the Service's pause flag, updates every job's
// next-run text, and emits the activity record the observer logs. Mirror the // next-run text, and emits the activity record the observer logs. Mirror the
@@ -335,7 +356,10 @@ func newJobsView(w fyne.Window, svc *app.Service) (fyne.CanvasObject, func()) {
sidebarHeader := container.NewVBox(globalControls, widget.NewSeparator(), widget.NewLabelWithStyle("Folder", fyne.TextAlignLeading, fyne.TextStyle{Bold: true}), folderSelect, toolbar) 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) sidebar := container.NewBorder(sidebarHeader, nil, nil, nil, list)
details := container.NewVBox( // The details pane is a Border: the fixed metadata rows pin to the top, the
// activity panel pins to the bottom, and the command output fills whatever
// vertical space is left in between so long output stays readable.
topDetails := container.NewVBox(
title, title,
widget.NewSeparator(), widget.NewSeparator(),
detailRow("Folder", folderLabel), detailRow("Folder", folderLabel),
@@ -343,17 +367,20 @@ func newJobsView(w fyne.Window, svc *app.Service) (fyne.CanvasObject, func()) {
detailRow("Command", commandLabel), detailRow("Command", commandLabel),
detailRow("Arguments", argumentsLabel), detailRow("Arguments", argumentsLabel),
detailRow("Run mode", runModeLabel), detailRow("Run mode", runModeLabel),
detailRow("Overlap policy", overlapPolicyLabel),
detailRow("Last run", lastRunLabel), detailRow("Last run", lastRunLabel),
detailRow("Next run", nextRunLabel), detailRow("Next run", nextRunLabel),
detailRow("State", stateLabel), detailRow("State", stateLabel),
detailRow("Statistics", statsLabel), detailRow("Statistics", statsLabel),
widget.NewSeparator(), widget.NewSeparator(),
widget.NewLabelWithStyle("Command output", fyne.TextAlignLeading, fyne.TextStyle{Bold: true}), widget.NewLabelWithStyle("Command output", fyne.TextAlignLeading, fyne.TextStyle{Bold: true}),
commandOutputScroll, )
activitySection := container.NewVBox(
widget.NewSeparator(), widget.NewSeparator(),
widget.NewLabelWithStyle("Selected job activity", fyne.TextAlignLeading, fyne.TextStyle{Bold: true}), widget.NewLabelWithStyle("Selected job activity", fyne.TextAlignLeading, fyne.TextStyle{Bold: true}),
jobLogs, container.New(fixedHeightLayout{height: jobActivityHeight}, jobLogs),
) )
details := container.NewBorder(topDetails, activitySection, nil, nil, commandOutputScroll)
fixedSidebar := container.New(minWidthLayout{width: minJobsSidebarWidth}, sidebar) fixedSidebar := container.New(minWidthLayout{width: minJobsSidebarWidth}, sidebar)
panel := container.NewBorder(nil, nil, fixedSidebar, nil, container.NewPadded(details)) panel := container.NewBorder(nil, nil, fixedSidebar, nil, container.NewPadded(details))
@@ -361,6 +388,18 @@ func newJobsView(w fyne.Window, svc *app.Service) (fyne.CanvasObject, func()) {
} }
// lastJobLogs returns a fresh slice of the most recent activity entries for the
// "Selected job activity" panel. Logs are stored newest-first (see
// app.Service.recordRun), so the leading entries are the latest; the result is
// capped at maxJobActivityRows.
func lastJobLogs(logs []event) []event {
n := len(logs)
if n > maxJobActivityRows {
n = maxJobActivityRows
}
return append([]event(nil), logs[:n]...)
}
func filteredJobIndexes(jobs []job, folder string) []int { func filteredJobIndexes(jobs []job, folder string) []int {
indexes := make([]int, 0, len(jobs)) indexes := make([]int, 0, len(jobs))
for index, current := range jobs { for index, current := range jobs {
+30
View File
@@ -35,3 +35,33 @@ func (l minWidthLayout) Layout(objects []fyne.CanvasObject, size fyne.Size) {
object.Resize(size) object.Resize(size)
} }
} }
// fixedHeightLayout forces its contents to a fixed height while leaving the
// width to the parent container. It is used to reserve a stable amount of space
// for the activity panel so a neighbouring widget can absorb the rest.
type fixedHeightLayout struct {
height float32
}
func (l fixedHeightLayout) MinSize(objects []fyne.CanvasObject) fyne.Size {
var width float32
for _, object := range objects {
if !object.Visible() {
continue
}
if min := object.MinSize(); min.Width > width {
width = min.Width
}
}
return fyne.NewSize(width, l.height)
}
func (l fixedHeightLayout) Layout(objects []fyne.CanvasObject, size fyne.Size) {
for _, object := range objects {
if !object.Visible() {
continue
}
object.Move(fyne.NewPos(0, 0))
object.Resize(fyne.NewSize(size.Width, l.height))
}
}
+3 -3
View File
@@ -40,7 +40,7 @@ func newMainView(w fyne.Window) (fyne.CanvasObject, func(time.Duration, bool)) {
jobsPanel, refreshJobsView := newJobsView(w, svc) jobsPanel, refreshJobsView := newJobsView(w, svc)
history := newHistoryView(&events) history, refreshHistory := newHistoryView(&events)
recordStartup := func(duration time.Duration, windowShown bool) { recordStartup := func(duration time.Duration, windowShown bool) {
// Startup is recorded as an in-memory History event instead of being // Startup is recorded as an in-memory History event instead of being
// persisted into jobs.yaml. It is session diagnostics, not durable job // persisted into jobs.yaml. It is session diagnostics, not durable job
@@ -51,12 +51,12 @@ func newMainView(w fyne.Window) (fyne.CanvasObject, func(time.Duration, bool)) {
detail = "Started in tray in " + duration.Round(time.Millisecond).String() detail = "Started in tray in " + duration.Round(time.Millisecond).String()
} }
events = append(events, newEvent(0, "Application", "Started", detail)) events = append(events, newEvent(0, "Application", "Started", detail))
history.Refresh() refreshHistory()
} }
refresh := func() { refresh := func() {
refreshJobsView() refreshJobsView()
history.Refresh() refreshHistory()
} }
// The Service announces every change through events. This single listener is // The Service announces every change through events. This single listener is
+1 -1
View File
@@ -126,7 +126,7 @@ func settingsView(w fyne.Window, svc *app.Service) fyne.CanvasObject {
widget.NewSeparator(), widget.NewSeparator(),
widget.NewLabelWithStyle("Queue", fyne.TextAlignLeading, fyne.TextStyle{Bold: true}), widget.NewLabelWithStyle("Queue", fyne.TextAlignLeading, fyne.TextStyle{Bold: true}),
settingsRow("Execution mode", container.New(minWidthLayout{width: settingsControlWidth}, executionModeSelect)), settingsRow("Execution mode", container.New(minWidthLayout{width: settingsControlWidth}, executionModeSelect)),
settingsRow("Overlap policy", container.New(minWidthLayout{width: settingsControlWidth}, overlapPolicySelect)), settingsRow("Default overlap policy", container.New(minWidthLayout{width: settingsControlWidth}, overlapPolicySelect)),
widget.NewSeparator(), widget.NewSeparator(),
widget.NewLabelWithStyle("Storage", fyne.TextAlignLeading, fyne.TextStyle{Bold: true}), widget.NewLabelWithStyle("Storage", fyne.TextAlignLeading, fyne.TextStyle{Bold: true}),
settingsRow("Config YAML", widget.NewLabel(store.Paths.ConfigPath)), settingsRow("Config YAML", widget.NewLabel(store.Paths.ConfigPath)),