refactor: split jobs_view, drop YAML migration, update docs (T6.1-T6.4)

T6.1: split jobs_view.go into three files — jobs_view_helpers.go (pure
helpers) and jobs_view_details.go (detailsPanel struct with widget
creation, update, clear, and container methods) — bringing jobs_view.go
from 459 to ~200 lines.

T6.2: remove stale YAML upgrade note from README; drop *.yaml from
.dockerignore.

T6.3: delete YAML shadow structs (yamlConfig/yamlJob/yamlJobsFile),
importYAMLConfig/importYAMLJobs, legacy path constants, and all
YAML-import tests; run go mod tidy to remove go.yaml.in/yaml/v4.

T6.4: refresh ARCHITECTURE.md — JSON storage references, new Key Domain
Concepts section (per-job overlap policy, run-time statistics + log
seeding, persisted pause flag, jobs_view split).

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
mixeme
2026-06-24 22:42:42 +03:00
parent d09b6e182c
commit 13f2779e1f
12 changed files with 302 additions and 407 deletions
-1
View File
@@ -4,5 +4,4 @@ dist
logs logs
gosentry.json gosentry.json
jobs.json jobs.json
*.yaml
*.exe *.exe
-4
View File
@@ -75,10 +75,6 @@ include the run timestamp and job name:
20260614-224306_Hello_scheduler.log 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 ## Schedules
Interval schedules using Go duration syntax: Interval schedules using Go duration syntax:
+72 -13
View File
@@ -14,7 +14,7 @@ src/
app/ Service — sole owner of job/runtime state; emits typed Events app/ Service — sole owner of job/runtime state; emits typed Events
scheduler/ pure timing loop; calls app.Service.RunDue on every tick scheduler/ pure timing loop; calls app.Service.RunDue on every tick
runner/ shell command execution + log file writing + cleanup runner/ shell command execution + log file writing + cleanup
storage/ YAML persistence (gosentry.yaml, jobs.yaml) storage/ JSON persistence (gosentry.json, jobs.json)
platform/ platform/
autostart/ Manager interface + Windows (shortcut) and Linux (XDG) impls autostart/ Manager interface + Windows (shortcut) and Linux (XDG) impls
desktop/ display-scale helper (Linux only) desktop/ display-scale helper (Linux only)
@@ -29,12 +29,12 @@ flowchart LR
user["Desktop user"] user["Desktop user"]
ui["src/ui\nFyne windows, tabs, dialogs"] ui["src/ui\nFyne windows, tabs, dialogs"]
svc["src/app Service\nsole owner of job + runtime state"] 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"] sched["src/scheduler Scheduler\npure timing loop"]
runner["src/runner\nshell command execution"] runner["src/runner\nshell command execution"]
autostart["src/platform/autostart Manager\nWindows shortcut / Linux XDG"] autostart["src/platform/autostart Manager\nWindows shortcut / Linux XDG"]
config["gosentry.yaml\napplication settings"] config["gosentry.json\napplication settings"]
jobs["jobs.yaml\njob definitions"] jobs["jobs.json\njob definitions"]
logs["logs_dir\nper-run command output logs"] logs["logs_dir\nper-run command output logs"]
shell["Platform shell\ncmd.exe /C or sh -c"] shell["Platform shell\ncmd.exe /C or sh -c"]
@@ -61,9 +61,11 @@ flowchart LR
1. Startup: 1. Startup:
`cmd/gosentry` calls `ui.Run`, which creates an `app.Service`, opens the `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 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: 2. Editing settings or jobs:
The UI calls mutating methods on `app.Service` (e.g. `CreateJob`, The UI calls mutating methods on `app.Service` (e.g. `CreateJob`,
@@ -79,18 +81,20 @@ flowchart LR
4. Manual run: 4. Manual run:
`Run now` in the UI calls `Service.RunNow`. The Service checks that the job `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 exists, is not already running, and that the scheduler is not globally paused,
executes `runner.RunJob` with the `Manual` trigger. then executes `runner.RunJob` with the `Manual` trigger.
5. Command execution: 5. Command execution:
`runner.RunJob` builds the platform-specific invocation, executes the `runner.RunJob` builds the platform-specific invocation, executes the
command through the platform shell, captures stdout and stderr, writes one 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: 6. History update:
When a run goroutine completes, `Service` updates the job's runtime, saves When a run goroutine completes, `Service` updates the job's runtime
YAML, triggers log cleanup, and emits `RunRecorded`. The UI observer appends (including the statistics aggregate), saves JSON, triggers log cleanup, and
the record to the History tab. emits `RunRecorded`. The UI observer appends the record to the History tab.
7. Autostart: 7. Autostart:
`UpdateSettings` in the Service calls `autostart.Manager.Set`. The Manager `UpdateSettings` in the Service calls `autostart.Manager.Set`. The Manager
@@ -99,6 +103,61 @@ flowchart LR
entries pass `--start-in-tray`. entries pass `--start-in-tray`.
8. Error surfacing: 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 `ErrorOccurred` events and displayed in the UI status area, rather than
being silently discarded. 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
`_<sanitized name>.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` |
+5 -5
View File
@@ -109,13 +109,13 @@ Done first because both share a compact, single-line record formatter.
- [x] T4.4 — persistence + restored-paused tests - [x] T4.4 — persistence + restored-paused tests
### Phase 5 — Window sizing ### Phase 5 — Window sizing
- [ ] T5.1 — 720p-safe default + MinSize - [x] T5.1 — 720p-safe default + MinSize
### Phase 6 — Refactor + cleanup ### Phase 6 — Refactor + cleanup
- [ ] T6.1 — `jobs_view.go` split - [x] T6.1 — `jobs_view.go` split
- [ ] T6.2 — post-field-test cleanup (keep startup timing) - [x] T6.2 — post-field-test cleanup (keep startup timing)
- [ ] T6.3 — drop YAML→JSON migration - [x] T6.3 — drop YAML→JSON migration
- [ ] T6.4 — ARCHITECTURE.md update - [x] T6.4 — ARCHITECTURE.md update
### Phase 7 — Portable packaging ### Phase 7 — Portable packaging
- [ ] T7.1 — Windows `.zip` - [ ] T7.1 — Windows `.zip`
-1
View File
@@ -5,7 +5,6 @@ go 1.22
require ( require (
fyne.io/fyne/v2 v2.7.4 fyne.io/fyne/v2 v2.7.4
github.com/robfig/cron/v3 v3.0.1 github.com/robfig/cron/v3 v3.0.1
go.yaml.in/yaml/v4 v4.0.0-rc.5
) )
require ( require (
-2
View File
@@ -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/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 h1:iERMLn0/QJeHFhxSt3p6PeN9mGnvIKSpG9YYorDMnic=
github.com/yuin/goldmark v1.7.8/go.mod h1:uzxRWxtg69N339t3louHJ7+O03ezfj6PlliRlaOzY1E= 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 h1:AN7zRgVsbvmTfNyqIbbOraYL8mSwcKncEj8ofjgzcMQ=
golang.org/x/image v0.24.0/go.mod h1:4b/ITuLfqYq1hqZcjofwctIhi7sZh2WaCjvsBNjjya8= golang.org/x/image v0.24.0/go.mod h1:4b/ITuLfqYq1hqZcjofwctIhi7sZh2WaCjvsBNjjya8=
golang.org/x/net v0.35.0 h1:T5GQRQb2y08kTAByq9L4/bz8cipCdA8FbRTXewonqY8= golang.org/x/net v0.35.0 h1:T5GQRQb2y08kTAByq9L4/bz8cipCdA8FbRTXewonqY8=
-4
View File
@@ -14,10 +14,6 @@ const (
// installed/copied program. // installed/copied program.
JobsFileName = "jobs.json" 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 // Paths contains both the physical program location and the resolved runtime
+3 -101
View File
@@ -9,7 +9,6 @@ import (
"strings" "strings"
"gitea.mixdep.ru/mix/gosentry/src/domain" "gitea.mixdep.ru/mix/gosentry/src/domain"
"go.yaml.in/yaml/v4"
) )
type Store struct { type Store struct {
@@ -17,41 +16,6 @@ type Store struct {
Config domain.Config 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) { func OpenStore() (*Store, []domain.Job, error) {
paths, err := ResolvePaths() paths, err := ResolvePaths()
if err != nil { if err != nil {
@@ -117,19 +81,9 @@ func loadOrCreateConfig(paths Paths) (domain.Config, error) {
} }
if _, err := os.Stat(paths.ConfigPath); errors.Is(err, os.ErrNotExist) { 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) return config, writeJSON(paths.ConfigPath, config)
} }
config = imported
} else {
data, err := os.ReadFile(paths.ConfigPath) data, err := os.ReadFile(paths.ConfigPath)
if err != nil { if err != nil {
return domain.Config{}, err return domain.Config{}, err
@@ -137,7 +91,6 @@ func loadOrCreateConfig(paths Paths) (domain.Config, error) {
if err := json.Unmarshal(data, &config); err != nil { if err := json.Unmarshal(data, &config); err != nil {
return domain.Config{}, err return domain.Config{}, err
} }
}
if strings.TrimSpace(config.JobsDir) == "" { if strings.TrimSpace(config.JobsDir) == "" {
// Empty paths are treated as missing values rather than intentional root // Empty paths are treated as missing values rather than intentional root
@@ -164,19 +117,8 @@ func loadOrCreateConfig(paths Paths) (domain.Config, error) {
func loadOrCreateJobs(path string) ([]domain.Job, error) { func loadOrCreateJobs(path string) ([]domain.Job, error) {
if _, err := os.Stat(path); errors.Is(err, os.ErrNotExist) { if _, err := os.Stat(path); errors.Is(err, os.ErrNotExist) {
// No JSON jobs file yet. Import a pre-migration jobs.yaml once if present; // Seed harmless sample jobs so a new user can immediately see scheduled
// otherwise seed harmless sample jobs so a new user can immediately see // and manual execution without inventing a command.
// 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
}
jobs := defaultJobs() jobs := defaultJobs()
normalizeJobs(jobs) normalizeJobs(jobs)
return jobs, writeJSON(path, domain.JobsFile{Jobs: jobs}) return jobs, writeJSON(path, domain.JobsFile{Jobs: jobs})
@@ -193,46 +135,6 @@ func loadOrCreateJobs(path string) ([]domain.Job, error) {
return file.Jobs, nil 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) { func normalizeJobs(jobs []domain.Job) {
next := 1 next := 1
for index := range jobs { for index := range jobs {
-89
View File
@@ -8,17 +8,8 @@ import (
"testing" "testing"
"gitea.mixdep.ru/mix/gosentry/src/domain" "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) { func TestJobsRoundTrip(t *testing.T) {
dir := t.TempDir() dir := t.TempDir()
path := filepath.Join(dir, "jobs.json") 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) { func TestLoadOrCreateConfigCreatesDefaultsOnFirstRun(t *testing.T) {
dir := t.TempDir() dir := t.TempDir()
paths := Paths{ 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")
}
}
+13 -179
View File
@@ -2,7 +2,6 @@ package ui
import ( import (
"fmt" "fmt"
"strings"
"gitea.mixdep.ru/mix/gosentry/src/app" "gitea.mixdep.ru/mix/gosentry/src/app"
"gitea.mixdep.ru/mix/gosentry/src/domain" "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 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}) dp := newDetailsPanel(jobs[selected], runtimeFor(selected), svc.Store().Config.OverlapPolicy)
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]))
},
)
updateDetails := func(index int) { updateDetails := func(index int) {
if index < 0 || index >= len(jobs) { if index < 0 || index >= len(jobs) {
// A folder filter can temporarily leave no selectable rows. Clearing // A folder filter can temporarily leave no selectable rows. Clearing
// the details panel avoids showing stale information for a hidden job. // the details panel avoids showing stale information for a hidden job.
title.SetText("No job selected") dp.clear()
folderLabel.SetText("")
scheduleLabel.SetText("")
commandLabel.SetText("")
argumentsLabel.SetText("")
runModeLabel.SetText("")
lastRunLabel.SetText("")
nextRunLabel.SetText("")
stateLabel.SetText("")
statsLabel.SetText("")
overlapPolicyLabel.SetText("")
commandOutput.SetText("")
selectedLogs = nil
return return
} }
selected = index selected = index
current := jobs[selected] dp.update(jobs[selected], runtimeFor(selected), svc.Store().Config.OverlapPolicy)
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)
} }
// list and folderSelect are declared early so closures below can reference // 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() syncFromService()
filteredJobs = filteredJobIndexes(jobs, selectedFolder) filteredJobs = filteredJobIndexes(jobs, selectedFolder)
updateDetails(selected) updateDetails(selected)
jobLogs.Refresh() dp.logs.Refresh()
if list != nil { if list != nil {
list.Refresh() 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() { 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) { 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) created, err := svc.CreateJob(saved)
if err != nil { if err != nil {
dialog.ShowError(err, w) dialog.ShowError(err, w)
@@ -236,9 +169,6 @@ func newJobsView(w fyne.Window, svc *app.Service) (fyne.CanvasObject, func()) {
return return
} }
showJobDialog(w, "Edit job", jobs[selected], func(saved job) { 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 saved.ID = jobs[selected].ID
if err := svc.UpdateJob(saved); err != nil { if err := svc.UpdateJob(saved); err != nil {
dialog.ShowError(err, w) dialog.ShowError(err, w)
@@ -269,15 +199,20 @@ func newJobsView(w fyne.Window, svc *app.Service) (fyne.CanvasObject, func()) {
list.Refresh() list.Refresh()
refreshView() refreshView()
}) })
stopAllText, stopAllIcon := "Pause all", theme.MediaStopIcon() stopAllText, stopAllIcon := "Pause all", theme.MediaStopIcon()
if schedulerPaused { if schedulerPaused {
stopAllText, stopAllIcon = "Resume all", theme.MediaPlayIcon() 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 := 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 pause flag, updates every job's next-run text,
// next-run text, and emits the activity record the observer logs. Mirror the // and emits the activity record the observer logs. Revert if the save fails.
// new state into the local flag and the controls; revert it if the save fails.
schedulerPaused = !schedulerPaused schedulerPaused = !schedulerPaused
if err := svc.SetGlobalPause(schedulerPaused); err != nil { if err := svc.SetGlobalPause(schedulerPaused); err != nil {
schedulerPaused = !schedulerPaused schedulerPaused = !schedulerPaused
@@ -300,8 +235,6 @@ func newJobsView(w fyne.Window, svc *app.Service) (fyne.CanvasObject, func()) {
if selected < 0 || selected >= len(jobs) { if selected < 0 || selected >= len(jobs) {
return return
} }
// SetEnabled toggles the job, updates its runtime/next-run, and records the
// "Resumed"/"Paused" activity the observer logs.
current := jobs[selected] current := jobs[selected]
if err := svc.SetEnabled(current.ID, !current.Enabled); err != nil { if err := svc.SetEnabled(current.ID, !current.Enabled); err != nil {
dialog.ShowError(err, w) dialog.ShowError(err, w)
@@ -322,9 +255,6 @@ func newJobsView(w fyne.Window, svc *app.Service) (fyne.CanvasObject, func()) {
if !confirm { if !confirm {
return 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 { if err := svc.DeleteJob(deleted.ID); err != nil {
dialog.ShowError(err, w) dialog.ShowError(err, w)
return 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) 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)
// 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) 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 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
}
+144
View File
@@ -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
}
+57
View File
@@ -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
}