Compare commits
9 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 80d13d0ac6 | |||
| 7e868d6f5b | |||
| b9505e83ab | |||
| b0d9883627 | |||
| 554ce2b93a | |||
| 3f3b977fb0 | |||
| 8f75318e8c | |||
| ed81443d29 | |||
| cf276fc8ec |
@@ -97,10 +97,10 @@ Done first because both share a compact, single-line record formatter.
|
||||
- [x] T2.7 — stats tests
|
||||
|
||||
### Phase 3 — Per-job run policy
|
||||
- [ ] T3.1 — `Job.OverlapPolicy` field
|
||||
- [ ] T3.2 — effective-policy dispatch + inherit
|
||||
- [ ] T3.3 — dialog select + settings/format wording
|
||||
- [ ] T3.4 — per-job override tests
|
||||
- [x] T3.1 — `Job.OverlapPolicy` field
|
||||
- [x] T3.2 — effective-policy dispatch + inherit
|
||||
- [x] T3.3 — dialog select + settings/format wording
|
||||
- [x] T3.4 — per-job override tests
|
||||
|
||||
### Phase 4 — Persist global pause state
|
||||
- [ ] T4.1 — `Config.Paused` field
|
||||
|
||||
@@ -92,6 +92,16 @@ func DisplayStats(rt *domain.JobRuntime) string {
|
||||
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,
|
||||
// or 0 if not found.
|
||||
func DisplayIndex(indexes []int, jobIndex int) int {
|
||||
|
||||
@@ -152,13 +152,17 @@ func (s *Service) SetEnabled(id int, enabled bool) error {
|
||||
func (s *Service) SetGlobalPause(paused bool) error {
|
||||
s.mu.Lock()
|
||||
s.paused = paused
|
||||
s.store.Config.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)
|
||||
err := s.store.SaveConfig()
|
||||
if err == nil {
|
||||
err = s.store.SaveJobs(s.jobs)
|
||||
}
|
||||
s.mu.Unlock()
|
||||
|
||||
state, detail := "Resumed", "All job execution resumed"
|
||||
|
||||
@@ -2,6 +2,8 @@ package app
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"sync/atomic"
|
||||
"testing"
|
||||
@@ -534,3 +536,73 @@ func TestPrependLogCapsActivityList(t *testing.T) {
|
||||
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
@@ -67,7 +67,6 @@ func (s *Service) RunDue(now time.Time) {
|
||||
var startErr error
|
||||
if !s.paused {
|
||||
sequential := s.store.Config.ExecutionMode == domain.ExecutionModeSequential
|
||||
queue := s.store.Config.OverlapPolicy == domain.OverlapPolicyQueue
|
||||
running := s.anyRunningLocked()
|
||||
for index := range s.jobs {
|
||||
job := &s.jobs[index]
|
||||
@@ -77,8 +76,9 @@ func (s *Service) RunDue(now time.Time) {
|
||||
}
|
||||
if runtime.LastState == "Running" {
|
||||
// The job came due again while its own run is still in flight.
|
||||
// Apply the overlap policy and step past this occurrence.
|
||||
if queue {
|
||||
// Apply the effective overlap policy and step past this
|
||||
// occurrence.
|
||||
if s.effectiveOverlapPolicy(job) == domain.OverlapPolicyQueue {
|
||||
runtime.Pending = true
|
||||
}
|
||||
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})
|
||||
}
|
||||
|
||||
// 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"
|
||||
// state. It backs the sequential-mode guards in RunNow and RunDue. The caller
|
||||
// must hold mu.
|
||||
|
||||
@@ -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
|
||||
// run is refused while another job is running, and allowed once nothing is.
|
||||
func TestRunNowSequentialGuard(t *testing.T) {
|
||||
|
||||
@@ -82,6 +82,7 @@ func NewService(store *storage.Store, jobs []domain.Job) *Service {
|
||||
schedules: make(map[int]domain.Schedule, len(jobs)),
|
||||
runJob: runner.RunJob,
|
||||
ctx: context.Background(),
|
||||
paused: store.Config.Paused,
|
||||
}
|
||||
// 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
|
||||
|
||||
@@ -40,6 +40,7 @@ type Config struct {
|
||||
NotifyOnFailure bool `json:"notify_on_failure,omitempty"`
|
||||
ExecutionMode ExecutionMode `json:"execution_mode,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
|
||||
|
||||
@@ -14,4 +14,5 @@ type Job struct {
|
||||
Arguments string `json:"arguments,omitempty"`
|
||||
StartOnly bool `json:"start_only,omitempty"`
|
||||
Enabled bool `json:"enabled"`
|
||||
OverlapPolicy string `json:"overlap_policy,omitempty"`
|
||||
}
|
||||
|
||||
@@ -33,6 +33,7 @@ type yamlConfig struct {
|
||||
NotifyOnFailure bool `yaml:"notify_on_failure,omitempty"`
|
||||
ExecutionMode domain.ExecutionMode `yaml:"execution_mode,omitempty"`
|
||||
OverlapPolicy domain.OverlapPolicy `yaml:"overlap_policy,omitempty"`
|
||||
Paused bool `yaml:"paused,omitempty"`
|
||||
}
|
||||
|
||||
type yamlJob struct {
|
||||
@@ -44,6 +45,7 @@ type yamlJob struct {
|
||||
Arguments string `yaml:"arguments,omitempty"`
|
||||
StartOnly bool `yaml:"start_only,omitempty"`
|
||||
Enabled bool `yaml:"enabled"`
|
||||
OverlapPolicy string `yaml:"overlap_policy,omitempty"`
|
||||
}
|
||||
|
||||
type yamlJobsFile struct {
|
||||
|
||||
+43
-3
@@ -9,6 +9,7 @@ import (
|
||||
|
||||
"fyne.io/fyne/v2"
|
||||
"fyne.io/fyne/v2/container"
|
||||
"fyne.io/fyne/v2/theme"
|
||||
"fyne.io/fyne/v2/widget"
|
||||
)
|
||||
|
||||
@@ -41,7 +42,38 @@ func collectActivity(jobs []job, runtimes map[int]*domain.JobRuntime) []event {
|
||||
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
|
||||
headerText := func(id widget.TableCellID) string {
|
||||
headers := []string{"Time", "Trigger", "Job", "State", "Detail", "Log"}
|
||||
@@ -107,8 +139,16 @@ func newHistoryView(events *[]event) *fyne.Container {
|
||||
table.SetColumnWidth(2, 170)
|
||||
table.SetColumnWidth(3, 90)
|
||||
table.SetColumnWidth(4, 260)
|
||||
table.SetColumnWidth(5, 240)
|
||||
return container.NewPadded(table)
|
||||
table.SetColumnWidth(5, logColumnWidth(*events))
|
||||
|
||||
// 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 {
|
||||
|
||||
@@ -13,6 +13,10 @@ import (
|
||||
"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
|
||||
// with the populated job only when the user clicks Save and all fields pass
|
||||
// validation.
|
||||
@@ -40,6 +44,15 @@ func showJobDialog(w fyne.Window, title string, current job, onSave func(job)) {
|
||||
startOnly.SetChecked(current.StartOnly)
|
||||
enabled := widget.NewCheck("Enabled", nil)
|
||||
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(
|
||||
title,
|
||||
@@ -52,6 +65,7 @@ func showJobDialog(w fyne.Window, title string, current job, onSave func(job)) {
|
||||
widget.NewFormItem("Command", commandRow),
|
||||
widget.NewFormItem("Arguments", argumentsEntry),
|
||||
widget.NewFormItem("", startOnly),
|
||||
widget.NewFormItem("Overlap policy", overlapSelect),
|
||||
widget.NewFormItem("", enabled),
|
||||
},
|
||||
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.StartOnly = startOnly.Checked
|
||||
current.Enabled = enabled.Checked
|
||||
current.OverlapPolicy = overlapSelect.Selected
|
||||
if current.OverlapPolicy == overlapPolicyInherit {
|
||||
current.OverlapPolicy = ""
|
||||
}
|
||||
// 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.
|
||||
|
||||
+47
-8
@@ -19,6 +19,16 @@ const allFolders = "All"
|
||||
const noFolder = "No folder"
|
||||
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.
|
||||
// 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
|
||||
@@ -51,7 +61,7 @@ func newJobsView(w fyne.Window, svc *app.Service) (fyne.CanvasObject, func()) {
|
||||
|
||||
selected := 0
|
||||
selectedFolder := allFolders
|
||||
schedulerPaused := false
|
||||
schedulerPaused := svc.Store().Config.Paused
|
||||
filteredJobs := filteredJobIndexes(jobs, selectedFolder)
|
||||
|
||||
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)
|
||||
stateLabel := newJobDetailLabel(selectedRuntime.LastState)
|
||||
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.SetText(selectedRuntime.Output)
|
||||
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.
|
||||
commandOutputScroll.SetMinSize(fyne.NewSize(520, 160))
|
||||
|
||||
selectedLogs := append([]event(nil), selectedRuntime.Logs...)
|
||||
selectedLogs := lastJobLogs(selectedRuntime.Logs)
|
||||
jobLogs := widget.NewList(
|
||||
func() int { return len(selectedLogs) },
|
||||
func() fyne.CanvasObject {
|
||||
@@ -102,6 +117,7 @@ func newJobsView(w fyne.Window, svc *app.Service) (fyne.CanvasObject, func()) {
|
||||
nextRunLabel.SetText("")
|
||||
stateLabel.SetText("")
|
||||
statsLabel.SetText("")
|
||||
overlapPolicyLabel.SetText("")
|
||||
commandOutput.SetText("")
|
||||
selectedLogs = nil
|
||||
return
|
||||
@@ -115,12 +131,13 @@ func newJobsView(w fyne.Window, svc *app.Service) (fyne.CanvasObject, func()) {
|
||||
commandLabel.SetText(current.Command)
|
||||
argumentsLabel.SetText(app.DisplayArguments(current.Arguments))
|
||||
runModeLabel.SetText(app.DisplayRunMode(current))
|
||||
overlapPolicyLabel.SetText(app.DisplayOverlapPolicy(current, svc.Store().Config.OverlapPolicy))
|
||||
lastRunLabel.SetText(rt.LastRun)
|
||||
nextRunLabel.SetText(rt.NextRun)
|
||||
stateLabel.SetText(rt.LastState)
|
||||
statsLabel.SetText(app.DisplayStats(rt))
|
||||
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
|
||||
@@ -252,7 +269,11 @@ func newJobsView(w fyne.Window, svc *app.Service) (fyne.CanvasObject, func()) {
|
||||
list.Refresh()
|
||||
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() {
|
||||
// SetGlobalPause flips the Service's pause flag, updates every job's
|
||||
// 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)
|
||||
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,
|
||||
widget.NewSeparator(),
|
||||
detailRow("Folder", folderLabel),
|
||||
@@ -343,17 +367,20 @@ func newJobsView(w fyne.Window, svc *app.Service) (fyne.CanvasObject, func()) {
|
||||
detailRow("Command", commandLabel),
|
||||
detailRow("Arguments", argumentsLabel),
|
||||
detailRow("Run mode", runModeLabel),
|
||||
detailRow("Overlap policy", overlapPolicyLabel),
|
||||
detailRow("Last run", lastRunLabel),
|
||||
detailRow("Next run", nextRunLabel),
|
||||
detailRow("State", stateLabel),
|
||||
detailRow("Statistics", statsLabel),
|
||||
widget.NewSeparator(),
|
||||
widget.NewLabelWithStyle("Command output", fyne.TextAlignLeading, fyne.TextStyle{Bold: true}),
|
||||
commandOutputScroll,
|
||||
)
|
||||
activitySection := container.NewVBox(
|
||||
widget.NewSeparator(),
|
||||
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)
|
||||
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 {
|
||||
indexes := make([]int, 0, len(jobs))
|
||||
for index, current := range jobs {
|
||||
|
||||
@@ -35,3 +35,33 @@ func (l minWidthLayout) Layout(objects []fyne.CanvasObject, size fyne.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))
|
||||
}
|
||||
}
|
||||
|
||||
@@ -40,7 +40,7 @@ func newMainView(w fyne.Window) (fyne.CanvasObject, func(time.Duration, bool)) {
|
||||
|
||||
jobsPanel, refreshJobsView := newJobsView(w, svc)
|
||||
|
||||
history := newHistoryView(&events)
|
||||
history, refreshHistory := 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
|
||||
@@ -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()
|
||||
}
|
||||
events = append(events, newEvent(0, "Application", "Started", detail))
|
||||
history.Refresh()
|
||||
refreshHistory()
|
||||
}
|
||||
|
||||
refresh := func() {
|
||||
refreshJobsView()
|
||||
history.Refresh()
|
||||
refreshHistory()
|
||||
}
|
||||
|
||||
// The Service announces every change through events. This single listener is
|
||||
|
||||
@@ -126,7 +126,7 @@ func settingsView(w fyne.Window, svc *app.Service) fyne.CanvasObject {
|
||||
widget.NewSeparator(),
|
||||
widget.NewLabelWithStyle("Queue", fyne.TextAlignLeading, fyne.TextStyle{Bold: true}),
|
||||
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.NewLabelWithStyle("Storage", fyne.TextAlignLeading, fyne.TextStyle{Bold: true}),
|
||||
settingsRow("Config YAML", widget.NewLabel(store.Paths.ConfigPath)),
|
||||
|
||||
Reference in New Issue
Block a user