Compare commits
9 Commits
07c0995935
...
80d13d0ac6
| 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
|
- [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
|
||||||
|
|||||||
@@ -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 {
|
||||||
|
|||||||
@@ -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"
|
||||||
|
|||||||
@@ -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
@@ -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.
|
||||||
|
|||||||
@@ -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) {
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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"`
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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
@@ -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 {
|
||||||
|
|||||||
@@ -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
@@ -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 {
|
||||||
|
|||||||
@@ -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))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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)),
|
||||||
|
|||||||
Reference in New Issue
Block a user