diff --git a/.dockerignore b/.dockerignore index b4042d6..6415e25 100644 --- a/.dockerignore +++ b/.dockerignore @@ -4,5 +4,4 @@ dist logs gosentry.json jobs.json -*.yaml *.exe diff --git a/README.md b/README.md index 3543b63..5d861e5 100644 --- a/README.md +++ b/README.md @@ -75,10 +75,6 @@ include the run timestamp and job name: 20260614-224306_Hello_scheduler.log ``` -**Upgrading from an earlier build:** if `gosentry.yaml` / `jobs.yaml` exist -next to the executable, GoSentry imports them once and rewrites the data as -JSON. The old `.yaml` files are left untouched. - ## Schedules Interval schedules using Go duration syntax: diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md index 67e6fa5..992a502 100644 --- a/docs/ARCHITECTURE.md +++ b/docs/ARCHITECTURE.md @@ -14,7 +14,7 @@ src/ app/ Service — sole owner of job/runtime state; emits typed Events scheduler/ pure timing loop; calls app.Service.RunDue on every tick runner/ shell command execution + log file writing + cleanup - storage/ YAML persistence (gosentry.yaml, jobs.yaml) + storage/ JSON persistence (gosentry.json, jobs.json) platform/ autostart/ Manager interface + Windows (shortcut) and Linux (XDG) impls desktop/ display-scale helper (Linux only) @@ -29,12 +29,12 @@ flowchart LR user["Desktop user"] ui["src/ui\nFyne windows, tabs, dialogs"] svc["src/app Service\nsole owner of job + runtime state"] - store["src/storage Store\nYAML config and jobs"] + store["src/storage Store\nJSON config and jobs"] sched["src/scheduler Scheduler\npure timing loop"] runner["src/runner\nshell command execution"] autostart["src/platform/autostart Manager\nWindows shortcut / Linux XDG"] - config["gosentry.yaml\napplication settings"] - jobs["jobs.yaml\njob definitions"] + config["gosentry.json\napplication settings"] + jobs["jobs.json\njob definitions"] logs["logs_dir\nper-run command output logs"] shell["Platform shell\ncmd.exe /C or sh -c"] @@ -61,9 +61,11 @@ flowchart LR 1. Startup: `cmd/gosentry` calls `ui.Run`, which creates an `app.Service`, opens the - store, loads `gosentry.yaml` and `jobs.yaml`, subscribes the UI to service + store, loads `gosentry.json` and `jobs.json`, subscribes the UI to service events, builds the main window, and calls `Service.Start` to begin the - scheduler loop. + scheduler loop. On first launch the service seeds per-job run-time statistics + from existing log files so the details panel reflects accumulated history + immediately (see §Statistics below). 2. Editing settings or jobs: The UI calls mutating methods on `app.Service` (e.g. `CreateJob`, @@ -79,18 +81,20 @@ flowchart LR 4. Manual run: `Run now` in the UI calls `Service.RunNow`. The Service checks that the job - exists, is not already running, and that the scheduler is not paused, then - executes `runner.RunJob` with the `Manual` trigger. + exists, is not already running, and that the scheduler is not globally paused, + then executes `runner.RunJob` with the `Manual` trigger. 5. Command execution: `runner.RunJob` builds the platform-specific invocation, executes the command through the platform shell, captures stdout and stderr, writes one - timestamped `.log` file, and returns a `domain.RunRecord`. + timestamped `.log` file, and returns a `domain.RunRecord` containing + `DurationMS` (wall-clock milliseconds from start to finish; 0 for + `StartOnly` fire-and-forget jobs). 6. History update: - When a run goroutine completes, `Service` updates the job's runtime, saves - YAML, triggers log cleanup, and emits `RunRecorded`. The UI observer appends - the record to the History tab. + When a run goroutine completes, `Service` updates the job's runtime + (including the statistics aggregate), saves JSON, triggers log cleanup, and + emits `RunRecorded`. The UI observer appends the record to the History tab. 7. Autostart: `UpdateSettings` in the Service calls `autostart.Manager.Set`. The Manager @@ -99,6 +103,61 @@ flowchart LR entries pass `--start-in-tray`. 8. Error surfacing: - Background errors (failed YAML saves, cleanup errors) are emitted as + Background errors (failed JSON saves, cleanup errors) are emitted as `ErrorOccurred` events and displayed in the UI status area, rather than being silently discarded. + +## Key Domain Concepts + +### Per-job overlap policy + +`domain.Job` carries an `OverlapPolicy` field (`json:"overlap_policy,omitempty"`). +When non-empty it overrides the global `Config.OverlapPolicy` for that job alone. +Empty means inherit the global default. `app.Service.RunDue` resolves the +effective policy per job: it uses `job.OverlapPolicy` when set, otherwise falls +back to `store.Config.OverlapPolicy`. `normalizeJob` in `app/operations.go` leaves +the field empty on new jobs so the inherit semantics are preserved. + +### Run-time statistics + +`domain.JobRuntime` holds a rolling aggregate updated after each run: + +| Field | Meaning | +|-------|---------| +| `RunCount` | total runs recorded | +| `FailCount` | runs that exited non-zero | +| `LastDurationMS` | wall-clock time of the most recent run | +| `AvgDurationMS` | mean over all runs with a recorded duration | +| `MaxDurationMS` | longest recorded run | + +`runner.RunJob` measures the wall-clock start→finish and sets `DurationMS` on +the returned `RunRecord`. `runner/logfile.go` writes a `duration` line into the +log file header alongside the existing `state` line. + +On startup, `runner.SeedStats` scans each job's log files (matched by the +`_.log` suffix, bounded by `Config.MaxLogFiles`) and folds the +parsed `state`/`duration` headers into a `runner.StatSeed` map. `NewService` +applies those seeds to the runtime map before the first scheduler tick, so the +details panel shows accumulated run history immediately after a restart. +Older log files that pre-date the `duration` header are tolerated: the run is +counted but the timing is skipped. + +### Persisted global pause + +`domain.Config` carries a `Paused bool` field (`json:"paused,omitempty"`). +`app.Service.SetGlobalPause` writes the new value into `store.Config` and calls +`SaveConfig`, so the paused state survives a restart. `NewService` initialises +`s.paused` from `store.Config.Paused` and applies the paused next-run text to +all runtimes before the first tick, ensuring the UI shows the correct state from +the moment the window opens. + +### `jobs_view.go` file structure + +`src/ui/jobs_view.go` is split across three files to stay within the ~250-line +size guideline: + +| File | Contents | +|------|----------| +| `jobs_view.go` | `newJobsView` — list, toolbar, button wiring, and layout | +| `jobs_view_details.go` | `detailsPanel` struct — widget creation, `update`, `clear`, `container` | +| `jobs_view_helpers.go` | Pure helpers — `filteredJobIndexes`, `folderOptions`, `filterValue`, `indexOfID`, `lastJobLogs` | diff --git a/docs/RELEASE-0.10-TASKS.md b/docs/RELEASE-0.10-TASKS.md index a49d725..60ef897 100644 --- a/docs/RELEASE-0.10-TASKS.md +++ b/docs/RELEASE-0.10-TASKS.md @@ -109,13 +109,13 @@ Done first because both share a compact, single-line record formatter. - [x] T4.4 — persistence + restored-paused tests ### Phase 5 — Window sizing -- [ ] T5.1 — 720p-safe default + MinSize +- [x] T5.1 — 720p-safe default + MinSize ### Phase 6 — Refactor + cleanup -- [ ] T6.1 — `jobs_view.go` split -- [ ] T6.2 — post-field-test cleanup (keep startup timing) -- [ ] T6.3 — drop YAML→JSON migration -- [ ] T6.4 — ARCHITECTURE.md update +- [x] T6.1 — `jobs_view.go` split +- [x] T6.2 — post-field-test cleanup (keep startup timing) +- [x] T6.3 — drop YAML→JSON migration +- [x] T6.4 — ARCHITECTURE.md update ### Phase 7 — Portable packaging - [ ] T7.1 — Windows `.zip` diff --git a/go.mod b/go.mod index 671cbc3..8a6869a 100644 --- a/go.mod +++ b/go.mod @@ -5,7 +5,6 @@ go 1.22 require ( fyne.io/fyne/v2 v2.7.4 github.com/robfig/cron/v3 v3.0.1 - go.yaml.in/yaml/v4 v4.0.0-rc.5 ) require ( diff --git a/go.sum b/go.sum index 8d938b0..d4ca4f7 100644 --- a/go.sum +++ b/go.sum @@ -71,8 +71,6 @@ github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U= github.com/yuin/goldmark v1.7.8 h1:iERMLn0/QJeHFhxSt3p6PeN9mGnvIKSpG9YYorDMnic= github.com/yuin/goldmark v1.7.8/go.mod h1:uzxRWxtg69N339t3louHJ7+O03ezfj6PlliRlaOzY1E= -go.yaml.in/yaml/v4 v4.0.0-rc.5 h1:JVliQq9EGOYaTgMi+k8BhUJyqcGk4ZqeuiN1Cirba9c= -go.yaml.in/yaml/v4 v4.0.0-rc.5/go.mod h1:aZqd9kCMsGL7AuUv/m/PvWLdg5sjJsZ4oHDEnfPPfY0= golang.org/x/image v0.24.0 h1:AN7zRgVsbvmTfNyqIbbOraYL8mSwcKncEj8ofjgzcMQ= golang.org/x/image v0.24.0/go.mod h1:4b/ITuLfqYq1hqZcjofwctIhi7sZh2WaCjvsBNjjya8= golang.org/x/net v0.35.0 h1:T5GQRQb2y08kTAByq9L4/bz8cipCdA8FbRTXewonqY8= diff --git a/src/storage/paths.go b/src/storage/paths.go index f3049fa..fc64057 100644 --- a/src/storage/paths.go +++ b/src/storage/paths.go @@ -14,10 +14,6 @@ const ( // installed/copied program. JobsFileName = "jobs.json" - // Legacy YAML file names used by builds before the JSON migration. These are - // read once on first start (P1.4) and then replaced by the JSON equivalents. - legacyYAMLConfigFileName = "gosentry.yaml" - legacyYAMLJobsFileName = "jobs.yaml" ) // Paths contains both the physical program location and the resolved runtime diff --git a/src/storage/store.go b/src/storage/store.go index 2673bc0..edbaef0 100644 --- a/src/storage/store.go +++ b/src/storage/store.go @@ -9,7 +9,6 @@ import ( "strings" "gitea.mixdep.ru/mix/gosentry/src/domain" - "go.yaml.in/yaml/v4" ) type Store struct { @@ -17,41 +16,6 @@ type Store struct { Config domain.Config } -// yamlConfig and yamlJob / yamlJobsFile mirror the durable domain types using the -// yaml tags that pre-JSON-migration files carried. They exist only so the -// one-time import can parse a legacy gosentry.yaml / jobs.yaml; the domain types -// themselves stay JSON-only. Field layout must stay identical to the matching -// domain struct so the value conversions in importYAMLConfig / importYAMLJobs -// remain valid. -type yamlConfig struct { - JobsDir string `yaml:"jobs_dir"` - LogsDir string `yaml:"logs_dir"` - MaxLogFiles int `yaml:"max_log_files"` - MaxLogAgeDays int `yaml:"max_log_age_days"` - StartOnLogin bool `yaml:"start_on_login,omitempty"` - KeepRunningInTray bool `yaml:"keep_running_in_tray,omitempty"` - 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 { - ID int `yaml:"id"` - Name string `yaml:"name"` - Folder string `yaml:"folder,omitempty"` - Schedule string `yaml:"schedule"` - Command string `yaml:"command"` - Arguments string `yaml:"arguments,omitempty"` - StartOnly bool `yaml:"start_only,omitempty"` - Enabled bool `yaml:"enabled"` - OverlapPolicy string `yaml:"overlap_policy,omitempty"` -} - -type yamlJobsFile struct { - Jobs []yamlJob `yaml:"jobs"` -} - func OpenStore() (*Store, []domain.Job, error) { paths, err := ResolvePaths() if err != nil { @@ -117,26 +81,15 @@ func loadOrCreateConfig(paths Paths) (domain.Config, error) { } if _, err := os.Stat(paths.ConfigPath); errors.Is(err, os.ErrNotExist) { - // No JSON config yet. Import a pre-migration gosentry.yaml once if it is - // present; otherwise write the defaults so later starts read a normal JSON - // file. The caller's SaveConfig rewrites whatever is loaded as gosentry.json. - legacyPath := filepath.Join(paths.AppDir, legacyYAMLConfigFileName) - imported, ok, err := importYAMLConfig(legacyPath, config) - if err != nil { - return domain.Config{}, err - } - if !ok { - return config, writeJSON(paths.ConfigPath, config) - } - config = imported - } else { - data, err := os.ReadFile(paths.ConfigPath) - if err != nil { - return domain.Config{}, err - } - if err := json.Unmarshal(data, &config); err != nil { - return domain.Config{}, err - } + return config, writeJSON(paths.ConfigPath, config) + } + + data, err := os.ReadFile(paths.ConfigPath) + if err != nil { + return domain.Config{}, err + } + if err := json.Unmarshal(data, &config); err != nil { + return domain.Config{}, err } if strings.TrimSpace(config.JobsDir) == "" { @@ -164,19 +117,8 @@ func loadOrCreateConfig(paths Paths) (domain.Config, error) { func loadOrCreateJobs(path string) ([]domain.Job, error) { if _, err := os.Stat(path); errors.Is(err, os.ErrNotExist) { - // No JSON jobs file yet. Import a pre-migration jobs.yaml once if present; - // otherwise seed harmless sample jobs so a new user can immediately see - // scheduled and manual execution without inventing a command. Imported jobs - // are returned unsaved here — the caller's SaveJobs rewrites them as - // jobs.json after normalization. - legacyPath := filepath.Join(filepath.Dir(path), legacyYAMLJobsFileName) - imported, ok, err := importYAMLJobs(legacyPath) - if err != nil { - return nil, err - } - if ok { - return imported, nil - } + // Seed harmless sample jobs so a new user can immediately see scheduled + // and manual execution without inventing a command. jobs := defaultJobs() normalizeJobs(jobs) return jobs, writeJSON(path, domain.JobsFile{Jobs: jobs}) @@ -193,46 +135,6 @@ func loadOrCreateJobs(path string) ([]domain.Job, error) { return file.Jobs, nil } -// importYAMLConfig reads a pre-migration gosentry.yaml into the current Config -// shape. It returns ok=false when the file is absent so the caller falls back to -// writing fresh defaults. The supplied base seeds the shadow struct so keys that -// the YAML omits keep their default value instead of becoming zero. -func importYAMLConfig(path string, base domain.Config) (domain.Config, bool, error) { - data, err := os.ReadFile(path) - if errors.Is(err, os.ErrNotExist) { - return domain.Config{}, false, nil - } - if err != nil { - return domain.Config{}, false, err - } - shadow := yamlConfig(base) - if err := yaml.Unmarshal(data, &shadow); err != nil { - return domain.Config{}, false, err - } - return domain.Config(shadow), true, nil -} - -// importYAMLJobs reads a pre-migration jobs.yaml into durable domain jobs. It -// returns ok=false when the file is absent so the caller can seed default jobs. -func importYAMLJobs(path string) ([]domain.Job, bool, error) { - data, err := os.ReadFile(path) - if errors.Is(err, os.ErrNotExist) { - return nil, false, nil - } - if err != nil { - return nil, false, err - } - var file yamlJobsFile - if err := yaml.Unmarshal(data, &file); err != nil { - return nil, false, err - } - jobs := make([]domain.Job, len(file.Jobs)) - for i := range file.Jobs { - jobs[i] = domain.Job(file.Jobs[i]) - } - return jobs, true, nil -} - func normalizeJobs(jobs []domain.Job) { next := 1 for index := range jobs { diff --git a/src/storage/store_test.go b/src/storage/store_test.go index d055669..583aea7 100644 --- a/src/storage/store_test.go +++ b/src/storage/store_test.go @@ -8,17 +8,8 @@ import ( "testing" "gitea.mixdep.ru/mix/gosentry/src/domain" - "go.yaml.in/yaml/v4" ) -func writeYAML(path string, value any) error { - data, err := yaml.Marshal(value) - if err != nil { - return err - } - return os.WriteFile(path, data, 0o644) -} - func TestJobsRoundTrip(t *testing.T) { dir := t.TempDir() path := filepath.Join(dir, "jobs.json") @@ -154,50 +145,6 @@ func TestNormalizeJobsFillsDefaults(t *testing.T) { } } -// TestLoadOrCreateConfigMigratesFromLegacy verifies that when gosentry.json is -// absent but gosentry.yaml exists the config is read from the legacy YAML file. -// This lets installs that pre-date the JSON migration start without manual steps. -func TestLoadOrCreateConfigMigratesFromLegacy(t *testing.T) { - dir := t.TempDir() - paths := Paths{ - AppDir: dir, - ConfigPath: filepath.Join(dir, ConfigFileName), // gosentry.json — not created - } - - legacy := yamlConfig{ - JobsDir: "/legacy/jobs", - LogsDir: "/legacy/logs", - MaxLogFiles: 77, - MaxLogAgeDays: 13, - StartOnLogin: true, - } - if err := writeYAML(filepath.Join(dir, legacyYAMLConfigFileName), legacy); err != nil { - t.Fatal(err) - } - - got, err := loadOrCreateConfig(paths) - if err != nil { - t.Fatal(err) - } - if got.JobsDir != legacy.JobsDir { - t.Errorf("JobsDir: got %q, want %q", got.JobsDir, legacy.JobsDir) - } - if got.LogsDir != legacy.LogsDir { - t.Errorf("LogsDir: got %q, want %q", got.LogsDir, legacy.LogsDir) - } - if got.MaxLogFiles != legacy.MaxLogFiles { - t.Errorf("MaxLogFiles: got %d, want %d", got.MaxLogFiles, legacy.MaxLogFiles) - } - if got.MaxLogAgeDays != legacy.MaxLogAgeDays { - t.Errorf("MaxLogAgeDays: got %d, want %d", got.MaxLogAgeDays, legacy.MaxLogAgeDays) - } - if got.StartOnLogin != legacy.StartOnLogin { - t.Errorf("StartOnLogin: got %v, want %v", got.StartOnLogin, legacy.StartOnLogin) - } -} - -// TestLoadOrCreateConfigCreatesDefaultsOnFirstRun verifies that the first run -// (no config files present) writes gosentry.json and returns sensible defaults. func TestLoadOrCreateConfigCreatesDefaultsOnFirstRun(t *testing.T) { dir := t.TempDir() paths := Paths{ @@ -252,39 +199,3 @@ func TestJobsJSONDoesNotPersistRuntimeNoise(t *testing.T) { } } } - -// TestLoadOrCreateJobsMigratesFromLegacy verifies that when jobs.json is absent -// but jobs.yaml exists the jobs are read from the legacy YAML file. -func TestLoadOrCreateJobsMigratesFromLegacy(t *testing.T) { - dir := t.TempDir() - jsonPath := filepath.Join(dir, JobsFileName) // jobs.json — not created - - legacy := yamlJobsFile{ - Jobs: []yamlJob{ - {ID: 10, Name: "Legacy job", Schedule: "@every 5m", Command: "echo legacy", Enabled: true}, - }, - } - if err := writeYAML(filepath.Join(dir, legacyYAMLJobsFileName), legacy); err != nil { - t.Fatal(err) - } - - got, err := loadOrCreateJobs(jsonPath) - if err != nil { - t.Fatal(err) - } - if len(got) != 1 { - t.Fatalf("expected 1 job, got %d", len(got)) - } - if got[0].ID != 10 { - t.Errorf("ID: got %d, want 10", got[0].ID) - } - if got[0].Name != "Legacy job" { - t.Errorf("Name: got %q, want 'Legacy job'", got[0].Name) - } - if got[0].Schedule != "@every 5m" { - t.Errorf("Schedule: got %q, want '@every 5m'", got[0].Schedule) - } - if !got[0].Enabled { - t.Errorf("Enabled: got false, want true") - } -} diff --git a/src/ui/jobs_view.go b/src/ui/jobs_view.go index 38a215d..9ae9cd9 100644 --- a/src/ui/jobs_view.go +++ b/src/ui/jobs_view.go @@ -2,7 +2,6 @@ package ui import ( "fmt" - "strings" "gitea.mixdep.ru/mix/gosentry/src/app" "gitea.mixdep.ru/mix/gosentry/src/domain" @@ -64,80 +63,17 @@ func newJobsView(w fyne.Window, svc *app.Service) (fyne.CanvasObject, func()) { schedulerPaused := svc.Store().Config.Paused filteredJobs := filteredJobIndexes(jobs, selectedFolder) - title := widget.NewLabelWithStyle(jobs[selected].Name, fyne.TextAlignLeading, fyne.TextStyle{Bold: true}) - title.Wrapping = fyne.TextWrapBreak - folderLabel := newJobDetailLabel(jobs[selected].Folder) - scheduleLabel := newJobDetailLabel(jobs[selected].Schedule) - commandLabel := newJobDetailLabel(jobs[selected].Command) - argumentsLabel := newJobDetailLabel(jobs[selected].Arguments) - runModeLabel := newJobDetailLabel(app.DisplayRunMode(jobs[selected])) - selectedRuntime := runtimeFor(selected) - lastRunLabel := newJobDetailLabel(selectedRuntime.LastRun) - nextRunLabel := newJobDetailLabel(selectedRuntime.NextRun) - stateLabel := newJobDetailLabel(selectedRuntime.LastState) - statsLabel := newJobDetailLabel(app.DisplayStats(selectedRuntime)) - 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) - // Command output can contain long lines and preserved whitespace. TextGrid is - // used instead of Label so stdout/stderr remains readable and does not vanish - // against the theme when it is placed inside a scroll container. - commandOutputScroll.SetMinSize(fyne.NewSize(460, 120)) - - selectedLogs := lastJobLogs(selectedRuntime.Logs) - jobLogs := widget.NewList( - func() int { return len(selectedLogs) }, - func() fyne.CanvasObject { - l := widget.NewLabel("log") - l.Wrapping = fyne.TextTruncate - return l - }, - func(id widget.ListItemID, item fyne.CanvasObject) { - item.(*widget.Label).SetText(app.EventLine(selectedLogs[id])) - }, - ) + dp := newDetailsPanel(jobs[selected], runtimeFor(selected), svc.Store().Config.OverlapPolicy) updateDetails := func(index int) { if index < 0 || index >= len(jobs) { // A folder filter can temporarily leave no selectable rows. Clearing // the details panel avoids showing stale information for a hidden job. - title.SetText("No job selected") - folderLabel.SetText("") - scheduleLabel.SetText("") - commandLabel.SetText("") - argumentsLabel.SetText("") - runModeLabel.SetText("") - lastRunLabel.SetText("") - nextRunLabel.SetText("") - stateLabel.SetText("") - statsLabel.SetText("") - overlapPolicyLabel.SetText("") - commandOutput.SetText("") - selectedLogs = nil + dp.clear() return } selected = index - current := jobs[selected] - rt := runtimeFor(selected) - title.SetText(current.Name) - folderLabel.SetText(app.DisplayFolder(current.Folder)) - scheduleLabel.SetText(current.Schedule) - commandLabel.SetText(current.Command) - argumentsLabel.SetText(app.DisplayArguments(current.Arguments)) - 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 = lastJobLogs(rt.Logs) + dp.update(jobs[selected], runtimeFor(selected), svc.Store().Config.OverlapPolicy) } // list and folderSelect are declared early so closures below can reference @@ -149,7 +85,7 @@ func newJobsView(w fyne.Window, svc *app.Service) (fyne.CanvasObject, func()) { syncFromService() filteredJobs = filteredJobIndexes(jobs, selectedFolder) updateDetails(selected) - jobLogs.Refresh() + dp.logs.Refresh() if list != nil { list.Refresh() } @@ -208,9 +144,6 @@ func newJobsView(w fyne.Window, svc *app.Service) (fyne.CanvasObject, func()) { addButton := widget.NewButtonWithIcon("New job", theme.ContentAddIcon(), func() { showJobDialog(w, "New job", job{Schedule: "@every 1m", Command: "echo GoSentry job ran", Enabled: true}, func(saved job) { - // The Service assigns the ID, stores the job, records the "Created" - // activity, and emits events. The observer appends those to History; we - // only refresh the snapshot and move the selection to the new job. created, err := svc.CreateJob(saved) if err != nil { dialog.ShowError(err, w) @@ -236,9 +169,6 @@ func newJobsView(w fyne.Window, svc *app.Service) (fyne.CanvasObject, func()) { return } showJobDialog(w, "Edit job", jobs[selected], func(saved job) { - // The job keeps its ID, so the Service preserves the runtime (keyed by - // ID), reflects any enabled/disabled change, recomputes the next run, and - // emits the "Updated" activity the observer records. saved.ID = jobs[selected].ID if err := svc.UpdateJob(saved); err != nil { dialog.ShowError(err, w) @@ -269,15 +199,20 @@ func newJobsView(w fyne.Window, svc *app.Service) (fyne.CanvasObject, func()) { list.Refresh() refreshView() }) + stopAllText, stopAllIcon := "Pause all", theme.MediaStopIcon() if schedulerPaused { stopAllText, stopAllIcon = "Resume all", theme.MediaPlayIcon() } + schedulerStateText := "Scheduler running" + if schedulerPaused { + schedulerStateText = "Scheduler paused" + } + schedulerState := widget.NewLabel(schedulerStateText) 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 - // new state into the local flag and the controls; revert it if the save fails. + // SetGlobalPause flips the pause flag, updates every job's next-run text, + // and emits the activity record the observer logs. Revert if the save fails. schedulerPaused = !schedulerPaused if err := svc.SetGlobalPause(schedulerPaused); err != nil { schedulerPaused = !schedulerPaused @@ -300,8 +235,6 @@ func newJobsView(w fyne.Window, svc *app.Service) (fyne.CanvasObject, func()) { if selected < 0 || selected >= len(jobs) { return } - // SetEnabled toggles the job, updates its runtime/next-run, and records the - // "Resumed"/"Paused" activity the observer logs. current := jobs[selected] if err := svc.SetEnabled(current.ID, !current.Enabled); err != nil { dialog.ShowError(err, w) @@ -322,9 +255,6 @@ func newJobsView(w fyne.Window, svc *app.Service) (fyne.CanvasObject, func()) { if !confirm { return } - // The Service removes the job and its runtime, persists, and records the - // "Deleted" activity the observer logs; the UI re-reads the snapshot and - // fixes up the folder filter and selection. if err := svc.DeleteJob(deleted.ID); err != nil { dialog.ShowError(err, w) return @@ -356,103 +286,7 @@ 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) - // 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), - detailRow("Schedule", scheduleLabel), - 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}), - ) - activitySection := container.NewVBox( - widget.NewSeparator(), - widget.NewLabelWithStyle("Selected job activity", fyne.TextAlignLeading, fyne.TextStyle{Bold: true}), - 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)) + panel := container.NewBorder(nil, nil, fixedSidebar, nil, container.NewPadded(dp.container())) return panel, refreshView } - - -// 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 { - if folder == allFolders || filterValue(current.Folder) == folder { - indexes = append(indexes, index) - } - } - return indexes -} - -func folderOptions(jobs []job) []string { - // "All" and "No folder" are always present so the filter UI is stable even - // before the user creates folders. - options := []string{allFolders, noFolder} - seen := map[string]bool{allFolders: true, noFolder: true} - for _, current := range jobs { - folder := strings.TrimSpace(current.Folder) - if folder == "" || seen[folder] { - continue - } - seen[folder] = true - options = append(options, folder) - } - return options -} - -func filterValue(folder string) string { - if strings.TrimSpace(folder) == "" { - return noFolder - } - return strings.TrimSpace(folder) -} - -func indexOfID(jobs []job, id int) int { - for index, current := range jobs { - if current.ID == id { - return index - } - } - return 0 -} - -func detailRow(label string, value fyne.CanvasObject) fyne.CanvasObject { - caption := widget.NewLabelWithStyle(label, fyne.TextAlignLeading, fyne.TextStyle{Bold: true}) - caption.Wrapping = fyne.TextTruncate - return container.NewGridWithColumns(2, caption, value) -} - -func newJobDetailLabel(text string) *widget.Label { - label := widget.NewLabel(text) - // Job names, commands, and paths can be much wider than the details panel. - // Breaking long runs of text keeps Label.MinSize stable when the selection - // changes, so the right panel does not force the whole window to resize. - label.Wrapping = fyne.TextWrapBreak - return label -} diff --git a/src/ui/jobs_view_details.go b/src/ui/jobs_view_details.go new file mode 100644 index 0000000..f7ec491 --- /dev/null +++ b/src/ui/jobs_view_details.go @@ -0,0 +1,144 @@ +package ui + +import ( + "gitea.mixdep.ru/mix/gosentry/src/app" + "gitea.mixdep.ru/mix/gosentry/src/domain" + + "fyne.io/fyne/v2" + "fyne.io/fyne/v2/container" + "fyne.io/fyne/v2/widget" +) + +// detailsPanel holds all widgets in the job details pane and knows how to +// assemble, populate, and clear them. Extracting it here keeps newJobsView +// focused on list, toolbar, and layout wiring without embedding 100+ lines of +// widget construction and update logic. +type detailsPanel struct { + title *widget.Label + folder *widget.Label + schedule *widget.Label + command *widget.Label + arguments *widget.Label + runMode *widget.Label + overlapPolicy *widget.Label + lastRun *widget.Label + nextRun *widget.Label + state *widget.Label + stats *widget.Label + + commandOutput *widget.TextGrid + commandOutputScroll *container.Scroll + + logs *widget.List + selectedLogs []event +} + +func newDetailsPanel(firstJob job, rt *domain.JobRuntime, globalOverlapPolicy domain.OverlapPolicy) *detailsPanel { + d := &detailsPanel{ + title: widget.NewLabelWithStyle("", fyne.TextAlignLeading, fyne.TextStyle{Bold: true}), + folder: newJobDetailLabel(""), + schedule: newJobDetailLabel(""), + command: newJobDetailLabel(""), + arguments: newJobDetailLabel(""), + runMode: newJobDetailLabel(""), + overlapPolicy: newJobDetailLabel(""), + lastRun: newJobDetailLabel(""), + nextRun: newJobDetailLabel(""), + state: newJobDetailLabel(""), + stats: newJobDetailLabel(""), + commandOutput: widget.NewTextGrid(), + } + d.title.Wrapping = fyne.TextWrapBreak + d.commandOutputScroll = container.NewScroll(d.commandOutput) + // Command output can contain long lines and preserved whitespace. TextGrid is + // used instead of Label so stdout/stderr remains readable and does not vanish + // against the theme when it is placed inside a scroll container. + d.commandOutputScroll.SetMinSize(fyne.NewSize(460, 120)) + d.logs = widget.NewList( + func() int { return len(d.selectedLogs) }, + func() fyne.CanvasObject { + l := widget.NewLabel("log") + l.Wrapping = fyne.TextTruncate + return l + }, + func(id widget.ListItemID, item fyne.CanvasObject) { + item.(*widget.Label).SetText(app.EventLine(d.selectedLogs[id])) + }, + ) + d.update(firstJob, rt, globalOverlapPolicy) + return d +} + +func (d *detailsPanel) update(j job, rt *domain.JobRuntime, globalOverlapPolicy domain.OverlapPolicy) { + d.title.SetText(j.Name) + d.folder.SetText(app.DisplayFolder(j.Folder)) + d.schedule.SetText(j.Schedule) + d.command.SetText(j.Command) + d.arguments.SetText(app.DisplayArguments(j.Arguments)) + d.runMode.SetText(app.DisplayRunMode(j)) + d.overlapPolicy.SetText(app.DisplayOverlapPolicy(j, globalOverlapPolicy)) + d.lastRun.SetText(rt.LastRun) + d.nextRun.SetText(rt.NextRun) + d.state.SetText(rt.LastState) + d.stats.SetText(app.DisplayStats(rt)) + d.commandOutput.SetText(rt.Output) + d.selectedLogs = lastJobLogs(rt.Logs) +} + +func (d *detailsPanel) clear() { + d.title.SetText("No job selected") + d.folder.SetText("") + d.schedule.SetText("") + d.command.SetText("") + d.arguments.SetText("") + d.runMode.SetText("") + d.overlapPolicy.SetText("") + d.lastRun.SetText("") + d.nextRun.SetText("") + d.state.SetText("") + d.stats.SetText("") + d.commandOutput.SetText("") + d.selectedLogs = nil +} + +// container assembles the details pane layout: metadata rows pin to the top, +// the activity panel pins to the bottom, and command output fills the remainder. +func (d *detailsPanel) container() fyne.CanvasObject { + top := container.NewVBox( + d.title, + widget.NewSeparator(), + detailRow("Folder", d.folder), + detailRow("Schedule", d.schedule), + detailRow("Command", d.command), + detailRow("Arguments", d.arguments), + detailRow("Run mode", d.runMode), + detailRow("Overlap policy", d.overlapPolicy), + detailRow("Last run", d.lastRun), + detailRow("Next run", d.nextRun), + detailRow("State", d.state), + detailRow("Statistics", d.stats), + widget.NewSeparator(), + widget.NewLabelWithStyle("Command output", fyne.TextAlignLeading, fyne.TextStyle{Bold: true}), + ) + activity := container.NewVBox( + widget.NewSeparator(), + widget.NewLabelWithStyle("Selected job activity", fyne.TextAlignLeading, fyne.TextStyle{Bold: true}), + container.New(fixedHeightLayout{height: jobActivityHeight}, d.logs), + ) + return container.NewBorder(top, activity, nil, nil, d.commandOutputScroll) +} + +func detailRow(label string, value fyne.CanvasObject) fyne.CanvasObject { + caption := widget.NewLabelWithStyle(label, fyne.TextAlignLeading, fyne.TextStyle{Bold: true}) + caption.Wrapping = fyne.TextTruncate + return container.NewGridWithColumns(2, caption, value) +} + +func newJobDetailLabel(text string) *widget.Label { + label := widget.NewLabel(text) + // Job names, commands, and paths can be much wider than the details panel. + // Breaking long runs of text keeps Label.MinSize stable when the selection + // changes, so the right panel does not force the whole window to resize. + label.Wrapping = fyne.TextWrapBreak + return label +} diff --git a/src/ui/jobs_view_helpers.go b/src/ui/jobs_view_helpers.go new file mode 100644 index 0000000..2aff8bf --- /dev/null +++ b/src/ui/jobs_view_helpers.go @@ -0,0 +1,57 @@ +package ui + +import "strings" + +// 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 { + if folder == allFolders || filterValue(current.Folder) == folder { + indexes = append(indexes, index) + } + } + return indexes +} + +func folderOptions(jobs []job) []string { + // "All" and "No folder" are always present so the filter UI is stable even + // before the user creates folders. + options := []string{allFolders, noFolder} + seen := map[string]bool{allFolders: true, noFolder: true} + for _, current := range jobs { + folder := strings.TrimSpace(current.Folder) + if folder == "" || seen[folder] { + continue + } + seen[folder] = true + options = append(options, folder) + } + return options +} + +func filterValue(folder string) string { + if strings.TrimSpace(folder) == "" { + return noFolder + } + return strings.TrimSpace(folder) +} + +func indexOfID(jobs []job, id int) int { + for index, current := range jobs { + if current.ID == id { + return index + } + } + return 0 +}