Compare commits
3 Commits
98c820e3bd
...
bd7ebde68e
| Author | SHA1 | Date | |
|---|---|---|---|
| bd7ebde68e | |||
| ca2a8c8aa7 | |||
| 0c8442a8d1 |
+29
-13
@@ -136,10 +136,13 @@ example window-maximized detection, which would need per-OS native calls).
|
|||||||
`UpdateSettings` has one extra step: when the configured jobs file changes
|
`UpdateSettings` has one extra step: when the configured jobs file changes
|
||||||
and a file already exists at the new path, that file is authoritative. The
|
and a file already exists at the new path, that file is authoritative. The
|
||||||
Service loads it, calls `adoptJobsLocked` to rebuild the jobs slice, runtime
|
Service loads it, calls `adoptJobsLocked` to rebuild the jobs slice, runtime
|
||||||
map, schedule cache, next-run times, and log-seeded statistics around it, and
|
map, schedule cache, and next-run times around it, applies the statistics
|
||||||
emits `JobsLoaded` plus a broad `JobChanged`. A path with no file behind it
|
seeded from the new logs directory, and emits `JobsLoaded` plus a broad
|
||||||
receives the current jobs instead. Adoption drops all runtime state, so it is
|
`JobChanged`. A path with no file behind it receives the current jobs instead.
|
||||||
refused while a job is running.
|
Adoption drops all runtime state, so it is refused while a job is running.
|
||||||
|
Reading the new file and seeding its statistics both happen before `mu` is
|
||||||
|
taken (the no-I/O-under-`mu` rule in [STANDARDS.md](STANDARDS.md)), so the
|
||||||
|
running-job check is re-evaluated under the lock before anything is replaced.
|
||||||
|
|
||||||
3. Scheduled run:
|
3. Scheduled run:
|
||||||
`scheduler.Scheduler` fires a tick every second. On each tick it calls
|
`scheduler.Scheduler` fires a tick every second. On each tick it calls
|
||||||
@@ -163,8 +166,9 @@ example window-maximized detection, which would need per-OS native calls).
|
|||||||
|
|
||||||
6. History update:
|
6. History update:
|
||||||
When a run goroutine completes, `Service` updates the job's runtime
|
When a run goroutine completes, `Service` updates the job's runtime
|
||||||
(including the statistics aggregate), saves JSON, triggers log cleanup, and
|
(including the statistics aggregate) under `mu`, then — after releasing it —
|
||||||
emits `RunRecorded`. The UI observer appends the record to the History tab.
|
runs log cleanup and emits `RunRecorded`. Nothing is saved: a run changes only
|
||||||
|
`JobRuntime`, which is never persisted. The UI observer appends the record to the History tab.
|
||||||
History rows exist only for the current process session; restarting the app
|
History rows exist only for the current process session; restarting the app
|
||||||
clears the table (aggregate stats in the details panel are still seeded from
|
clears the table (aggregate stats in the details panel are still seeded from
|
||||||
log files).
|
log files).
|
||||||
@@ -220,9 +224,9 @@ resolves the effective duration under `mu` and `startRunLocked` snapshots it int
|
|||||||
resolved duration as an argument, so the runner stays ignorant of the global
|
resolved duration as an argument, so the runner stays ignorant of the global
|
||||||
config: a positive duration applies the timeout via `context.WithTimeout` and
|
config: a positive duration applies the timeout via `context.WithTimeout` and
|
||||||
reports `Timed out after <timeout>` on expiry; a non-positive duration runs
|
reports `Timed out after <timeout>` on expiry; a non-positive duration runs
|
||||||
without a deadline, bounded only by `ctx` (app shutdown). `StartOnly` jobs run on
|
without a deadline, bounded only by `ctx` (app shutdown). `StartOnly` jobs are
|
||||||
the untimed context and so measure launch latency only, unaffected by the run
|
built on `context.Background()` instead — neither the timeout nor app shutdown
|
||||||
timeout.
|
applies to them — and so measure launch latency only.
|
||||||
|
|
||||||
### Run-time statistics
|
### Run-time statistics
|
||||||
|
|
||||||
@@ -266,16 +270,28 @@ the moment the window opens.
|
|||||||
### `jobs_view.go` file structure
|
### `jobs_view.go` file structure
|
||||||
|
|
||||||
The size guideline for a file in this project is ~250 lines.
|
The size guideline for a file in this project is ~250 lines.
|
||||||
`src/ui/jobs_view.go` is split across three files along these seams; the view
|
`src/ui/jobs_view.go` is split across five files along these seams:
|
||||||
file itself has grown back over the guideline since — see the split item in
|
|
||||||
[ROADMAP.md](ROADMAP.md), which tracks every file currently over it:
|
|
||||||
|
|
||||||
| File | Contents |
|
| File | Contents |
|
||||||
|------|----------|
|
|------|----------|
|
||||||
| `jobs_view.go` | `newJobsView` — list, toolbar, button wiring, and layout |
|
| `jobs_view.go` | `jobsView` struct — construction, `refresh`, `updateDetails`, the pause control, and layout assembly |
|
||||||
|
| `jobs_view_state.go` | `jobsViewState` — the jobs/runtime snapshot, the folder filter, and the selection |
|
||||||
|
| `jobs_view_list.go` | The sidebar list: row template, row rendering, row mode, and the compact/detailed toggle |
|
||||||
|
| `jobs_view_toolbar.go` | The per-job button row — new, edit, run, pause, delete |
|
||||||
| `jobs_view_details.go` | `detailsPanel` struct — widget creation, `update`, `clear`, `container` |
|
| `jobs_view_details.go` | `detailsPanel` struct — widget creation, `update`, `clear`, `container` |
|
||||||
| `jobs_view_helpers.go` | Pure helpers — `filteredJobIndexes`, `folderOptions`, `filterValue`, `indexOfID`, `lastJobLogs`, `nextJobListView`, `viewToggleText` |
|
| `jobs_view_helpers.go` | Pure helpers — `filteredJobIndexes`, `folderOptions`, `filterValue`, `indexOfID`, `lastJobLogs`, `nextJobListView`, `viewToggleText` |
|
||||||
|
|
||||||
|
The widgets hold no job state of their own: they read `jobsViewState`, which is
|
||||||
|
the only thing that reads the Service. The **selection is a job ID, not a row
|
||||||
|
index.** Every path that changes the job list replaces the state's snapshot —
|
||||||
|
create, delete, and edit from this view's own handlers, adopting a different
|
||||||
|
jobs file from the Service, which the view only learns about through the refresh
|
||||||
|
`JobsLoaded` triggers. An index that outlives its snapshot points at whichever
|
||||||
|
job now sits there, so the details pane would describe one job while the list
|
||||||
|
highlighted another. Rows are derived from the ID at render time
|
||||||
|
(`selectedIndex`, `displayRow`), and `jobsView.refresh` ends by pointing the
|
||||||
|
list's highlight at the selected job.
|
||||||
|
|
||||||
### `settings_view.go` file structure
|
### `settings_view.go` file structure
|
||||||
|
|
||||||
`src/ui/settings_view.go` is split across three files the same way, once its
|
`src/ui/settings_view.go` is split across three files the same way, once its
|
||||||
|
|||||||
@@ -39,6 +39,11 @@ the app icon (experimental).**
|
|||||||
longer than its own interval no longer accumulates an unbounded backlog that
|
longer than its own interval no longer accumulates an unbounded backlog that
|
||||||
then runs back-to-back indefinitely. The job details pane now shows the
|
then runs back-to-back indefinitely. The job details pane now shows the
|
||||||
queued-run count (", N queued") whenever it is non-zero.
|
queued-run count (", N queued") whenever it is non-zero.
|
||||||
|
- **Start-only jobs are no longer tied to the application's lifetime.** A job
|
||||||
|
with *Start only* checked is launched on an uncancelable context, so quitting
|
||||||
|
GoSentry (or a run context being cancelled) can no longer try to kill a
|
||||||
|
process it deliberately stopped waiting for. This also removes a goroutine
|
||||||
|
that leaked on every start-only run and lived until the app exited.
|
||||||
- The History tab no longer grows without bound: it keeps the newest 1000
|
- The History tab no longer grows without bound: it keeps the newest 1000
|
||||||
records and drops the oldest, the way a job's own activity list is capped.
|
records and drops the oldest, the way a job's own activity list is capped.
|
||||||
Column widths are also folded in one record at a time instead of being
|
Column widths are also folded in one record at a time instead of being
|
||||||
@@ -46,6 +51,17 @@ the app icon (experimental).**
|
|||||||
gets slower the longer the app has been running. Measured on 5000 accumulated
|
gets slower the longer the app has been running. Measured on 5000 accumulated
|
||||||
records, one History redraw went from **15.8 ms to 0.9 ms**; at the new cap
|
records, one History redraw went from **15.8 ms to 0.9 ms**; at the new cap
|
||||||
the width rescan alone accounted for 1.5 ms of every redraw.
|
the width rescan alone accounted for 1.5 ms of every redraw.
|
||||||
|
- **The Jobs tab keeps its selection on the job, not on the row.** Selecting a
|
||||||
|
different jobs file in Settings replaces the whole job list; the details pane
|
||||||
|
then described whichever job happened to land on the previously selected row —
|
||||||
|
or went blank if the new list was shorter — while the highlight in the list
|
||||||
|
stayed where it was. The selection now follows the job itself, and the
|
||||||
|
highlight and the details pane always describe the same one.
|
||||||
|
- **Max log files and max log age days now accept 0, meaning "keep
|
||||||
|
everything."** Log cleanup already supported disabling either policy; the
|
||||||
|
Settings form and the Service validator rejected the value that would have
|
||||||
|
turned it on. A config that already set either to 0 is no longer silently
|
||||||
|
rewritten back to the 100/30 defaults on load.
|
||||||
|
|
||||||
**Jobs:**
|
**Jobs:**
|
||||||
|
|
||||||
@@ -64,6 +80,19 @@ the app icon (experimental).**
|
|||||||
- App-side failure-notification timing is appended to `logs/notify-timing.log`
|
- App-side failure-notification timing is appended to `logs/notify-timing.log`
|
||||||
for diagnosing toast delay (OS latency excluded). `scripts/measure-windows-toast.ps1`
|
for diagnosing toast delay (OS latency excluded). `scripts/measure-windows-toast.ps1`
|
||||||
measures the PowerShell baseline on Windows.
|
measures the PowerShell baseline on Windows.
|
||||||
|
- File I/O no longer happens while `Service.mu` is held — that is the lock the
|
||||||
|
UI thread takes on every job and runtime read, so a JSON write, the
|
||||||
|
post-run log cleanup, or the startup log scan used to make a UI refresh wait
|
||||||
|
on the disk. Saves are now prepared under the lock and written after it is
|
||||||
|
released, in preparation order, so `jobs.json` still ends up matching the
|
||||||
|
in-memory list. Seeding statistics from logs also opens each log file once
|
||||||
|
instead of twice.
|
||||||
|
- The Jobs tab was split into `jobs_view.go` (construction, refresh, layout),
|
||||||
|
`jobs_view_state.go` (the job/runtime snapshot, folder filter, and selection),
|
||||||
|
`jobs_view_list.go`, and `jobs_view_toolbar.go`. What used to be one 330-line
|
||||||
|
constructor whose dozen closures shared seven mutable locals is now widgets
|
||||||
|
reading one named state object — which is what made the selection fix above a
|
||||||
|
change in one place instead of five.
|
||||||
|
|
||||||
## 1.0.1 - 2026-08-04
|
## 1.0.1 - 2026-08-04
|
||||||
|
|
||||||
|
|||||||
+22
-20
@@ -123,24 +123,24 @@ Design notes / open questions:
|
|||||||
|
|
||||||
[ARCHITECTURE.md](ARCHITECTURE.md) sets a ~250-line guideline per source file
|
[ARCHITECTURE.md](ARCHITECTURE.md) sets a ~250-line guideline per source file
|
||||||
and records the `jobs_view.go` and `settings_view.go` splits as the worked
|
and records the `jobs_view.go` and `settings_view.go` splits as the worked
|
||||||
examples. Six non-test files are over it at 1.0.0, including both files that
|
examples. `jobs_view.go` was split again in 1.0.2 — into view, state, list, and
|
||||||
were already split once:
|
toolbar — because the selection defect it carried was a symptom of the size
|
||||||
|
(one 330-line constructor over seven shared locals). Five non-test files are
|
||||||
|
over the guideline as of that pass:
|
||||||
|
|
||||||
| File | Lines |
|
| File | Lines |
|
||||||
|------|-------|
|
|------|-------|
|
||||||
| `src/app/operations.go` | 490 |
|
| `src/app/operations.go` | 529 |
|
||||||
| `src/ui/jobs_view.go` | 355 |
|
| `src/ui/history_view.go` | 373 |
|
||||||
| `src/app/run.go` | 287 |
|
| `src/storage/store.go` | 365 |
|
||||||
| `src/ui/history_view.go` | 282 |
|
| `src/ui/settings_view.go` | 318 |
|
||||||
| `src/ui/settings_view.go` | 277 |
|
| `src/app/run.go` | 274 |
|
||||||
| `src/storage/store.go` | 265 |
|
|
||||||
|
|
||||||
This is deliberately deferred to the next whole-project review rather than done
|
The remaining five are deliberately deferred rather than done piecemeal: a
|
||||||
piecemeal: a future review already asks item 2 to look for exactly this,
|
split touches every reader of the file, and doing them in one pass keeps the
|
||||||
a split touches every reader of the file, and doing all six in one pass keeps
|
seams consistent instead of settling them five different ways. Splitting is
|
||||||
the seams consistent instead of settling them six different ways. Splitting is
|
|
||||||
also the kind of change that reads as pure movement while quietly dropping a
|
also the kind of change that reads as pure movement while quietly dropping a
|
||||||
function, so it wants one careful pass, not six hurried ones.
|
function, so it wants one careful pass, not five hurried ones.
|
||||||
|
|
||||||
Seams visible today, as a starting point rather than a decision:
|
Seams visible today, as a starting point rather than a decision:
|
||||||
|
|
||||||
@@ -152,13 +152,15 @@ Seams visible today, as a starting point rather than a decision:
|
|||||||
- **`history_view.go`** — the column-measuring helpers (`textWidth` through
|
- **`history_view.go`** — the column-measuring helpers (`textWidth` through
|
||||||
`historyColumnWidths`) are pure, already unit-tested, and independent of the
|
`historyColumnWidths`) are pure, already unit-tested, and independent of the
|
||||||
table they size.
|
table they size.
|
||||||
- **`jobs_view.go`** — nearly all of it is one `newJobsView` constructor, so the
|
- **`store.go`** — path resolution, the config load/normalize path, and the jobs
|
||||||
split has to break that function up (list template, toolbar handlers,
|
load/normalize path are three separate concerns in one file.
|
||||||
assembly) rather than move whole functions. Larger judgement call than the
|
- **`run.go`**, **`settings_view.go`** — barely over. Worth re-measuring at the
|
||||||
others.
|
time; if a pass elsewhere has shrunk them, leave them alone rather than
|
||||||
- **`run.go`**, **`settings_view.go`**, **`store.go`** — barely over. Worth
|
splitting for the sake of the number.
|
||||||
re-measuring at the time; if a pass elsewhere has shrunk them, leave them
|
|
||||||
alone rather than splitting for the sake of the number.
|
The `jobs_view.go` pass is the worked example for the rest: the constructor was
|
||||||
|
broken up along the state it shared, not along line count, and the split landed
|
||||||
|
with the selection fix rather than promising it separately.
|
||||||
|
|
||||||
Scope note: the guideline is about source files. Test files are much larger and
|
Scope note: the guideline is about source files. Test files are much larger and
|
||||||
that is fine — a table-driven test file grows with the cases it covers.
|
that is fine — a table-driven test file grows with the cases it covers.
|
||||||
|
|||||||
@@ -11,6 +11,15 @@ in [ARCHITECTURE.md](ARCHITECTURE.md); test conventions in [TESTS.md](TESTS.md).
|
|||||||
- Fixes with severity ≥ medium → regression test.
|
- Fixes with severity ≥ medium → regression test.
|
||||||
- Documented intentional behavior → section below, not a backlog bug.
|
- Documented intentional behavior → section below, not a backlog bug.
|
||||||
- UI view constructors accept `*app.Service`; call `app.Open()` only from `run.go`.
|
- UI view constructors accept `*app.Service`; call `app.Open()` only from `run.go`.
|
||||||
|
- **No blocking file I/O under `Service.mu`.** It is the lock the Fyne main
|
||||||
|
thread takes on every `Jobs()` and `Runtime()` call, so a JSON write, a
|
||||||
|
log-directory scan, or a pass over every log header inside it makes a UI
|
||||||
|
refresh wait on the disk. Mutate state under the lock, snapshot what the I/O
|
||||||
|
needs, and run the I/O after `mu.Unlock()` — the way `emit()` already is.
|
||||||
|
Store writes go through `Service.deferSaveLocked` and `Store.PrepareSaveJobs` /
|
||||||
|
`Store.PrepareSaveConfig`, which take `saveMu` while `mu` is still held so
|
||||||
|
writes still reach the file in the order their snapshots were taken; log
|
||||||
|
cleanup and `runner.SeedStats` run from plain snapshots.
|
||||||
- A size that must follow the theme is **measured at build time, not written as
|
- A size that must follow the theme is **measured at build time, not written as
|
||||||
a pixel constant.** `theme.Padding()` and text metrics depend on the running
|
a pixel constant.** `theme.Padding()` and text metrics depend on the running
|
||||||
app's theme, text size, and DPI, so a hand-tuned number is only correct for
|
app's theme, text size, and DPI, so a hand-tuned number is only correct for
|
||||||
@@ -62,6 +71,22 @@ change to their shape has to stay compatible on its own.
|
|||||||
= 0) and is overridable per job (`Job.TimeoutSeconds *int`: unset = inherit the
|
= 0) and is overridable per job (`Job.TimeoutSeconds *int`: unset = inherit the
|
||||||
global default, 0 = no timeout, positive = seconds). Neither zero may be
|
global default, 0 = no timeout, positive = seconds). Neither zero may be
|
||||||
normalized away on load — 0 is a value, not a missing field.
|
normalized away on load — 0 is a value, not a missing field.
|
||||||
|
- **`Config.MaxLogFiles` and `Config.MaxLogAgeDays` of 0 mean "keep everything",
|
||||||
|
not "unset".** `runner.CleanupLogs` already treated `<= 0` as "policy
|
||||||
|
disabled"; `app.validateConfig` and the Settings form now accept 0 (only a
|
||||||
|
negative count is rejected), and `storage.loadOrCreateConfig` no longer
|
||||||
|
backfills 0 to 100 / 30 — a config written before either field existed still
|
||||||
|
picks up the default because `json.Unmarshal` leaves an absent key holding
|
||||||
|
whatever `DefaultConfig()` set, the same mechanism `DefaultTimeoutSeconds`
|
||||||
|
relies on.
|
||||||
|
- **A `StartOnly` process is expected to outlive GoSentry.** The option exists to
|
||||||
|
launch something and let go of it, so the runner builds that invocation on
|
||||||
|
`context.Background()`, not on the application's lifecycle context: quitting
|
||||||
|
GoSentry (or cancelling a run) does not stop a process it started this way, and
|
||||||
|
`Service.Stop()` reaches only jobs the runner is still waiting on. The
|
||||||
|
uncancelable context is also what keeps `os/exec` from leaving a watcher
|
||||||
|
goroutine per run — it only starts one when the context can be done, and
|
||||||
|
`StartOnly` never calls `Wait` to end it.
|
||||||
- **History tab is session-only.** `JobRuntime.Logs` exists only in memory for the
|
- **History tab is session-only.** `JobRuntime.Logs` exists only in memory for the
|
||||||
current process. Log files on disk feed aggregate statistics via `SeedStats`
|
current process. Log files on disk feed aggregate statistics via `SeedStats`
|
||||||
only. See [ARCHITECTURE.md](ARCHITECTURE.md).
|
only. See [ARCHITECTURE.md](ARCHITECTURE.md).
|
||||||
|
|||||||
+27
-1
@@ -166,6 +166,8 @@ Tests all mutating operations on the Service, scheduler integration, and setting
|
|||||||
| `TestUpdateSettingsAdoptsExistingJobsFile` | Verifies that selecting a jobs file that already exists replaces the job list with its contents, rebuilds runtimes, and emits `JobsLoaded`. |
|
| `TestUpdateSettingsAdoptsExistingJobsFile` | Verifies that selecting a jobs file that already exists replaces the job list with its contents, rebuilds runtimes, and emits `JobsLoaded`. |
|
||||||
| `TestUpdateSettingsKeepsJobsWhenTheNewFileIsMissing` | Verifies that a path with no file behind it receives the current jobs instead (the rename/relocate case). |
|
| `TestUpdateSettingsKeepsJobsWhenTheNewFileIsMissing` | Verifies that a path with no file behind it receives the current jobs instead (the rename/relocate case). |
|
||||||
| `TestUpdateSettingsRefusesJobsFileSwitchWhileRunning` | Verifies that switching the jobs file is refused (and not persisted) while a job runs, while unrelated settings still save. |
|
| `TestUpdateSettingsRefusesJobsFileSwitchWhileRunning` | Verifies that switching the jobs file is refused (and not persisted) while a job runs, while unrelated settings still save. |
|
||||||
|
| `TestUpdateSettingsSeedsAdoptedJobsFromLogs` | Verifies that statistics reconstructed from the new logs directory still reach the runtime map, now that the log scan happens before `UpdateSettings` takes `mu`. |
|
||||||
|
| `TestConcurrentJobOperationsLeaveTheFileMatchingMemory` | Verifies that saves prepared under `mu` and run after it is released still land in mutation order, so `jobs.json` matches the in-memory list after concurrent create/disable operations. |
|
||||||
| `TestSetJobListViewPersistsToConfigFile` | Verifies the Jobs-list density preference reaches `gosentry.json`, so the chosen view reopens after a restart. |
|
| `TestSetJobListViewPersistsToConfigFile` | Verifies the Jobs-list density preference reaches `gosentry.json`, so the chosen view reopens after a restart. |
|
||||||
| `TestSetJobListViewNormalizesUnknownValue` | Verifies anything but `"compact"` is stored as `"detailed"`, so the config never gains a value no reader understands. |
|
| `TestSetJobListViewNormalizesUnknownValue` | Verifies anything but `"compact"` is stored as `"detailed"`, so the config never gains a value no reader understands. |
|
||||||
| `TestPrependLogCapsActivityList` | Verifies that the activity log never grows beyond its maximum cap. |
|
| `TestPrependLogCapsActivityList` | Verifies that the activity log never grows beyond its maximum cap. |
|
||||||
@@ -319,6 +321,7 @@ Tests command execution, exit code handling, output capture, and the run timeout
|
|||||||
|------|---------|
|
|------|---------|
|
||||||
| `TestRunJobStartOnlyDoesNotWaitForExitCode` | Verifies that `StartOnly: true` jobs launch and return "OK" immediately without waiting for the process to exit. |
|
| `TestRunJobStartOnlyDoesNotWaitForExitCode` | Verifies that `StartOnly: true` jobs launch and return "OK" immediately without waiting for the process to exit. |
|
||||||
| `TestRunJobStartOnlyReportsStartFailure` | Verifies that `StartOnly: true` jobs still report "Failed" if the process cannot be started. |
|
| `TestRunJobStartOnlyReportsStartFailure` | Verifies that `StartOnly: true` jobs still report "Failed" if the process cannot be started. |
|
||||||
|
| `TestRunJobStartOnlyLeavesNoContextWatcher` | Verifies that a start-only run leaves no `os/exec` context-watcher goroutine behind, since it never calls `Wait` and the started process is meant to outlive the app. |
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@@ -468,11 +471,34 @@ widgets are assembled.
|
|||||||
| `TestJobListViewCompactConfigOpensCompact` | Verifies the persisted density is honoured at build time, not only after a tap. |
|
| `TestJobListViewCompactConfigOpensCompact` | Verifies the persisted density is honoured at build time, not only after a tap. |
|
||||||
| `TestJobsSidebarWidthIsItsContent` | Regression guard: nothing but the sidebar's own toolbar row imposes a width floor on it. |
|
| `TestJobsSidebarWidthIsItsContent` | Regression guard: nothing but the sidebar's own toolbar row imposes a width floor on it. |
|
||||||
| `TestJobsSplitOpensAtTheSidebarWidth` | Verifies the derived split offset opens the divider at the sidebar's own width at the default window size — enough that the toolbar is never born clipped, and no more. |
|
| `TestJobsSplitOpensAtTheSidebarWidth` | Verifies the derived split offset opens the divider at the sidebar's own width at the default window size — enough that the toolbar is never born clipped, and no more. |
|
||||||
| `TestToolbarButtonRedrawsRowAndDetails` | Regression guard: with the duplicate refreshes removed from the handlers, `refreshView` alone must re-snapshot the jobs and repopulate the details pane. |
|
| `TestToolbarButtonRedrawsRowAndDetails` | Regression guard: with the duplicate refreshes removed from the handlers, `jobsView.refresh` alone must re-snapshot the jobs and repopulate the details pane. |
|
||||||
|
| `TestJobsViewSelectionSurvivesAJobsFileSwitch` | Regression guard: adopting a different jobs file replaces the whole list from the Service, and the refresh that follows must leave the details pane and the list highlight describing the same job — not redraw the pane from a row index that belonged to the previous list. |
|
||||||
| `TestDetailCaptionWidthCoversEveryCaption` | Verifies every caption `metadataRows` returns fits the measured caption column, which is what makes the single row list self-enforcing. |
|
| `TestDetailCaptionWidthCoversEveryCaption` | Verifies every caption `metadataRows` returns fits the measured caption column, which is what makes the single row list self-enforcing. |
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
### src/ui/jobs_view_state_test.go
|
||||||
|
|
||||||
|
**Package:** `ui`
|
||||||
|
|
||||||
|
Tests `jobsViewState`, the Jobs tab's model: the job/runtime snapshot, the
|
||||||
|
folder filter, and the ID-based selection. No Fyne app is built — the state
|
||||||
|
touches no widgets, so these run in milliseconds.
|
||||||
|
|
||||||
|
| Test | Purpose |
|
||||||
|
|------|---------|
|
||||||
|
| `TestJobsViewStateSelectsTheFirstJob` | Verifies the opening state selects the first row, so the details pane is never blank when there is something to show. |
|
||||||
|
| `TestJobsViewStateEmptyListSelectsNothing` | Verifies an empty job list leaves nothing selected and no row to highlight (`displayRow` = -1). |
|
||||||
|
| `TestJobsViewStateSelectionFollowsTheJobNotTheRow` | Regression guard: a job removed above the selected one (through the Service, the way an external change reaches the view) must not slide the selection onto its neighbour — the selection is a job ID, and only its row moves. |
|
||||||
|
| `TestJobsViewStateDropsSelectionWhenItsJobIsGone` | Verifies a selection whose job no longer exists falls back to the first visible row instead of describing whichever job inherited its position. |
|
||||||
|
| `TestJobsViewStateApplyFilter` | Verifies the folder filter keeps a selection it still shows, moves it to the folder's first row when it does not, and that "No folder" matches the job without one. |
|
||||||
|
| `TestJobsViewStateEmptyFilterSelectsNothing` | Verifies a filter matching no job is a filter choice, not an error state: nothing selected, nothing highlighted, and the selection returns when the filter is cleared. |
|
||||||
|
| `TestJobsViewStateHiddenSelectionIsNotHighlighted` | Verifies a selected job the filter hides reports no display row rather than falling back to row 0, which would highlight an unrelated job. |
|
||||||
|
| `TestJobsViewStateRuntimeIsNeverNil` | Verifies `runtime` returns an empty `JobRuntime` for a job the Service has none for, so callers need no nil check. |
|
||||||
|
| `TestJobsViewStateJobAtRejectsRowsOutsideTheFilter` | Verifies row lookups are bounded by the filtered rows, which is what the list widget draws from. |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
### src/ui/history_view_test.go
|
### src/ui/history_view_test.go
|
||||||
|
|
||||||
**Package:** `ui`
|
**Package:** `ui`
|
||||||
|
|||||||
+6
-3
@@ -4,9 +4,12 @@ import "gitea.mixdep.ru/mix/gosentry/src/domain"
|
|||||||
|
|
||||||
// Event is something the Service did to its state that observers may want to
|
// Event is something the Service did to its state that observers may want to
|
||||||
// react to. It is a sealed interface: the concrete types in this file are the
|
// react to. It is a sealed interface: the concrete types in this file are the
|
||||||
// only implementations (enforced by the unexported isEvent marker), so a UI
|
// only implementations (enforced by the unexported isEvent marker), so an
|
||||||
// listener can exhaustively type-switch over them and the compiler will flag a
|
// Event handed to an Observer is always one of the types declared here — a
|
||||||
// new event type that a switch forgot to handle.
|
// caller outside this package cannot manufacture a new one. Go's type switch
|
||||||
|
// has no exhaustiveness check, so sealing buys that guarantee, not a
|
||||||
|
// compile-time warning when a new event type is added and a listener forgets
|
||||||
|
// to handle it; the listener still has to be updated by hand.
|
||||||
//
|
//
|
||||||
// Events replace the old single onChange callback. Instead of the scheduler
|
// Events replace the old single onChange callback. Instead of the scheduler
|
||||||
// reaching into the GUI, the Service emits typed events and the UI subscribes —
|
// reaching into the GUI, the Service emits typed events and the UI subscribes —
|
||||||
|
|||||||
+66
-35
@@ -43,15 +43,23 @@ func (s *Service) CreateJob(job domain.Job) (domain.Job, error) {
|
|||||||
s.parseScheduleLocked(&job)
|
s.parseScheduleLocked(&job)
|
||||||
record := uiRecord(job.ID, job.Name, "Created", "Job was added")
|
record := uiRecord(job.ID, job.Name, "Created", "Job was added")
|
||||||
prependLog(runtime, record)
|
prependLog(runtime, record)
|
||||||
err := s.store.SaveJobs(s.jobs)
|
save := s.deferSaveLocked(s.store.PrepareSaveJobs(s.jobs))
|
||||||
if err != nil {
|
s.mu.Unlock()
|
||||||
s.jobs = s.jobs[:len(s.jobs)-1]
|
|
||||||
|
if err := save(); err != nil {
|
||||||
|
// The write is atomic, so a failure left the file holding the previous
|
||||||
|
// list: take the job back out so memory matches what is on disk. Another
|
||||||
|
// operation may have run in between, so it is removed by ID rather than by
|
||||||
|
// truncating the slice.
|
||||||
|
s.mu.Lock()
|
||||||
|
if index := s.indexByIDLocked(job.ID); index >= 0 {
|
||||||
|
s.jobs = append(s.jobs[:index], s.jobs[index+1:]...)
|
||||||
|
}
|
||||||
delete(s.runtimes, job.ID)
|
delete(s.runtimes, job.ID)
|
||||||
delete(s.schedules, job.ID)
|
delete(s.schedules, job.ID)
|
||||||
s.mu.Unlock()
|
s.mu.Unlock()
|
||||||
return domain.Job{}, err
|
return domain.Job{}, err
|
||||||
}
|
}
|
||||||
s.mu.Unlock()
|
|
||||||
s.emit(RunRecorded{Record: record})
|
s.emit(RunRecorded{Record: record})
|
||||||
s.emit(JobChanged{JobID: job.ID})
|
s.emit(JobChanged{JobID: job.ID})
|
||||||
return job, nil
|
return job, nil
|
||||||
@@ -87,10 +95,10 @@ func (s *Service) UpdateJob(job domain.Job) error {
|
|||||||
s.refreshNextRunLocked(existing, runtime)
|
s.refreshNextRunLocked(existing, runtime)
|
||||||
record := uiRecord(job.ID, job.Name, "Updated", "Job settings changed")
|
record := uiRecord(job.ID, job.Name, "Updated", "Job settings changed")
|
||||||
prependLog(runtime, record)
|
prependLog(runtime, record)
|
||||||
err := s.store.SaveJobs(s.jobs)
|
save := s.deferSaveLocked(s.store.PrepareSaveJobs(s.jobs))
|
||||||
s.mu.Unlock()
|
s.mu.Unlock()
|
||||||
|
|
||||||
if err != nil {
|
if err := save(); err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
s.emit(RunRecorded{Record: record})
|
s.emit(RunRecorded{Record: record})
|
||||||
@@ -113,10 +121,10 @@ func (s *Service) DeleteJob(id int) error {
|
|||||||
delete(s.runtimes, id)
|
delete(s.runtimes, id)
|
||||||
delete(s.schedules, id)
|
delete(s.schedules, id)
|
||||||
record := uiRecord(id, deleted.Name, "Deleted", "Job was removed")
|
record := uiRecord(id, deleted.Name, "Deleted", "Job was removed")
|
||||||
err := s.store.SaveJobs(s.jobs)
|
save := s.deferSaveLocked(s.store.PrepareSaveJobs(s.jobs))
|
||||||
s.mu.Unlock()
|
s.mu.Unlock()
|
||||||
|
|
||||||
if err != nil {
|
if err := save(); err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
s.emit(RunRecorded{Record: record})
|
s.emit(RunRecorded{Record: record})
|
||||||
@@ -155,10 +163,10 @@ func (s *Service) SetEnabled(id int, enabled bool) error {
|
|||||||
record = uiRecord(id, job.Name, "Paused", "Job was disabled")
|
record = uiRecord(id, job.Name, "Paused", "Job was disabled")
|
||||||
}
|
}
|
||||||
prependLog(runtime, record)
|
prependLog(runtime, record)
|
||||||
err := s.store.SaveJobs(s.jobs)
|
save := s.deferSaveLocked(s.store.PrepareSaveJobs(s.jobs))
|
||||||
s.mu.Unlock()
|
s.mu.Unlock()
|
||||||
|
|
||||||
if err != nil {
|
if err := save(); err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
s.emit(RunRecorded{Record: record})
|
s.emit(RunRecorded{Record: record})
|
||||||
@@ -188,10 +196,10 @@ func (s *Service) SetGlobalPause(paused bool) error {
|
|||||||
}
|
}
|
||||||
s.refreshNextRunFromLocked(job, runtime, now)
|
s.refreshNextRunFromLocked(job, runtime, now)
|
||||||
}
|
}
|
||||||
err := s.store.SaveConfig()
|
save := s.deferSaveLocked(s.store.PrepareSaveConfig())
|
||||||
s.mu.Unlock()
|
s.mu.Unlock()
|
||||||
|
|
||||||
if err != nil {
|
if err := save(); err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
state, detail := "Resumed", "All job execution resumed"
|
state, detail := "Resumed", "All job execution resumed"
|
||||||
@@ -219,9 +227,9 @@ func (s *Service) SetJobListView(view domain.JobListView) error {
|
|||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
s.store.Config.JobListView = view
|
s.store.Config.JobListView = view
|
||||||
err := s.store.SaveConfig()
|
save := s.deferSaveLocked(s.store.PrepareSaveConfig())
|
||||||
s.mu.Unlock()
|
s.mu.Unlock()
|
||||||
return err
|
return save()
|
||||||
}
|
}
|
||||||
|
|
||||||
// ShouldNotifyOnFailure reports whether the user has enabled desktop
|
// ShouldNotifyOnFailure reports whether the user has enabled desktop
|
||||||
@@ -251,54 +259,75 @@ func (s *Service) UpdateSettings(config domain.Config) error {
|
|||||||
config.JobsFile = strings.TrimSpace(config.JobsFile)
|
config.JobsFile = strings.TrimSpace(config.JobsFile)
|
||||||
|
|
||||||
s.mu.Lock()
|
s.mu.Lock()
|
||||||
jobsPath := storage.ResolveConfiguredPath(s.store.Paths.AppDir, config.JobsFile)
|
// AppDir is fixed for the process and only UpdateSettings itself — a UI
|
||||||
|
// action — can move JobsPath, so this snapshot stays valid across the reads
|
||||||
|
// below.
|
||||||
|
appDir := s.store.Paths.AppDir
|
||||||
|
jobsPath := storage.ResolveConfiguredPath(appDir, config.JobsFile)
|
||||||
switching := jobsPath != s.store.Paths.JobsPath
|
switching := jobsPath != s.store.Paths.JobsPath
|
||||||
if switching && s.anyRunningLocked() {
|
running := s.anyRunningLocked()
|
||||||
s.mu.Unlock()
|
s.mu.Unlock()
|
||||||
|
|
||||||
|
if switching && running {
|
||||||
return errors.New("cannot change the jobs file while a job is running")
|
return errors.New("cannot change the jobs file while a job is running")
|
||||||
}
|
}
|
||||||
// Read the new file before anything is written, so a file that cannot be
|
// Read the new file, and reconstruct its jobs' statistics from the logs the
|
||||||
// parsed leaves both the config and the current jobs untouched.
|
// new config points at, before anything is written and while no lock is held:
|
||||||
|
// both are file I/O, and SeedStats opens every log in the directory. A file
|
||||||
|
// that cannot be parsed leaves both the config and the current jobs untouched.
|
||||||
var adopted []domain.Job
|
var adopted []domain.Job
|
||||||
|
var seeds map[int]runner.SeededStats
|
||||||
if switching {
|
if switching {
|
||||||
jobs, found, err := storage.LoadJobsFile(jobsPath)
|
jobs, found, err := storage.LoadJobsFile(jobsPath)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
s.mu.Unlock()
|
|
||||||
return fmt.Errorf("read jobs file %s: %w", jobsPath, err)
|
return fmt.Errorf("read jobs file %s: %w", jobsPath, err)
|
||||||
}
|
}
|
||||||
if found {
|
if found {
|
||||||
adopted = jobs
|
adopted = jobs
|
||||||
|
seeds = runner.SeedStats(storage.ResolveConfiguredPath(appDir, config.LogsDir), jobs, config.MaxLogFiles)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
s.store.Config = config
|
s.mu.Lock()
|
||||||
if err := s.store.SaveConfig(); err != nil {
|
// The guard above was evaluated before the reads, off the lock, so re-check
|
||||||
|
// it: a scheduled run may have started in the meantime, and adoption drops
|
||||||
|
// every runtime.
|
||||||
|
if switching && s.anyRunningLocked() {
|
||||||
s.mu.Unlock()
|
s.mu.Unlock()
|
||||||
return err
|
return errors.New("cannot change the jobs file while a job is running")
|
||||||
}
|
}
|
||||||
|
s.store.Config = config
|
||||||
|
saveConfig := s.store.PrepareSaveConfig()
|
||||||
if adopted != nil {
|
if adopted != nil {
|
||||||
s.adoptJobsLocked(adopted)
|
s.adoptJobsLocked(adopted)
|
||||||
|
s.applySeededStatsLocked(seeds)
|
||||||
}
|
}
|
||||||
// SaveConfig re-resolved the paths from the new config, so SaveJobs writes to
|
// PrepareSaveConfig re-resolved the paths from the new config, so the jobs
|
||||||
// the (possibly new) jobs file and cleanup targets the new logs dir. Adopted
|
// write targets the (possibly new) jobs file and cleanup targets the new logs
|
||||||
// jobs are written back too, which persists the IDs and defaults that
|
// dir. Adopted jobs are written back too, which persists the IDs and defaults
|
||||||
// normalization filled in, exactly as loading them at startup would.
|
// that normalization filled in, exactly as loading them at startup would. The
|
||||||
if err := s.store.SaveJobs(s.jobs); err != nil {
|
// jobs write is skipped when the config write fails, because both writes run
|
||||||
s.mu.Unlock()
|
// in the order prepared and stop at the first error.
|
||||||
return err
|
save := s.deferSaveLocked(saveConfig, s.store.PrepareSaveJobs(s.jobs))
|
||||||
}
|
|
||||||
loaded := len(s.jobs)
|
loaded := len(s.jobs)
|
||||||
logsDir := s.store.Paths.LogsDir
|
logsDir := s.store.Paths.LogsDir
|
||||||
maxFiles := s.store.Config.MaxLogFiles
|
maxFiles := s.store.Config.MaxLogFiles
|
||||||
maxAge := s.store.Config.MaxLogAgeDays
|
maxAge := s.store.Config.MaxLogAgeDays
|
||||||
s.mu.Unlock()
|
s.mu.Unlock()
|
||||||
|
|
||||||
|
saveErr := save()
|
||||||
if adopted != nil {
|
if adopted != nil {
|
||||||
// A broad JobChanged redraws the job list; JobsLoaded tells the user in
|
// A broad JobChanged redraws the job list; JobsLoaded tells the user in
|
||||||
// History which file those jobs came from, since nothing was asked.
|
// History which file those jobs came from, since nothing was asked. Both
|
||||||
|
// are emitted even when the write failed: the adopted jobs are already the
|
||||||
|
// in-memory list, and a job list the user cannot see would be worse than
|
||||||
|
// the error they are about to be shown.
|
||||||
s.emit(JobsLoaded{Path: jobsPath, Count: loaded})
|
s.emit(JobsLoaded{Path: jobsPath, Count: loaded})
|
||||||
s.emit(JobChanged{})
|
s.emit(JobChanged{})
|
||||||
}
|
}
|
||||||
|
if saveErr != nil {
|
||||||
|
return saveErr
|
||||||
|
}
|
||||||
return runner.CleanupLogs(logsDir, maxFiles, maxAge)
|
return runner.CleanupLogs(logsDir, maxFiles, maxAge)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -474,11 +503,13 @@ func validateConfig(config domain.Config) error {
|
|||||||
if strings.TrimSpace(config.LogsDir) == "" {
|
if strings.TrimSpace(config.LogsDir) == "" {
|
||||||
return errors.New("logs directory is required")
|
return errors.New("logs directory is required")
|
||||||
}
|
}
|
||||||
if config.MaxLogFiles <= 0 {
|
// 0 means "keep everything" (see runner.CleanupLogs); only a negative count
|
||||||
return errors.New("max log files must be a positive number")
|
// is rejected, the same three-state shape as DefaultTimeoutSeconds below.
|
||||||
|
if config.MaxLogFiles < 0 {
|
||||||
|
return errors.New("max log files must be zero (unlimited) or a positive number")
|
||||||
}
|
}
|
||||||
if config.MaxLogAgeDays <= 0 {
|
if config.MaxLogAgeDays < 0 {
|
||||||
return errors.New("max log age days must be a positive number")
|
return errors.New("max log age days must be zero (unlimited) or a positive number")
|
||||||
}
|
}
|
||||||
if config.ExecutionMode != domain.ExecutionModeParallel && config.ExecutionMode != domain.ExecutionModeSequential {
|
if config.ExecutionMode != domain.ExecutionModeParallel && config.ExecutionMode != domain.ExecutionModeSequential {
|
||||||
return errors.New("execution mode must be 'parallel' or 'sequential'")
|
return errors.New("execution mode must be 'parallel' or 'sequential'")
|
||||||
|
|||||||
+119
-8
@@ -3,8 +3,10 @@ package app
|
|||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
|
"fmt"
|
||||||
"os"
|
"os"
|
||||||
"path/filepath"
|
"path/filepath"
|
||||||
|
"sync"
|
||||||
"sync/atomic"
|
"sync/atomic"
|
||||||
"testing"
|
"testing"
|
||||||
"time"
|
"time"
|
||||||
@@ -520,9 +522,9 @@ func TestUpdateSettingsPersistsAndValidates(t *testing.T) {
|
|||||||
svc := newTempService(t, nil)
|
svc := newTempService(t, nil)
|
||||||
|
|
||||||
bad := svc.store.Config
|
bad := svc.store.Config
|
||||||
bad.MaxLogFiles = 0
|
bad.MaxLogFiles = -1
|
||||||
if err := svc.UpdateSettings(bad); err == nil {
|
if err := svc.UpdateSettings(bad); err == nil {
|
||||||
t.Error("expected validation error for non-positive max log files")
|
t.Error("expected validation error for negative max log files")
|
||||||
}
|
}
|
||||||
|
|
||||||
good := svc.store.Config
|
good := svc.store.Config
|
||||||
@@ -531,8 +533,21 @@ func TestUpdateSettingsPersistsAndValidates(t *testing.T) {
|
|||||||
if err := svc.UpdateSettings(good); err != nil {
|
if err := svc.UpdateSettings(good); err != nil {
|
||||||
t.Fatalf("UpdateSettings: %v", err)
|
t.Fatalf("UpdateSettings: %v", err)
|
||||||
}
|
}
|
||||||
if svc.Store().Config.MaxLogAgeDays != 7 || svc.Store().Config.NotifyOnFailure {
|
if svc.store.Config.MaxLogAgeDays != 7 || svc.store.Config.NotifyOnFailure {
|
||||||
t.Errorf("config not applied: %+v", svc.Store().Config)
|
t.Errorf("config not applied: %+v", svc.store.Config)
|
||||||
|
}
|
||||||
|
|
||||||
|
// 0 means "keep everything" (see STANDARDS §Intentional behavior), not an
|
||||||
|
// invalid value, so it must be accepted and persisted rather than rejected
|
||||||
|
// or silently backfilled.
|
||||||
|
unlimited := svc.store.Config
|
||||||
|
unlimited.MaxLogFiles = 0
|
||||||
|
unlimited.MaxLogAgeDays = 0
|
||||||
|
if err := svc.UpdateSettings(unlimited); err != nil {
|
||||||
|
t.Fatalf("UpdateSettings with zero retention limits: %v", err)
|
||||||
|
}
|
||||||
|
if svc.store.Config.MaxLogFiles != 0 || svc.store.Config.MaxLogAgeDays != 0 {
|
||||||
|
t.Errorf("zero retention limits not preserved: %+v", svc.store.Config)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -547,8 +562,8 @@ func TestUpdateSettingsRejectsInvalidConfigs(t *testing.T) {
|
|||||||
{"missing jobs file", func(c *domain.Config) { c.JobsFile = " " }},
|
{"missing jobs file", func(c *domain.Config) { c.JobsFile = " " }},
|
||||||
{"jobs file without a file name", func(c *domain.Config) { c.JobsFile = "jobs" + string(filepath.Separator) }},
|
{"jobs file without a file name", func(c *domain.Config) { c.JobsFile = "jobs" + string(filepath.Separator) }},
|
||||||
{"missing logs dir", func(c *domain.Config) { c.LogsDir = "" }},
|
{"missing logs dir", func(c *domain.Config) { c.LogsDir = "" }},
|
||||||
{"non-positive max files", func(c *domain.Config) { c.MaxLogFiles = 0 }},
|
{"negative max files", func(c *domain.Config) { c.MaxLogFiles = -1 }},
|
||||||
{"non-positive max age", func(c *domain.Config) { c.MaxLogAgeDays = -1 }},
|
{"negative max age", func(c *domain.Config) { c.MaxLogAgeDays = -1 }},
|
||||||
{"negative default timeout", func(c *domain.Config) { c.DefaultTimeoutSeconds = -1 }},
|
{"negative default timeout", func(c *domain.Config) { c.DefaultTimeoutSeconds = -1 }},
|
||||||
}
|
}
|
||||||
for _, tc := range tests {
|
for _, tc := range tests {
|
||||||
@@ -710,12 +725,12 @@ func TestUpdateSettingsRefusesJobsFileSwitchWhileRunning(t *testing.T) {
|
|||||||
if err := svc.UpdateSettings(config); err == nil {
|
if err := svc.UpdateSettings(config); err == nil {
|
||||||
t.Error("expected the jobs-file switch to be refused while a job is running")
|
t.Error("expected the jobs-file switch to be refused while a job is running")
|
||||||
}
|
}
|
||||||
if svc.Store().Config.JobsFile == config.JobsFile {
|
if svc.store.Config.JobsFile == config.JobsFile {
|
||||||
t.Error("the refused switch must not have been persisted")
|
t.Error("the refused switch must not have been persisted")
|
||||||
}
|
}
|
||||||
|
|
||||||
// A setting that does not touch the jobs file still saves during a run.
|
// A setting that does not touch the jobs file still saves during a run.
|
||||||
unrelated := svc.Store().Config
|
unrelated := svc.store.Config
|
||||||
unrelated.NotifyOnFailure = !unrelated.NotifyOnFailure
|
unrelated.NotifyOnFailure = !unrelated.NotifyOnFailure
|
||||||
if err := svc.UpdateSettings(unrelated); err != nil {
|
if err := svc.UpdateSettings(unrelated); err != nil {
|
||||||
t.Errorf("unrelated setting should still save during a run: %v", err)
|
t.Errorf("unrelated setting should still save during a run: %v", err)
|
||||||
@@ -725,6 +740,102 @@ func TestUpdateSettingsRefusesJobsFileSwitchWhileRunning(t *testing.T) {
|
|||||||
waitRecord(t, done)
|
waitRecord(t, done)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Adoption reconstructs the adopted jobs' aggregate statistics from the log
|
||||||
|
// files the new configuration points at. That scan opens every log in the
|
||||||
|
// directory, so UpdateSettings runs it before taking the state lock; this pins
|
||||||
|
// that its result still reaches the runtime map.
|
||||||
|
func TestUpdateSettingsSeedsAdoptedJobsFromLogs(t *testing.T) {
|
||||||
|
svc := newTempService(t, []domain.Job{{ID: 1, Name: "Local", Schedule: "@every 1m", Command: "echo local", Enabled: true}})
|
||||||
|
|
||||||
|
logsDir := svc.store.Paths.LogsDir
|
||||||
|
if err := os.MkdirAll(logsDir, 0o755); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
log := "time: 2026-08-05 10:00:00\njob_id: 7\njob_name: Adopted\ntrigger: Schedule\nstate: Failed\ndetail: boom\nduration: 1500\n\nstdout:\n<empty>\n"
|
||||||
|
if err := os.WriteFile(filepath.Join(logsDir, "20260805-100000_Adopted.log"), []byte(log), 0o644); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
shared := filepath.Join(svc.store.Paths.AppDir, "shared.json")
|
||||||
|
data, err := json.Marshal(domain.JobsFile{Jobs: []domain.Job{
|
||||||
|
{ID: 7, Name: "Adopted", Schedule: "@every 5m", Command: "echo adopted", Enabled: true},
|
||||||
|
}})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if err := os.WriteFile(shared, data, 0o644); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
config := svc.store.Config
|
||||||
|
config.JobsFile = shared
|
||||||
|
if err := svc.UpdateSettings(config); err != nil {
|
||||||
|
t.Fatalf("UpdateSettings: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
runtime := svc.Runtime(7)
|
||||||
|
if runtime == nil {
|
||||||
|
t.Fatal("the adopted job has no runtime")
|
||||||
|
}
|
||||||
|
if runtime.RunCount != 1 || runtime.FailCount != 1 || runtime.LastDurationMS != 1500 {
|
||||||
|
t.Errorf("seeded stats: RunCount=%d FailCount=%d LastDurationMS=%d, want 1/1/1500",
|
||||||
|
runtime.RunCount, runtime.FailCount, runtime.LastDurationMS)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Job saves run after mu is released, so one operation can be writing while
|
||||||
|
// another mutates state. deferSaveLocked takes its own lock while mu is still
|
||||||
|
// held, which is what keeps writes in mutation order: whatever changed the list
|
||||||
|
// last also wrote it last, so the file ends up matching memory instead of
|
||||||
|
// holding an older snapshot.
|
||||||
|
func TestConcurrentJobOperationsLeaveTheFileMatchingMemory(t *testing.T) {
|
||||||
|
svc := newTempService(t, nil)
|
||||||
|
|
||||||
|
const workers = 8
|
||||||
|
var wg sync.WaitGroup
|
||||||
|
for i := 0; i < workers; i++ {
|
||||||
|
wg.Add(1)
|
||||||
|
go func(i int) {
|
||||||
|
defer wg.Done()
|
||||||
|
job, err := svc.CreateJob(domain.Job{Name: fmt.Sprintf("Job %d", i), Schedule: "@every 1m", Command: "echo hi", Enabled: true})
|
||||||
|
if err != nil {
|
||||||
|
t.Errorf("CreateJob %d: %v", i, err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if err := svc.SetEnabled(job.ID, false); err != nil {
|
||||||
|
t.Errorf("SetEnabled %d: %v", job.ID, err)
|
||||||
|
}
|
||||||
|
}(i)
|
||||||
|
}
|
||||||
|
wg.Wait()
|
||||||
|
|
||||||
|
memory := svc.Jobs()
|
||||||
|
if len(memory) != workers {
|
||||||
|
t.Fatalf("jobs in memory = %d, want %d", len(memory), workers)
|
||||||
|
}
|
||||||
|
saved, found, err := storage.LoadJobsFile(svc.store.Paths.JobsPath)
|
||||||
|
if err != nil || !found {
|
||||||
|
t.Fatalf("read jobs file: found=%v err=%v", found, err)
|
||||||
|
}
|
||||||
|
if len(saved) != len(memory) {
|
||||||
|
t.Fatalf("jobs on disk = %d, want %d: the last write must be the last mutation", len(saved), len(memory))
|
||||||
|
}
|
||||||
|
onDisk := make(map[int]domain.Job, len(saved))
|
||||||
|
for _, job := range saved {
|
||||||
|
onDisk[job.ID] = job
|
||||||
|
}
|
||||||
|
for _, job := range memory {
|
||||||
|
got, ok := onDisk[job.ID]
|
||||||
|
if !ok {
|
||||||
|
t.Errorf("job %d (%q) is in memory but missing from the file", job.ID, job.Name)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if got.Name != job.Name || got.Enabled != job.Enabled {
|
||||||
|
t.Errorf("job %d on disk = %q/%v, want %q/%v", job.ID, got.Name, got.Enabled, job.Name, job.Enabled)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func TestPrependLogCapsActivityList(t *testing.T) {
|
func TestPrependLogCapsActivityList(t *testing.T) {
|
||||||
runtime := &domain.JobRuntime{}
|
runtime := &domain.JobRuntime{}
|
||||||
for i := 0; i < maxJobLogs+10; i++ {
|
for i := 0; i < maxJobLogs+10; i++ {
|
||||||
|
|||||||
+6
-2
@@ -147,7 +147,6 @@ func (s *Service) executeRun(ctx context.Context, jobCopy domain.Job, trigger st
|
|||||||
record, logErr := s.runJob(ctx, &jobCopy, trigger, env.logsDir, env.timeout)
|
record, logErr := s.runJob(ctx, &jobCopy, trigger, env.logsDir, env.timeout)
|
||||||
|
|
||||||
s.mu.Lock()
|
s.mu.Lock()
|
||||||
var cleanupErr error
|
|
||||||
var rerunStarted bool
|
var rerunStarted bool
|
||||||
if current := s.findByIDLocked(jobCopy.ID); current != nil {
|
if current := s.findByIDLocked(jobCopy.ID); current != nil {
|
||||||
runtime := s.runtimeForLocked(current)
|
runtime := s.runtimeForLocked(current)
|
||||||
@@ -166,10 +165,15 @@ func (s *Service) executeRun(ctx context.Context, jobCopy domain.Job, trigger st
|
|||||||
} else {
|
} else {
|
||||||
s.refreshNextRunLocked(current, runtime)
|
s.refreshNextRunLocked(current, runtime)
|
||||||
}
|
}
|
||||||
cleanupErr = runner.CleanupLogs(env.logsDir, env.maxFiles, env.maxAge)
|
|
||||||
}
|
}
|
||||||
s.mu.Unlock()
|
s.mu.Unlock()
|
||||||
|
|
||||||
|
// Cleanup is a directory scan plus up to MaxLogFiles unlinks. It needs only
|
||||||
|
// the values already snapshotted into runEnv, so it runs after mu is released
|
||||||
|
// rather than making every UI refresh wait behind it. It runs even when the
|
||||||
|
// job is gone, because the run still wrote a log file that retention covers.
|
||||||
|
cleanupErr := runner.CleanupLogs(env.logsDir, env.maxFiles, env.maxAge)
|
||||||
|
|
||||||
if logErr != nil {
|
if logErr != nil {
|
||||||
s.emit(ErrorOccurred{Err: fmt.Errorf("write run log for %q: %w", jobCopy.Name, logErr)})
|
s.emit(ErrorOccurred{Err: fmt.Errorf("write run log for %q: %w", jobCopy.Name, logErr)})
|
||||||
}
|
}
|
||||||
|
|||||||
+69
-13
@@ -26,7 +26,10 @@ import (
|
|||||||
// it; unexported helpers ending in "Locked" assume the caller already holds it.
|
// it; unexported helpers ending in "Locked" assume the caller already holds it.
|
||||||
// The Service must never call back into the UI (or any code that might re-enter
|
// The Service must never call back into the UI (or any code that might re-enter
|
||||||
// the Service) while holding mu — in particular emit() is always called after
|
// the Service) while holding mu — in particular emit() is always called after
|
||||||
// mu is released.
|
// mu is released. Blocking file I/O follows the same rule: mu is the lock the
|
||||||
|
// Fyne main thread takes on every Jobs() and Runtime() call, so a JSON write, a
|
||||||
|
// log-directory scan, or a pass over every log header must not happen inside it
|
||||||
|
// (see deferSaveLocked, executeRun, and applySeededStatsLocked).
|
||||||
type Service struct {
|
type Service struct {
|
||||||
mu sync.Mutex
|
mu sync.Mutex
|
||||||
store *storage.Store
|
store *storage.Store
|
||||||
@@ -56,6 +59,13 @@ type Service struct {
|
|||||||
// do not exercise autostart; Open() wires it via autostart.New().
|
// do not exercise autostart; Open() wires it via autostart.New().
|
||||||
manager autostart.Manager
|
manager autostart.Manager
|
||||||
|
|
||||||
|
// saveMu serializes the store writes that operations prepare under mu and run
|
||||||
|
// after releasing it. It is taken while mu is still held and released once the
|
||||||
|
// write is done, so writes reach the file in the same order their snapshots
|
||||||
|
// were taken and an older snapshot can never land on top of a newer one.
|
||||||
|
// Nothing may take mu while holding saveMu.
|
||||||
|
saveMu sync.Mutex
|
||||||
|
|
||||||
// observers and their guard live in events.go. dispatchMu is separate from mu
|
// observers and their guard live in events.go. dispatchMu is separate from mu
|
||||||
// so that emitting an event never requires (or is held under) the state lock:
|
// so that emitting an event never requires (or is held under) the state lock:
|
||||||
// the Service must release mu before dispatching, per the locking contract.
|
// the Service must release mu before dispatching, per the locking contract.
|
||||||
@@ -63,6 +73,26 @@ type Service struct {
|
|||||||
observers []Observer
|
observers []Observer
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// deferSaveLocked prepares the store writes for the caller to run after mu is
|
||||||
|
// released, and takes saveMu now so a later operation's write cannot overtake
|
||||||
|
// this one. The caller must hold mu, must unlock it before calling the returned
|
||||||
|
// function, and must call that function exactly once. Keeping the marshal, the
|
||||||
|
// fsync, and the rename out of the critical section is what stops a settings
|
||||||
|
// change or a job edit from blocking a scheduler tick or a finishing run. The
|
||||||
|
// writes run in the order given and stop at the first error.
|
||||||
|
func (s *Service) deferSaveLocked(writes ...func() error) func() error {
|
||||||
|
s.saveMu.Lock()
|
||||||
|
return func() error {
|
||||||
|
defer s.saveMu.Unlock()
|
||||||
|
for _, write := range writes {
|
||||||
|
if err := write(); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// NewService wires the Service to a loaded store and its jobs. It builds the
|
// NewService wires the Service to a loaded store and its jobs. It builds the
|
||||||
// initial runtime map from the durable jobs so every job has transient state
|
// initial runtime map from the durable jobs so every job has transient state
|
||||||
// from the moment the Service exists, and parses each job's schedule once. The
|
// from the moment the Service exists, and parses each job's schedule once. The
|
||||||
@@ -77,18 +107,19 @@ func NewService(store *storage.Store, jobs []domain.Job) *Service {
|
|||||||
// No lock is needed here: construction is single-threaded, before Start
|
// No lock is needed here: construction is single-threaded, before Start
|
||||||
// launches the timing loop.
|
// launches the timing loop.
|
||||||
s.adoptJobsLocked(jobs)
|
s.adoptJobsLocked(jobs)
|
||||||
|
s.applySeededStatsLocked(runner.SeedStats(store.Paths.LogsDir, s.jobs, store.Config.MaxLogFiles))
|
||||||
return s
|
return s
|
||||||
}
|
}
|
||||||
|
|
||||||
// adoptJobsLocked makes jobs the Service's durable state and rebuilds everything
|
// adoptJobsLocked makes jobs the Service's durable state and rebuilds everything
|
||||||
// derived from it: the runtime map, the parsed-schedule cache, each job's first
|
// derived from it: the runtime map, the parsed-schedule cache, and each job's
|
||||||
// next-run — so the Service is ready to schedule the moment it exists, mirroring
|
// first next-run — so the Service is ready to schedule the moment it exists,
|
||||||
// the old scheduler's reset-on-construction — and the statistics seeded from
|
// mirroring the old scheduler's reset-on-construction.
|
||||||
// existing log files, so the details panel shows accumulated run history
|
|
||||||
// immediately rather than only runs since this process started.
|
|
||||||
//
|
//
|
||||||
// It backs both construction and a Settings change that points at a different
|
// It backs both construction and a Settings change that points at a different
|
||||||
// jobs file. The caller must hold mu.
|
// jobs file. Statistics seeded from existing log files are applied separately by
|
||||||
|
// applySeededStatsLocked, because reconstructing them is file I/O. The caller
|
||||||
|
// must hold mu.
|
||||||
func (s *Service) adoptJobsLocked(jobs []domain.Job) {
|
func (s *Service) adoptJobsLocked(jobs []domain.Job) {
|
||||||
s.jobs = jobs
|
s.jobs = jobs
|
||||||
s.runtimes = domain.NewRuntimes(jobs)
|
s.runtimes = domain.NewRuntimes(jobs)
|
||||||
@@ -100,7 +131,16 @@ func (s *Service) adoptJobsLocked(jobs []domain.Job) {
|
|||||||
s.parseScheduleLocked(job)
|
s.parseScheduleLocked(job)
|
||||||
s.refreshNextRunFromLocked(job, s.runtimes[job.ID], now)
|
s.refreshNextRunFromLocked(job, s.runtimes[job.ID], now)
|
||||||
}
|
}
|
||||||
for id, seed := range runner.SeedStats(s.store.Paths.LogsDir, s.jobs, s.store.Config.MaxLogFiles) {
|
}
|
||||||
|
|
||||||
|
// applySeededStatsLocked folds statistics reconstructed from existing log files
|
||||||
|
// into the runtime map, so the details panel shows accumulated run history
|
||||||
|
// immediately rather than only runs since this process started. It is separate
|
||||||
|
// from adoptJobsLocked because producing the seeds opens every log file in the
|
||||||
|
// directory, which must not happen under mu: callers compute the map first and
|
||||||
|
// apply it here. The caller must hold mu.
|
||||||
|
func (s *Service) applySeededStatsLocked(seeds map[int]runner.SeededStats) {
|
||||||
|
for id, seed := range seeds {
|
||||||
runtime := s.runtimes[id]
|
runtime := s.runtimes[id]
|
||||||
if runtime == nil {
|
if runtime == nil {
|
||||||
continue
|
continue
|
||||||
@@ -164,11 +204,27 @@ func Open() (*Service, error) {
|
|||||||
return svc, nil
|
return svc, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// Store returns the underlying store. It is exposed so callers that still need
|
// Config returns a copy of the current application configuration, safe to
|
||||||
// resolved paths and config (the GUI, during the transition) can reach them;
|
// call from any goroutine. UpdateSettings, SetGlobalPause, and SetJobListView
|
||||||
// later phases narrow this surface.
|
// are the only writers and all mutate store.Config under mu; copying under the
|
||||||
func (s *Service) Store() *storage.Store {
|
// same lock is what keeps a UI read from racing them, instead of holding onto
|
||||||
return s.store
|
// the *storage.Store this used to hand out (see STANDARDS: the UI reads
|
||||||
|
// Service state through typed events and accessors, never shared mutable
|
||||||
|
// state).
|
||||||
|
func (s *Service) Config() domain.Config {
|
||||||
|
s.mu.Lock()
|
||||||
|
defer s.mu.Unlock()
|
||||||
|
return s.store.Config
|
||||||
|
}
|
||||||
|
|
||||||
|
// Paths returns a copy of the store's resolved filesystem paths. AppDir and
|
||||||
|
// ConfigPath are fixed for the process; JobsPath, JobsDir, and LogsDir are
|
||||||
|
// re-derived under mu on every settings save (storage.Store.applyConfigPaths),
|
||||||
|
// so this copies under the same lock as Config for the same reason.
|
||||||
|
func (s *Service) Paths() storage.Paths {
|
||||||
|
s.mu.Lock()
|
||||||
|
defer s.mu.Unlock()
|
||||||
|
return s.store.Paths
|
||||||
}
|
}
|
||||||
|
|
||||||
// Jobs returns a copy of the durable jobs slice. Returning a copy keeps callers
|
// Jobs returns a copy of the durable jobs slice. Returning a copy keeps callers
|
||||||
|
|||||||
@@ -18,16 +18,16 @@ type linuxManager struct{}
|
|||||||
func New() Manager { return linuxManager{} }
|
func New() Manager { return linuxManager{} }
|
||||||
|
|
||||||
func (linuxManager) Set(enabled, startInTray bool, executablePath, iconPath string) error {
|
func (linuxManager) Set(enabled, startInTray bool, executablePath, iconPath string) error {
|
||||||
return SetAutostart(enabled, startInTray, executablePath, iconPath)
|
return setAutostart(enabled, startInTray, executablePath, iconPath)
|
||||||
}
|
}
|
||||||
|
|
||||||
func (linuxManager) Status(expectedEnabled, startInTray bool, executablePath string) (bool, string) {
|
func (linuxManager) Status(expectedEnabled, startInTray bool, executablePath string) (bool, string) {
|
||||||
return AutostartStatus(expectedEnabled, startInTray, executablePath)
|
return autostartStatus(expectedEnabled, startInTray, executablePath)
|
||||||
}
|
}
|
||||||
|
|
||||||
const autostartDesktopFileName = "gosentry.desktop"
|
const autostartDesktopFileName = "gosentry.desktop"
|
||||||
|
|
||||||
func SetAutostart(enabled bool, startInTray bool, executablePath string, iconPath string) error {
|
func setAutostart(enabled bool, startInTray bool, executablePath string, iconPath string) error {
|
||||||
desktopPath, err := autostartDesktopPath()
|
desktopPath, err := autostartDesktopPath()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
@@ -58,7 +58,7 @@ X-GNOME-Autostart-enabled=true
|
|||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func AutostartStatus(expectedEnabled bool, startInTray bool, executablePath string) (bool, string) {
|
func autostartStatus(expectedEnabled bool, startInTray bool, executablePath string) (bool, string) {
|
||||||
desktopPath, err := autostartDesktopPath()
|
desktopPath, err := autostartDesktopPath()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return false, "Cannot resolve XDG autostart directory"
|
return false, "Cannot resolve XDG autostart directory"
|
||||||
|
|||||||
@@ -14,7 +14,7 @@ func TestLinuxAutostartStartsInTray(t *testing.T) {
|
|||||||
t.Setenv("XDG_CONFIG_HOME", t.TempDir())
|
t.Setenv("XDG_CONFIG_HOME", t.TempDir())
|
||||||
|
|
||||||
executablePath := "/opt/Go Sentry/gosentry"
|
executablePath := "/opt/Go Sentry/gosentry"
|
||||||
if err := SetAutostart(true, true, executablePath, "/opt/Go Sentry/gosentry.png"); err != nil {
|
if err := setAutostart(true, true, executablePath, "/opt/Go Sentry/gosentry.png"); err != nil {
|
||||||
t.Fatalf("enable autostart: %v", err)
|
t.Fatalf("enable autostart: %v", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -37,7 +37,7 @@ func TestLinuxAutostartWithoutTrayFlag(t *testing.T) {
|
|||||||
t.Setenv("XDG_CONFIG_HOME", t.TempDir())
|
t.Setenv("XDG_CONFIG_HOME", t.TempDir())
|
||||||
|
|
||||||
executablePath := "/opt/Go Sentry/gosentry"
|
executablePath := "/opt/Go Sentry/gosentry"
|
||||||
if err := SetAutostart(true, false, executablePath, ""); err != nil {
|
if err := setAutostart(true, false, executablePath, ""); err != nil {
|
||||||
t.Fatalf("enable autostart: %v", err)
|
t.Fatalf("enable autostart: %v", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -10,21 +10,21 @@ type otherManager struct{}
|
|||||||
func New() Manager { return otherManager{} }
|
func New() Manager { return otherManager{} }
|
||||||
|
|
||||||
func (otherManager) Set(enabled, startInTray bool, executablePath, iconPath string) error {
|
func (otherManager) Set(enabled, startInTray bool, executablePath, iconPath string) error {
|
||||||
return SetAutostart(enabled, startInTray, executablePath, iconPath)
|
return setAutostart(enabled, startInTray, executablePath, iconPath)
|
||||||
}
|
}
|
||||||
|
|
||||||
func (otherManager) Status(expectedEnabled, startInTray bool, executablePath string) (bool, string) {
|
func (otherManager) Status(expectedEnabled, startInTray bool, executablePath string) (bool, string) {
|
||||||
return AutostartStatus(expectedEnabled, startInTray, executablePath)
|
return autostartStatus(expectedEnabled, startInTray, executablePath)
|
||||||
}
|
}
|
||||||
|
|
||||||
func SetAutostart(enabled bool, startInTray bool, executablePath string, iconPath string) error {
|
func setAutostart(enabled bool, startInTray bool, executablePath string, iconPath string) error {
|
||||||
if !enabled {
|
if !enabled {
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
return fmt.Errorf("autostart is not implemented for this platform")
|
return fmt.Errorf("autostart is not implemented for this platform")
|
||||||
}
|
}
|
||||||
|
|
||||||
func AutostartStatus(expectedEnabled bool, startInTray bool, executablePath string) (bool, string) {
|
func autostartStatus(expectedEnabled bool, startInTray bool, executablePath string) (bool, string) {
|
||||||
if !expectedEnabled {
|
if !expectedEnabled {
|
||||||
return true, "Autostart is off"
|
return true, "Autostart is off"
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -17,17 +17,17 @@ type windowsManager struct{}
|
|||||||
func New() Manager { return windowsManager{} }
|
func New() Manager { return windowsManager{} }
|
||||||
|
|
||||||
func (windowsManager) Set(enabled, startInTray bool, executablePath, iconPath string) error {
|
func (windowsManager) Set(enabled, startInTray bool, executablePath, iconPath string) error {
|
||||||
return SetAutostart(enabled, startInTray, executablePath, iconPath)
|
return setAutostart(enabled, startInTray, executablePath, iconPath)
|
||||||
}
|
}
|
||||||
|
|
||||||
func (windowsManager) Status(expectedEnabled, startInTray bool, executablePath string) (bool, string) {
|
func (windowsManager) Status(expectedEnabled, startInTray bool, executablePath string) (bool, string) {
|
||||||
return AutostartStatus(expectedEnabled, startInTray, executablePath)
|
return autostartStatus(expectedEnabled, startInTray, executablePath)
|
||||||
}
|
}
|
||||||
|
|
||||||
const autostartName = "GoSentry"
|
const autostartName = "GoSentry"
|
||||||
const startupShortcutFile = autostartName + ".lnk"
|
const startupShortcutFile = autostartName + ".lnk"
|
||||||
|
|
||||||
func SetAutostart(enabled bool, startInTray bool, executablePath string, iconPath string) error {
|
func setAutostart(enabled bool, startInTray bool, executablePath string, iconPath string) error {
|
||||||
// Windows autostart used to write HKCU\Run values, but that approach became
|
// Windows autostart used to write HKCU\Run values, but that approach became
|
||||||
// brittle once paths with spaces and the "--start-in-tray" argument entered
|
// brittle once paths with spaces and the "--start-in-tray" argument entered
|
||||||
// the picture. A Startup-folder shortcut stores target path and arguments as
|
// the picture. A Startup-folder shortcut stores target path and arguments as
|
||||||
@@ -44,7 +44,7 @@ func SetAutostart(enabled bool, startInTray bool, executablePath string, iconPat
|
|||||||
return removeIfExists(shortcutPath)
|
return removeIfExists(shortcutPath)
|
||||||
}
|
}
|
||||||
|
|
||||||
func AutostartStatus(expectedEnabled bool, startInTray bool, executablePath string) (bool, string) {
|
func autostartStatus(expectedEnabled bool, startInTray bool, executablePath string) (bool, string) {
|
||||||
shortcutPath, err := startupShortcutPath()
|
shortcutPath, err := startupShortcutPath()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return false, "Startup folder cannot be resolved"
|
return false, "Startup folder cannot be resolved"
|
||||||
@@ -126,7 +126,7 @@ func readShortcut(shortcutPath string) (string, string, error) {
|
|||||||
// OEM code page (e.g. CP866 on Russian Windows). Without this override,
|
// OEM code page (e.g. CP866 on Russian Windows). Without this override,
|
||||||
// [Console]::Out.Write encodes Cyrillic and other non-ASCII characters as
|
// [Console]::Out.Write encodes Cyrillic and other non-ASCII characters as
|
||||||
// OEM bytes; Go then reads them as UTF-8 and gets a different string from
|
// OEM bytes; Go then reads them as UTF-8 and gets a different string from
|
||||||
// os.Executable, causing AutostartStatus to report "shortcut points to
|
// os.Executable, causing autostartStatus to report "shortcut points to
|
||||||
// another executable" for any install path that contains non-ASCII chars.
|
// another executable" for any install path that contains non-ASCII chars.
|
||||||
// New-Object System.Text.UTF8Encoding($false) is UTF-8 without BOM.
|
// New-Object System.Text.UTF8Encoding($false) is UTF-8 without BOM.
|
||||||
script := `[Console]::OutputEncoding = New-Object System.Text.UTF8Encoding($false); $shell = New-Object -ComObject WScript.Shell; $shortcut = $shell.CreateShortcut($env:GOSENTRY_SHORTCUT_PATH); [Console]::Out.Write($shortcut.TargetPath + [Environment]::NewLine + $shortcut.Arguments)`
|
script := `[Console]::OutputEncoding = New-Object System.Text.UTF8Encoding($false); $shell = New-Object -ComObject WScript.Shell; $shortcut = $shell.CreateShortcut($env:GOSENTRY_SHORTCUT_PATH); [Console]::Out.Write($shortcut.TargetPath + [Environment]::NewLine + $shortcut.Arguments)`
|
||||||
|
|||||||
@@ -134,7 +134,7 @@ func TestAutostartStatusRequiresMatchingTrayFlag(t *testing.T) {
|
|||||||
t.Fatalf("create shortcut: %v", err)
|
t.Fatalf("create shortcut: %v", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
ok, message := AutostartStatus(true, false, targetPath)
|
ok, message := autostartStatus(true, false, targetPath)
|
||||||
if ok {
|
if ok {
|
||||||
t.Fatalf("expected problem when tray flag mismatches, got OK: %s", message)
|
t.Fatalf("expected problem when tray flag mismatches, got OK: %s", message)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -9,6 +9,11 @@ import (
|
|||||||
"time"
|
"time"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
// CleanupLogs enforces the count and age retention policies on the .log files
|
||||||
|
// in logsDir. maxFiles <= 0 disables the count policy and maxAgeDays <= 0
|
||||||
|
// disables the age policy, independently — "keep everything" is a value the
|
||||||
|
// user can choose in Settings, not just an internal default (STANDARDS
|
||||||
|
// §Intentional behavior).
|
||||||
func CleanupLogs(logsDir string, maxFiles int, maxAgeDays int) error {
|
func CleanupLogs(logsDir string, maxFiles int, maxAgeDays int) error {
|
||||||
entries, err := os.ReadDir(logsDir)
|
entries, err := os.ReadDir(logsDir)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
|
|||||||
@@ -36,7 +36,15 @@ func RunJob(ctx context.Context, job *domain.Job, trigger string, logsDir string
|
|||||||
var detail string
|
var detail string
|
||||||
var durationMS int64
|
var durationMS int64
|
||||||
if job.StartOnly {
|
if job.StartOnly {
|
||||||
invocation := jobInvocation(ctx, *job)
|
// A StartOnly process is deliberately never waited for, so it must not be
|
||||||
|
// tied to any cancelable context: exec.CommandContext leaves a watcher
|
||||||
|
// goroutine alive until Wait returns or the context is done, and since
|
||||||
|
// StartOnly never calls Wait that goroutine would live for the rest of the
|
||||||
|
// process — one per run — and then try to kill a process whose handle
|
||||||
|
// startJobOnly has already released. context.Background() has a nil Done
|
||||||
|
// channel, so os/exec starts no watcher at all and the started process is
|
||||||
|
// left to outlive GoSentry, which is the point of the option.
|
||||||
|
invocation := jobInvocation(context.Background(), *job)
|
||||||
// StartOnly jobs don't wait for process exit, so the duration measures
|
// StartOnly jobs don't wait for process exit, so the duration measures
|
||||||
// launch latency (time to spawn the process) rather than run time.
|
// launch latency (time to spawn the process) rather than run time.
|
||||||
state, detail, output, durationMS = startJobOnly(invocation, *job, started)
|
state, detail, output, durationMS = startJobOnly(invocation, *job, started)
|
||||||
|
|||||||
@@ -401,6 +401,58 @@ func TestRunJobZeroTimeoutMeansNoTimeout(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// A StartOnly run must not leave a watcher goroutine behind. exec.CommandContext
|
||||||
|
// keeps one alive until Wait returns or the context is done, and StartOnly never
|
||||||
|
// waits, so binding it to the caller's cancelable context would leak one
|
||||||
|
// goroutine per run for the lifetime of the app — and then, on shutdown, kill a
|
||||||
|
// process whose handle startJobOnly has already released.
|
||||||
|
func TestRunJobStartOnlyLeavesNoContextWatcher(t *testing.T) {
|
||||||
|
command := "sh"
|
||||||
|
arguments := "-c\nexit 0"
|
||||||
|
if runtime.GOOS == "windows" {
|
||||||
|
command = `C:\Windows\System32\cmd.exe`
|
||||||
|
arguments = "/C\nexit /b 0"
|
||||||
|
}
|
||||||
|
job := domain.Job{
|
||||||
|
ID: 53,
|
||||||
|
Name: "Start Only Goroutines",
|
||||||
|
Command: command,
|
||||||
|
Arguments: arguments,
|
||||||
|
StartOnly: true,
|
||||||
|
}
|
||||||
|
|
||||||
|
ctx, cancel := context.WithCancel(context.Background())
|
||||||
|
defer cancel()
|
||||||
|
|
||||||
|
const runs = 5
|
||||||
|
before := settledGoroutines()
|
||||||
|
for i := 0; i < runs; i++ {
|
||||||
|
if _, err := RunJob(ctx, &job, "Manual", t.TempDir(), 30*time.Second); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// Counted before cancel on purpose: a watcher would still be parked on
|
||||||
|
// ctx.Done() at this point, and cancelling first would release it.
|
||||||
|
if leaked := settledGoroutines() - before; leaked > 1 {
|
||||||
|
t.Errorf("%d goroutines left after %d StartOnly runs, want none tied to the run context", leaked, runs)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// settledGoroutines returns the goroutine count once it has stopped falling, so
|
||||||
|
// a goroutine that is still on its way out is not mistaken for a leak.
|
||||||
|
func settledGoroutines() int {
|
||||||
|
lowest := runtime.NumGoroutine()
|
||||||
|
for stable, i := 0, 0; stable < 3 && i < 100; i++ {
|
||||||
|
time.Sleep(10 * time.Millisecond)
|
||||||
|
if count := runtime.NumGoroutine(); count < lowest {
|
||||||
|
lowest, stable = count, 0
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
stable++
|
||||||
|
}
|
||||||
|
return lowest
|
||||||
|
}
|
||||||
|
|
||||||
func TestRunJobStartOnlyIgnoresTimeout(t *testing.T) {
|
func TestRunJobStartOnlyIgnoresTimeout(t *testing.T) {
|
||||||
command := "sh"
|
command := "sh"
|
||||||
arguments := "-c\nsleep 5"
|
arguments := "-c\nsleep 5"
|
||||||
|
|||||||
+51
-55
@@ -45,8 +45,8 @@ func SeedStats(logsDir string, jobs []domain.Job, maxFiles int) map[int]SeededSt
|
|||||||
return result
|
return result
|
||||||
}
|
}
|
||||||
|
|
||||||
byID := make(map[int][]string)
|
byID := make(map[int][]logSummary)
|
||||||
byName := make(map[string][]string)
|
byName := make(map[string][]logSummary)
|
||||||
for _, entry := range entries {
|
for _, entry := range entries {
|
||||||
if entry.IsDir() {
|
if entry.IsDir() {
|
||||||
continue
|
continue
|
||||||
@@ -55,9 +55,10 @@ func SeedStats(logsDir string, jobs []domain.Job, maxFiles int) map[int]SeededSt
|
|||||||
if !strings.HasSuffix(strings.ToLower(name), ".log") {
|
if !strings.HasSuffix(strings.ToLower(name), ".log") {
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
path := filepath.Join(logsDir, name)
|
summary := readLogSummary(filepath.Join(logsDir, name))
|
||||||
if jobID, ok := readLogJobID(path); ok {
|
summary.name = name
|
||||||
byID[jobID] = append(byID[jobID], name)
|
if summary.hasJobID {
|
||||||
|
byID[summary.jobID] = append(byID[summary.jobID], summary)
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
base := name[:len(name)-len(".log")]
|
base := name[:len(name)-len(".log")]
|
||||||
@@ -65,7 +66,7 @@ func SeedStats(logsDir string, jobs []domain.Job, maxFiles int) map[int]SeededSt
|
|||||||
if idx < 0 {
|
if idx < 0 {
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
byName[base[idx+1:]] = append(byName[base[idx+1:]], name)
|
byName[base[idx+1:]] = append(byName[base[idx+1:]], summary)
|
||||||
}
|
}
|
||||||
|
|
||||||
for _, job := range jobs {
|
for _, job := range jobs {
|
||||||
@@ -76,37 +77,37 @@ func SeedStats(logsDir string, jobs []domain.Job, maxFiles int) map[int]SeededSt
|
|||||||
if len(files) == 0 {
|
if len(files) == 0 {
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
// The timestamp prefix sorts chronologically, so a lexical sort puts the
|
// The timestamp prefix sorts chronologically, so a lexical sort by file
|
||||||
// oldest first; keep the newest maxFiles to honor the retention bound.
|
// name puts the oldest first; keep the newest maxFiles to honor the
|
||||||
sort.Strings(files)
|
// retention bound.
|
||||||
|
sort.Slice(files, func(i, j int) bool { return files[i].name < files[j].name })
|
||||||
if maxFiles > 0 && len(files) > maxFiles {
|
if maxFiles > 0 && len(files) > maxFiles {
|
||||||
files = files[len(files)-maxFiles:]
|
files = files[len(files)-maxFiles:]
|
||||||
}
|
}
|
||||||
result[job.ID] = aggregateLogStats(logsDir, files)
|
result[job.ID] = aggregateLogStats(files)
|
||||||
}
|
}
|
||||||
return result
|
return result
|
||||||
}
|
}
|
||||||
|
|
||||||
// aggregateLogStats folds the header of each log file (oldest first) into one
|
// aggregateLogStats folds the already-read header of each log file (oldest
|
||||||
// SeededStats. Files lacking a duration line contribute to the run/fail counts
|
// first) into one SeededStats. Files lacking a duration line contribute to the
|
||||||
// but not to the duration aggregates.
|
// run/fail counts but not to the duration aggregates.
|
||||||
func aggregateLogStats(logsDir string, files []string) SeededStats {
|
func aggregateLogStats(files []logSummary) SeededStats {
|
||||||
var stats SeededStats
|
var stats SeededStats
|
||||||
var durationSum int64
|
var durationSum int64
|
||||||
var durationCount int
|
var durationCount int
|
||||||
for _, file := range files {
|
for _, file := range files {
|
||||||
state, durationMS, hasDuration := readLogHeader(filepath.Join(logsDir, file))
|
|
||||||
stats.RunCount++
|
stats.RunCount++
|
||||||
if state == "Failed" {
|
if file.state == "Failed" {
|
||||||
stats.FailCount++
|
stats.FailCount++
|
||||||
}
|
}
|
||||||
if hasDuration {
|
if file.hasDuration {
|
||||||
// Files are oldest first, so the last assignment is the newest run.
|
// Files are oldest first, so the last assignment is the newest run.
|
||||||
stats.LastDurationMS = durationMS
|
stats.LastDurationMS = file.durationMS
|
||||||
if durationMS > stats.MaxDurationMS {
|
if file.durationMS > stats.MaxDurationMS {
|
||||||
stats.MaxDurationMS = durationMS
|
stats.MaxDurationMS = file.durationMS
|
||||||
}
|
}
|
||||||
durationSum += durationMS
|
durationSum += file.durationMS
|
||||||
durationCount++
|
durationCount++
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -117,39 +118,29 @@ func aggregateLogStats(logsDir string, files []string) SeededStats {
|
|||||||
return stats
|
return stats
|
||||||
}
|
}
|
||||||
|
|
||||||
// readLogJobID reads the job_id field from a log file header.
|
// logSummary is everything SeedStats needs from one run log: the file name it
|
||||||
func readLogJobID(path string) (int, bool) {
|
// sorts by, which job wrote it, how the run ended, and how long it took.
|
||||||
file, err := os.Open(path)
|
type logSummary struct {
|
||||||
if err != nil {
|
name string
|
||||||
return 0, false
|
jobID int
|
||||||
}
|
hasJobID bool
|
||||||
defer file.Close()
|
state string
|
||||||
|
durationMS int64
|
||||||
scanner := bufio.NewScanner(file)
|
hasDuration bool
|
||||||
for scanner.Scan() {
|
|
||||||
line := scanner.Text()
|
|
||||||
if line == "" {
|
|
||||||
break
|
|
||||||
}
|
|
||||||
if rest, ok := strings.CutPrefix(line, "job_id: "); ok {
|
|
||||||
id, err := strconv.Atoi(strings.TrimSpace(rest))
|
|
||||||
if err != nil {
|
|
||||||
return 0, false
|
|
||||||
}
|
|
||||||
return id, true
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return 0, false
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// readLogHeader reads the "state" and "duration" fields from a log file's
|
// readLogSummary reads the job_id, state, and duration fields from a log file's
|
||||||
// header (the lines before the first blank line). hasDuration reports whether a
|
// header (the lines before the first blank line) in a single pass, so seeding
|
||||||
// well-formed duration line was present, distinguishing a legacy duration-less
|
// opens each log once rather than once to find its job and again to read its
|
||||||
// log from one that genuinely recorded a zero-millisecond run.
|
// result. The has* flags report whether a well-formed line was present,
|
||||||
func readLogHeader(path string) (state string, durationMS int64, hasDuration bool) {
|
// distinguishing a legacy log written before the field existed from one that
|
||||||
|
// genuinely recorded a zero value. An unreadable file yields a zero summary,
|
||||||
|
// which falls back to matching by the job name in the file name.
|
||||||
|
func readLogSummary(path string) logSummary {
|
||||||
|
var summary logSummary
|
||||||
file, err := os.Open(path)
|
file, err := os.Open(path)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return "", 0, false
|
return summary
|
||||||
}
|
}
|
||||||
defer file.Close()
|
defer file.Close()
|
||||||
|
|
||||||
@@ -159,14 +150,19 @@ func readLogHeader(path string) (state string, durationMS int64, hasDuration boo
|
|||||||
if line == "" {
|
if line == "" {
|
||||||
break // end of header
|
break // end of header
|
||||||
}
|
}
|
||||||
if rest, ok := strings.CutPrefix(line, "state: "); ok {
|
if rest, ok := strings.CutPrefix(line, "job_id: "); ok {
|
||||||
state = strings.TrimSpace(rest)
|
if id, err := strconv.Atoi(strings.TrimSpace(rest)); err == nil {
|
||||||
|
summary.jobID = id
|
||||||
|
summary.hasJobID = true
|
||||||
|
}
|
||||||
|
} else if rest, ok := strings.CutPrefix(line, "state: "); ok {
|
||||||
|
summary.state = strings.TrimSpace(rest)
|
||||||
} else if rest, ok := strings.CutPrefix(line, "duration: "); ok {
|
} else if rest, ok := strings.CutPrefix(line, "duration: "); ok {
|
||||||
if value, err := strconv.ParseInt(strings.TrimSpace(rest), 10, 64); err == nil {
|
if value, err := strconv.ParseInt(strings.TrimSpace(rest), 10, 64); err == nil {
|
||||||
durationMS = value
|
summary.durationMS = value
|
||||||
hasDuration = true
|
summary.hasDuration = true
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
return state, durationMS, hasDuration
|
return summary
|
||||||
}
|
}
|
||||||
|
|||||||
+41
-13
@@ -65,19 +65,47 @@ func OpenStore() (*Store, []domain.Job, error) {
|
|||||||
return store, jobs, nil
|
return store, jobs, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func (s *Store) SaveConfig() error {
|
// PrepareSaveConfig re-resolves the derived paths from the current config and
|
||||||
|
// snapshots everything the write needs, returning the write itself as a closure.
|
||||||
|
// It exists so a caller that guards the Store with its own lock can do the file
|
||||||
|
// I/O — a marshal, an fsync, and a rename — after releasing that lock: the
|
||||||
|
// snapshot cannot change under the closure, so running it unlocked is safe.
|
||||||
|
// Prepared writes must be run in the order they were prepared, or an older
|
||||||
|
// snapshot can land on top of a newer one.
|
||||||
|
func (s *Store) PrepareSaveConfig() func() error {
|
||||||
s.applyConfigPaths()
|
s.applyConfigPaths()
|
||||||
if err := os.MkdirAll(s.Paths.AppDir, 0o755); err != nil {
|
dir := s.Paths.AppDir
|
||||||
|
path := s.Paths.ConfigPath
|
||||||
|
config := s.Config
|
||||||
|
return func() error {
|
||||||
|
if err := os.MkdirAll(dir, 0o755); err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
return writeJSON(s.Paths.ConfigPath, s.Config)
|
return writeJSON(path, config)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// PrepareSaveJobs is PrepareSaveConfig for the jobs file. The jobs slice is
|
||||||
|
// copied, so the caller may keep mutating its own slice as soon as this returns.
|
||||||
|
func (s *Store) PrepareSaveJobs(jobs []domain.Job) func() error {
|
||||||
|
dir := s.Paths.JobsDir
|
||||||
|
path := s.Paths.JobsPath
|
||||||
|
snapshot := make([]domain.Job, len(jobs))
|
||||||
|
copy(snapshot, jobs)
|
||||||
|
return func() error {
|
||||||
|
if err := os.MkdirAll(dir, 0o755); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
return writeJSON(path, domain.JobsFile{Jobs: snapshot})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Store) SaveConfig() error {
|
||||||
|
return s.PrepareSaveConfig()()
|
||||||
}
|
}
|
||||||
|
|
||||||
func (s *Store) SaveJobs(jobs []domain.Job) error {
|
func (s *Store) SaveJobs(jobs []domain.Job) error {
|
||||||
if err := os.MkdirAll(s.Paths.JobsDir, 0o755); err != nil {
|
return s.PrepareSaveJobs(jobs)()
|
||||||
return err
|
|
||||||
}
|
|
||||||
return writeJSON(s.Paths.JobsPath, domain.JobsFile{Jobs: jobs})
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func loadOrCreateConfig(paths Paths) (domain.Config, error) {
|
func loadOrCreateConfig(paths Paths) (domain.Config, error) {
|
||||||
@@ -116,12 +144,12 @@ func loadOrCreateConfig(paths Paths) (domain.Config, error) {
|
|||||||
if strings.TrimSpace(config.LogsDir) == "" {
|
if strings.TrimSpace(config.LogsDir) == "" {
|
||||||
config.LogsDir = "logs"
|
config.LogsDir = "logs"
|
||||||
}
|
}
|
||||||
if config.MaxLogFiles <= 0 {
|
// MaxLogFiles and MaxLogAgeDays are deliberately not normalized: 0 means
|
||||||
config.MaxLogFiles = 100
|
// "keep everything" (see runner.CleanupLogs), not a missing value, so
|
||||||
}
|
// backfilling it here would make that choice impossible to persist. A config
|
||||||
if config.MaxLogAgeDays <= 0 {
|
// written before either field existed already carries 0 from json.Unmarshal
|
||||||
config.MaxLogAgeDays = 30
|
// leaving the DefaultConfig() value in config untouched, so old files still
|
||||||
}
|
// pick up 100 / 30 without an explicit backfill.
|
||||||
if config.ExecutionMode == "" {
|
if config.ExecutionMode == "" {
|
||||||
config.ExecutionMode = domain.ExecutionModeParallel
|
config.ExecutionMode = domain.ExecutionModeParallel
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -183,6 +183,35 @@ func TestLoadOrCreateConfigCreatesDefaultsOnFirstRun(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// TestLoadOrCreateConfigPreservesZeroRetentionLimits verifies that 0 in
|
||||||
|
// max_log_files / max_log_age_days is read back as 0 ("keep everything"), not
|
||||||
|
// backfilled to the 100 / 30 defaults, since a config that already has the
|
||||||
|
// field set is not the "field is missing" case loadOrCreateConfig backfills.
|
||||||
|
func TestLoadOrCreateConfigPreservesZeroRetentionLimits(t *testing.T) {
|
||||||
|
dir := t.TempDir()
|
||||||
|
paths := Paths{
|
||||||
|
AppDir: dir,
|
||||||
|
ConfigPath: filepath.Join(dir, ConfigFileName),
|
||||||
|
}
|
||||||
|
want := domain.DefaultConfig()
|
||||||
|
want.MaxLogFiles = 0
|
||||||
|
want.MaxLogAgeDays = 0
|
||||||
|
if err := writeJSON(paths.ConfigPath, want); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
got, err := loadOrCreateConfig(paths)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if got.MaxLogFiles != 0 {
|
||||||
|
t.Errorf("MaxLogFiles: got %d, want 0 (unlimited)", got.MaxLogFiles)
|
||||||
|
}
|
||||||
|
if got.MaxLogAgeDays != 0 {
|
||||||
|
t.Errorf("MaxLogAgeDays: got %d, want 0 (unlimited)", got.MaxLogAgeDays)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// TestLoadOrCreateJobsSeedsSampleJobsOnFirstRun verifies that a missing
|
// TestLoadOrCreateJobsSeedsSampleJobsOnFirstRun verifies that a missing
|
||||||
// jobs.json is created with the sample jobs from defaultJobs, so a new user
|
// jobs.json is created with the sample jobs from defaultJobs, so a new user
|
||||||
// sees scheduled and manual execution without inventing a command.
|
// sees scheduled and manual execution without inventing a command.
|
||||||
|
|||||||
+156
-313
@@ -1,8 +1,6 @@
|
|||||||
package ui
|
package ui
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"fmt"
|
|
||||||
|
|
||||||
"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"
|
||||||
|
|
||||||
@@ -22,340 +20,185 @@ const noFolder = "No folder"
|
|||||||
// view; this panel is a quick at-a-glance summary anchored below the output.
|
// view; this panel is a quick at-a-glance summary anchored below the output.
|
||||||
const maxJobActivityRows = 3
|
const maxJobActivityRows = 3
|
||||||
|
|
||||||
|
// jobsView owns the Jobs tab: the widgets, the view-only preferences they draw
|
||||||
|
// (list mode and the scheduler pause label), and the jobsViewState the widgets
|
||||||
|
// read. It replaces a single constructor whose dozen closures shared seven
|
||||||
|
// mutable locals — the state each handler touches is now named on the struct
|
||||||
|
// rather than captured, and the invariants that used to be maintained by hand in
|
||||||
|
// five places live on jobsViewState.
|
||||||
|
type jobsView struct {
|
||||||
|
w fyne.Window
|
||||||
|
svc *app.Service
|
||||||
|
state *jobsViewState
|
||||||
|
dp *detailsPanel
|
||||||
|
|
||||||
|
list *widget.List
|
||||||
|
folderSelect *widget.Select
|
||||||
|
viewButton *widget.Button
|
||||||
|
stopAllButton *widget.Button
|
||||||
|
schedulerState *widget.Label
|
||||||
|
|
||||||
|
// listView and paused mirror Service-owned config so the widgets can be
|
||||||
|
// relabelled without a round trip. Both are re-read from the Service on every
|
||||||
|
// refresh; neither is a source of truth.
|
||||||
|
listView domain.JobListView
|
||||||
|
paused bool
|
||||||
|
}
|
||||||
|
|
||||||
// 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
|
||||||
// in mainwindow.go). The refresh function re-reads the service snapshot and
|
// in mainwindow.go). The refresh function re-reads the service snapshot and
|
||||||
// redraws all widgets in the jobs view; it does NOT touch history or settings.
|
// redraws all widgets in the jobs view; it does NOT touch history or settings.
|
||||||
func newJobsView(w fyne.Window, svc *app.Service) (fyne.CanvasObject, func()) {
|
func newJobsView(w fyne.Window, svc *app.Service) (fyne.CanvasObject, func()) {
|
||||||
jobs := svc.Jobs()
|
config := svc.Config()
|
||||||
runtimes := make(map[int]*domain.JobRuntime, len(jobs))
|
v := &jobsView{
|
||||||
syncFromService := func() {
|
w: w,
|
||||||
jobs = svc.Jobs()
|
svc: svc,
|
||||||
for id := range runtimes {
|
state: newJobsViewState(svc),
|
||||||
delete(runtimes, id)
|
listView: config.JobListView,
|
||||||
}
|
paused: config.Paused,
|
||||||
for _, current := range jobs {
|
|
||||||
if rt := svc.Runtime(current.ID); rt != nil {
|
|
||||||
runtimes[current.ID] = rt
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
syncFromService()
|
|
||||||
runtimeFor := func(index int) *domain.JobRuntime {
|
|
||||||
if index < 0 || index >= len(jobs) {
|
|
||||||
return &domain.JobRuntime{}
|
|
||||||
}
|
|
||||||
if rt := runtimes[jobs[index].ID]; rt != nil {
|
|
||||||
return rt
|
|
||||||
}
|
|
||||||
return &domain.JobRuntime{}
|
|
||||||
}
|
}
|
||||||
|
v.dp = newDetailsPanel(job{}, &domain.JobRuntime{}, config.OverlapPolicy, config.DefaultTimeoutSeconds)
|
||||||
|
v.updateDetails()
|
||||||
|
|
||||||
selected := 0
|
// Build order follows what refresh() touches: the folder select fires its
|
||||||
if len(jobs) == 0 {
|
// OnChanged from SetSelected below, which refreshes, so every widget that
|
||||||
selected = -1
|
// refresh() reaches has to exist by then.
|
||||||
}
|
v.list = v.newList()
|
||||||
selectedFolder := allFolders
|
v.viewButton = v.newViewToggle()
|
||||||
schedulerPaused := svc.Store().Config.Paused
|
globalControls := v.newGlobalControls()
|
||||||
listView := svc.Store().Config.JobListView
|
v.folderSelect = v.newFolderSelect()
|
||||||
filteredJobs := filteredJobIndexes(jobs, selectedFolder)
|
v.folderSelect.SetSelected(v.state.folder)
|
||||||
|
v.syncListSelection()
|
||||||
|
|
||||||
dp := newDetailsPanel(job{}, &domain.JobRuntime{}, svc.Store().Config.OverlapPolicy, svc.Store().Config.DefaultTimeoutSeconds)
|
return v.assemble(globalControls), v.refresh
|
||||||
if selected >= 0 {
|
}
|
||||||
dp.update(jobs[selected], runtimeFor(selected), svc.Store().Config.OverlapPolicy, svc.Store().Config.DefaultTimeoutSeconds)
|
|
||||||
} else {
|
|
||||||
dp.clear()
|
|
||||||
}
|
|
||||||
|
|
||||||
updateDetails := func(index int) {
|
// refresh re-reads the Service and redraws the whole view. It is the single
|
||||||
if index < 0 || index >= len(jobs) {
|
// entry point for "something changed": the toolbar handlers call it after a
|
||||||
// A folder filter can temporarily leave no selectable rows. Clearing
|
// successful operation, and mainwindow's event observer calls it for everything
|
||||||
// the details panel avoids showing stale information for a hidden job.
|
// else.
|
||||||
dp.clear()
|
func (v *jobsView) refresh() {
|
||||||
return
|
v.state.sync()
|
||||||
}
|
// The pause state is Service-owned and can change from outside this view, so
|
||||||
selected = index
|
// it is re-read here rather than mirrored from the tap handler alone — that is
|
||||||
dp.update(jobs[selected], runtimeFor(selected), svc.Store().Config.OverlapPolicy, svc.Store().Config.DefaultTimeoutSeconds)
|
// what makes this view a consumer of SchedulerStateChanged.
|
||||||
}
|
v.applySchedulerState(v.svc.Config().Paused)
|
||||||
|
v.updateDetails()
|
||||||
|
v.dp.logs.Refresh()
|
||||||
|
v.list.Refresh()
|
||||||
|
v.syncListSelection()
|
||||||
|
}
|
||||||
|
|
||||||
// list and folderSelect are declared early so closures below can reference
|
// updateDetails repopulates the details pane from the current selection.
|
||||||
// them before the widget.NewList / widget.NewSelect calls assign the values.
|
func (v *jobsView) updateDetails() {
|
||||||
var list *widget.List
|
current, ok := v.state.selected()
|
||||||
var folderSelect *widget.Select
|
if !ok {
|
||||||
|
// A folder filter can temporarily leave no selectable rows. Clearing the
|
||||||
|
// details panel avoids showing stale information for a hidden job.
|
||||||
|
v.dp.clear()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
// Overlap policy and the default timeout are global settings that can change
|
||||||
|
// from the Settings tab while this view is open, so they are re-read on every
|
||||||
|
// update rather than captured once at construction.
|
||||||
|
config := v.svc.Config()
|
||||||
|
v.dp.update(current, v.state.runtime(current.ID), config.OverlapPolicy, config.DefaultTimeoutSeconds)
|
||||||
|
}
|
||||||
|
|
||||||
refreshView := func() {
|
// syncListSelection points the list's highlight at the selected job. It is what
|
||||||
syncFromService()
|
// keeps the highlight and the details pane describing the same job when the row
|
||||||
filteredJobs = filteredJobIndexes(jobs, selectedFolder)
|
// a job sits in moves — a job created or deleted above it, a folder filter
|
||||||
updateDetails(selected)
|
// applied, or a different jobs file adopted. widget.List.Select returns early
|
||||||
dp.logs.Refresh()
|
// when the row is already highlighted, so calling this on every refresh does not
|
||||||
if list != nil {
|
// fight the user's scrolling.
|
||||||
list.Refresh()
|
func (v *jobsView) syncListSelection() {
|
||||||
}
|
row := v.state.displayRow()
|
||||||
|
if row < 0 {
|
||||||
|
v.list.UnselectAll()
|
||||||
|
return
|
||||||
}
|
}
|
||||||
|
v.list.Select(row)
|
||||||
|
}
|
||||||
|
|
||||||
// applyRowMode expresses the current view mode as visibility on the row's
|
// rebuildFolders re-derives the folder filter's options from the current jobs.
|
||||||
// four labels. widget.List caches the row template's MinSize, and
|
// Creating, editing, and deleting a job can all add or remove a folder.
|
||||||
// list.Refresh() re-creates the template and recomputes it, so hiding lines
|
func (v *jobsView) rebuildFolders() {
|
||||||
// is what actually shrinks the rows: layout.NewCustomPaddedVBoxLayout and the
|
v.folderSelect.Options = folderOptions(v.state.jobs)
|
||||||
// border layout both skip hidden children when measuring.
|
v.folderSelect.Refresh()
|
||||||
applyRowMode := func(inlineStatus, meta, status fyne.CanvasObject) {
|
}
|
||||||
if listView.IsCompact() {
|
|
||||||
inlineStatus.Show()
|
|
||||||
meta.Hide()
|
|
||||||
status.Hide()
|
|
||||||
return
|
|
||||||
}
|
|
||||||
inlineStatus.Hide()
|
|
||||||
meta.Show()
|
|
||||||
status.Show()
|
|
||||||
}
|
|
||||||
|
|
||||||
list = widget.NewList(
|
// assemble puts the sidebar (global controls, folder filter, toolbar, list) and
|
||||||
func() int { return len(filteredJobs) },
|
// the details pane into the master/detail split the tab shows.
|
||||||
func() fyne.CanvasObject {
|
func (v *jobsView) assemble(globalControls fyne.CanvasObject) fyne.CanvasObject {
|
||||||
name := widget.NewLabelWithStyle("Job name", fyne.TextAlignLeading, fyne.TextStyle{Bold: true})
|
|
||||||
// Truncating stops a long name from pushing the compact row's status
|
|
||||||
// off the right-hand edge. Labels default to TextWrapOff, which grows
|
|
||||||
// the widget to fit instead.
|
|
||||||
name.Truncation = fyne.TextTruncateClip
|
|
||||||
inlineStatus := widget.NewLabel("status")
|
|
||||||
meta := widget.NewLabel("schedule")
|
|
||||||
status := widget.NewLabel("status")
|
|
||||||
applyRowMode(inlineStatus, meta, status)
|
|
||||||
nameLine := container.NewBorder(nil, nil, nil, inlineStatus, name)
|
|
||||||
return container.New(layout.NewCustomPaddedVBoxLayout(rowOverlap()), nameLine, meta, status)
|
|
||||||
},
|
|
||||||
func(id widget.ListItemID, item fyne.CanvasObject) {
|
|
||||||
row := item.(*fyne.Container)
|
|
||||||
// NewBorder keeps the center object first and appends the border slots
|
|
||||||
// after it, so nameLine is [name, inlineStatus].
|
|
||||||
nameLine := row.Objects[0].(*fyne.Container)
|
|
||||||
name := nameLine.Objects[0].(*widget.Label)
|
|
||||||
inlineStatus := nameLine.Objects[1].(*widget.Label)
|
|
||||||
meta := row.Objects[1].(*widget.Label)
|
|
||||||
status := row.Objects[2].(*widget.Label)
|
|
||||||
|
|
||||||
current := jobs[filteredJobs[id]]
|
|
||||||
name.SetText(current.Name)
|
|
||||||
// Keep each row compact: folder, schedule, and command are shown in one
|
|
||||||
// metadata line so the left pane stays useful even with many jobs.
|
|
||||||
meta.SetText(app.DisplayFolder(current.Folder) + " " + current.Schedule + " " + app.DisplayInvocation(current))
|
|
||||||
statusText := app.StatusText(current, runtimes[current.ID])
|
|
||||||
status.SetText(statusText)
|
|
||||||
inlineStatus.SetText(statusText)
|
|
||||||
// A full Refresh reuses rows built under the previous mode, so
|
|
||||||
// visibility cannot be left to the create callback alone.
|
|
||||||
applyRowMode(inlineStatus, meta, status)
|
|
||||||
},
|
|
||||||
)
|
|
||||||
list.OnSelected = func(id widget.ListItemID) {
|
|
||||||
if id < 0 || id >= len(filteredJobs) {
|
|
||||||
updateDetails(-1)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
updateDetails(filteredJobs[id])
|
|
||||||
}
|
|
||||||
if len(filteredJobs) > 0 && selected >= 0 {
|
|
||||||
list.Select(app.DisplayIndex(filteredJobs, selected))
|
|
||||||
}
|
|
||||||
|
|
||||||
folderSelect = widget.NewSelect(folderOptions(jobs), func(value string) {
|
|
||||||
if value == "" {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
selectedFolder = value
|
|
||||||
filteredJobs = filteredJobIndexes(jobs, selectedFolder)
|
|
||||||
if len(filteredJobs) == 0 {
|
|
||||||
// The "No folder" filter is intentionally allowed to be empty. It is a
|
|
||||||
// real filter choice, not an error state, so the selection is cleared.
|
|
||||||
// This path returns without reaching refreshView(), so it is the one
|
|
||||||
// place the list has to be redrawn by hand.
|
|
||||||
selected = -1
|
|
||||||
updateDetails(-1)
|
|
||||||
list.Refresh()
|
|
||||||
return
|
|
||||||
}
|
|
||||||
selected = filteredJobs[0]
|
|
||||||
list.Select(0)
|
|
||||||
refreshView()
|
|
||||||
})
|
|
||||||
folderSelect.SetSelected(selectedFolder)
|
|
||||||
|
|
||||||
// viewToggleIcon pairs with viewToggleText: both name the action the button
|
|
||||||
// performs, not the state it is in, matching stopAllButton's convention.
|
|
||||||
viewToggleIcon := func(current domain.JobListView) fyne.Resource {
|
|
||||||
if current.IsCompact() {
|
|
||||||
return theme.ViewFullScreenIcon()
|
|
||||||
}
|
|
||||||
return theme.ListIcon()
|
|
||||||
}
|
|
||||||
viewButton := widget.NewButtonWithIcon(viewToggleText(listView), viewToggleIcon(listView), nil)
|
|
||||||
viewButton.OnTapped = func() {
|
|
||||||
next := nextJobListView(listView)
|
|
||||||
listView = next
|
|
||||||
if err := svc.SetJobListView(next); err != nil {
|
|
||||||
// Roll the mode back and leave the button as it was, so the button
|
|
||||||
// never claims a preference that did not reach disk.
|
|
||||||
listView = nextJobListView(next)
|
|
||||||
dialog.ShowError(err, w)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
viewButton.SetText(viewToggleText(listView))
|
|
||||||
viewButton.SetIcon(viewToggleIcon(listView))
|
|
||||||
// Refresh re-creates the row template, which is what recomputes the
|
|
||||||
// cached row height for the new mode. Selection is untouched.
|
|
||||||
list.Refresh()
|
|
||||||
}
|
|
||||||
|
|
||||||
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) {
|
|
||||||
created, err := svc.CreateJob(saved)
|
|
||||||
if err != nil {
|
|
||||||
dialog.ShowError(err, w)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
syncFromService()
|
|
||||||
folderSelect.Options = folderOptions(jobs)
|
|
||||||
folderSelect.Refresh()
|
|
||||||
targetFolder := filterValue(created.Folder)
|
|
||||||
if selectedFolder != allFolders && selectedFolder != targetFolder {
|
|
||||||
selectedFolder = targetFolder
|
|
||||||
folderSelect.SetSelected(targetFolder)
|
|
||||||
}
|
|
||||||
selected = indexOfID(jobs, created.ID)
|
|
||||||
filteredJobs = filteredJobIndexes(jobs, selectedFolder)
|
|
||||||
list.Select(app.DisplayIndex(filteredJobs, selected))
|
|
||||||
refreshView()
|
|
||||||
})
|
|
||||||
})
|
|
||||||
editButton := widget.NewButtonWithIcon("Edit", theme.DocumentCreateIcon(), func() {
|
|
||||||
if selected < 0 || selected >= len(jobs) {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
showJobDialog(w, "Edit job", jobs[selected], func(saved job) {
|
|
||||||
saved.ID = jobs[selected].ID
|
|
||||||
if err := svc.UpdateJob(saved); err != nil {
|
|
||||||
dialog.ShowError(err, w)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
syncFromService()
|
|
||||||
folderSelect.Options = folderOptions(jobs)
|
|
||||||
folderSelect.Refresh()
|
|
||||||
refreshView()
|
|
||||||
})
|
|
||||||
})
|
|
||||||
runButton := widget.NewButtonWithIcon("Run now", theme.MediaPlayIcon(), func() {
|
|
||||||
if selected < 0 || selected >= len(jobs) {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
// A manual run is allowed even while the scheduler is paused: pause only
|
|
||||||
// stops automatic scheduled runs, not the user's explicit "Run now".
|
|
||||||
if err := svc.RunNow(jobs[selected].ID); err != nil {
|
|
||||||
dialog.ShowError(err, w)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
refreshView()
|
|
||||||
})
|
|
||||||
|
|
||||||
stopAllText, stopAllIcon := "Disable auto", theme.MediaPauseIcon()
|
|
||||||
if schedulerPaused {
|
|
||||||
stopAllText, stopAllIcon = "Enable auto", 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 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
|
|
||||||
dialog.ShowError(err, w)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
if schedulerPaused {
|
|
||||||
schedulerState.SetText("Scheduler paused")
|
|
||||||
stopAllButton.SetText("Enable auto")
|
|
||||||
stopAllButton.SetIcon(theme.MediaPlayIcon())
|
|
||||||
} else {
|
|
||||||
schedulerState.SetText("Scheduler running")
|
|
||||||
stopAllButton.SetText("Disable auto")
|
|
||||||
stopAllButton.SetIcon(theme.MediaPauseIcon())
|
|
||||||
}
|
|
||||||
refreshView()
|
|
||||||
}
|
|
||||||
pauseButton := widget.NewButtonWithIcon("Pause", theme.MediaPauseIcon(), func() {
|
|
||||||
if selected < 0 || selected >= len(jobs) {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
current := jobs[selected]
|
|
||||||
if err := svc.SetEnabled(current.ID, !current.Enabled); err != nil {
|
|
||||||
dialog.ShowError(err, w)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
refreshView()
|
|
||||||
})
|
|
||||||
deleteButton := widget.NewButtonWithIcon("Delete", theme.DeleteIcon(), func() {
|
|
||||||
if selected < 0 || selected >= len(jobs) {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
deleted := jobs[selected]
|
|
||||||
// Deletion is confirmed because jobs can represent real system actions.
|
|
||||||
// There is no undo yet, so accidental removal should require one more click.
|
|
||||||
dialog.ShowConfirm("Delete job", fmt.Sprintf("Delete %q?", deleted.Name), func(confirm bool) {
|
|
||||||
if !confirm {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
if err := svc.DeleteJob(deleted.ID); err != nil {
|
|
||||||
dialog.ShowError(err, w)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
syncFromService()
|
|
||||||
folderSelect.Options = folderOptions(jobs)
|
|
||||||
folderSelect.Refresh()
|
|
||||||
filteredJobs = filteredJobIndexes(jobs, selectedFolder)
|
|
||||||
if len(filteredJobs) == 0 && selectedFolder != allFolders {
|
|
||||||
selectedFolder = allFolders
|
|
||||||
folderSelect.SetSelected(allFolders)
|
|
||||||
filteredJobs = filteredJobIndexes(jobs, selectedFolder)
|
|
||||||
}
|
|
||||||
if len(filteredJobs) == 0 {
|
|
||||||
selected = -1
|
|
||||||
} else {
|
|
||||||
selected = filteredJobs[0]
|
|
||||||
}
|
|
||||||
if selected >= 0 {
|
|
||||||
list.Select(app.DisplayIndex(filteredJobs, selected))
|
|
||||||
}
|
|
||||||
refreshView()
|
|
||||||
}, w)
|
|
||||||
})
|
|
||||||
|
|
||||||
toolbar := container.NewHBox(addButton, editButton, runButton, pauseButton, deleteButton, layout.NewSpacer())
|
|
||||||
// The row sits directly under the tab bar with no AppTabs inset, while the
|
|
||||||
// default VBox gap below it is one theme padding — add the same on top so
|
|
||||||
// the button is not flush against the tabs.
|
|
||||||
globalControls := container.New(
|
|
||||||
layout.NewCustomPaddedLayout(theme.Padding(), 0, 0, 0),
|
|
||||||
container.NewHBox(stopAllButton, schedulerState, layout.NewSpacer()),
|
|
||||||
)
|
|
||||||
// The whole filter is one row: caption on the left, view toggle on the right,
|
// The whole filter is one row: caption on the left, view toggle on the right,
|
||||||
// select filling what is left. The border layout gives both edges their
|
// select filling what is left. The border layout gives both edges their
|
||||||
// MinSize, so the header is a line shorter than a stacked caption would make it.
|
// MinSize, so the header is a line shorter than a stacked caption would make it.
|
||||||
folderCaption := widget.NewLabelWithStyle("Folder", fyne.TextAlignLeading, fyne.TextStyle{Bold: true})
|
folderCaption := widget.NewLabelWithStyle("Folder", fyne.TextAlignLeading, fyne.TextStyle{Bold: true})
|
||||||
filterRow := container.NewBorder(nil, nil, folderCaption, viewButton, folderSelect)
|
filterRow := container.NewBorder(nil, nil, folderCaption, v.viewButton, v.folderSelect)
|
||||||
sidebarHeader := container.NewVBox(globalControls, widget.NewSeparator(), filterRow, toolbar)
|
sidebarHeader := container.NewVBox(globalControls, widget.NewSeparator(), filterRow, v.newToolbar())
|
||||||
sidebar := container.NewBorder(sidebarHeader, nil, nil, nil, list)
|
sidebar := container.NewBorder(sidebarHeader, nil, nil, nil, v.list)
|
||||||
|
|
||||||
// A split rather than a Border left slot: the border pinned the sidebar at its
|
// A split rather than a Border left slot: the border pinned the sidebar at its
|
||||||
// MinSize forever, so the user could never trade list width for detail width.
|
// MinSize forever, so the user could never trade list width for detail width.
|
||||||
// The divider lets either pane grow, and neither can be dragged below its own
|
// The divider lets either pane grow, and neither can be dragged below its own
|
||||||
// content minimum.
|
// content minimum.
|
||||||
panel := container.NewHSplit(sidebar, container.NewPadded(dp.container()))
|
panel := container.NewHSplit(sidebar, container.NewPadded(v.dp.container()))
|
||||||
panel.SetOffset(initialSplitOffset(sidebar.MinSize().Width))
|
panel.SetOffset(initialSplitOffset(sidebar.MinSize().Width))
|
||||||
return panel, refreshView
|
return panel
|
||||||
|
}
|
||||||
|
|
||||||
|
// newFolderSelect builds the folder filter. Selecting a folder narrows the list
|
||||||
|
// and, when the selected job is no longer visible, moves the selection to the
|
||||||
|
// first row that is (see jobsViewState.applyFilter).
|
||||||
|
func (v *jobsView) newFolderSelect() *widget.Select {
|
||||||
|
return widget.NewSelect(folderOptions(v.state.jobs), func(value string) {
|
||||||
|
if value == "" {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
v.state.applyFilter(value)
|
||||||
|
v.refresh()
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// newGlobalControls builds the pause control row that sits above the filter.
|
||||||
|
func (v *jobsView) newGlobalControls() fyne.CanvasObject {
|
||||||
|
v.schedulerState = widget.NewLabel("")
|
||||||
|
v.stopAllButton = widget.NewButtonWithIcon("", nil, nil)
|
||||||
|
v.applySchedulerState(v.paused)
|
||||||
|
v.stopAllButton.OnTapped = func() {
|
||||||
|
// SetGlobalPause flips the pause flag, updates every job's next-run text,
|
||||||
|
// and emits the activity record the observer logs. refresh re-derives the
|
||||||
|
// pause state from the Service, so a failed save leaves the control showing
|
||||||
|
// what actually happened.
|
||||||
|
if err := v.svc.SetGlobalPause(!v.paused); err != nil {
|
||||||
|
dialog.ShowError(err, v.w)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
v.refresh()
|
||||||
|
}
|
||||||
|
// The row sits directly under the tab bar with no AppTabs inset, while the
|
||||||
|
// default VBox gap below it is one theme padding — add the same on top so
|
||||||
|
// the button is not flush against the tabs.
|
||||||
|
return container.New(
|
||||||
|
layout.NewCustomPaddedLayout(theme.Padding(), 0, 0, 0),
|
||||||
|
container.NewHBox(v.stopAllButton, v.schedulerState, layout.NewSpacer()),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// applySchedulerState is the one place that draws the pause control and its
|
||||||
|
// status text from a pause value, so refresh can drive it from whatever the
|
||||||
|
// Service reports instead of only the tap handler mirroring its own toggle.
|
||||||
|
func (v *jobsView) applySchedulerState(paused bool) {
|
||||||
|
v.paused = paused
|
||||||
|
if paused {
|
||||||
|
v.schedulerState.SetText("Scheduler paused")
|
||||||
|
v.stopAllButton.SetText("Enable auto")
|
||||||
|
v.stopAllButton.SetIcon(theme.MediaPlayIcon())
|
||||||
|
return
|
||||||
|
}
|
||||||
|
v.schedulerState.SetText("Scheduler running")
|
||||||
|
v.stopAllButton.SetText("Disable auto")
|
||||||
|
v.stopAllButton.SetIcon(theme.MediaPauseIcon())
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,114 @@
|
|||||||
|
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/dialog"
|
||||||
|
"fyne.io/fyne/v2/layout"
|
||||||
|
"fyne.io/fyne/v2/theme"
|
||||||
|
"fyne.io/fyne/v2/widget"
|
||||||
|
)
|
||||||
|
|
||||||
|
// newList builds the sidebar's job list. Rows are drawn from jobsViewState's
|
||||||
|
// filtered view, so the row index the widget reports is a position in the
|
||||||
|
// filter, never an index into the job snapshot.
|
||||||
|
func (v *jobsView) newList() *widget.List {
|
||||||
|
list := widget.NewList(
|
||||||
|
func() int { return len(v.state.filtered) },
|
||||||
|
func() fyne.CanvasObject {
|
||||||
|
name := widget.NewLabelWithStyle("Job name", fyne.TextAlignLeading, fyne.TextStyle{Bold: true})
|
||||||
|
// Truncating stops a long name from pushing the compact row's status
|
||||||
|
// off the right-hand edge. Labels default to TextWrapOff, which grows
|
||||||
|
// the widget to fit instead.
|
||||||
|
name.Truncation = fyne.TextTruncateClip
|
||||||
|
inlineStatus := widget.NewLabel("status")
|
||||||
|
meta := widget.NewLabel("schedule")
|
||||||
|
status := widget.NewLabel("status")
|
||||||
|
v.applyRowMode(inlineStatus, meta, status)
|
||||||
|
nameLine := container.NewBorder(nil, nil, nil, inlineStatus, name)
|
||||||
|
return container.New(layout.NewCustomPaddedVBoxLayout(rowOverlap()), nameLine, meta, status)
|
||||||
|
},
|
||||||
|
func(id widget.ListItemID, item fyne.CanvasObject) {
|
||||||
|
current, ok := v.state.jobAt(int(id))
|
||||||
|
if !ok {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
row := item.(*fyne.Container)
|
||||||
|
// NewBorder keeps the center object first and appends the border slots
|
||||||
|
// after it, so nameLine is [name, inlineStatus].
|
||||||
|
nameLine := row.Objects[0].(*fyne.Container)
|
||||||
|
name := nameLine.Objects[0].(*widget.Label)
|
||||||
|
inlineStatus := nameLine.Objects[1].(*widget.Label)
|
||||||
|
meta := row.Objects[1].(*widget.Label)
|
||||||
|
status := row.Objects[2].(*widget.Label)
|
||||||
|
|
||||||
|
name.SetText(current.Name)
|
||||||
|
// Keep each row compact: folder, schedule, and command are shown in one
|
||||||
|
// metadata line so the left pane stays useful even with many jobs.
|
||||||
|
meta.SetText(app.DisplayFolder(current.Folder) + " " + current.Schedule + " " + app.DisplayInvocation(current))
|
||||||
|
statusText := app.StatusText(current, v.state.runtime(current.ID))
|
||||||
|
status.SetText(statusText)
|
||||||
|
inlineStatus.SetText(statusText)
|
||||||
|
// A full Refresh reuses rows built under the previous mode, so
|
||||||
|
// visibility cannot be left to the create callback alone.
|
||||||
|
v.applyRowMode(inlineStatus, meta, status)
|
||||||
|
},
|
||||||
|
)
|
||||||
|
list.OnSelected = func(id widget.ListItemID) {
|
||||||
|
v.state.selectRow(int(id))
|
||||||
|
v.updateDetails()
|
||||||
|
}
|
||||||
|
return list
|
||||||
|
}
|
||||||
|
|
||||||
|
// applyRowMode expresses the current view mode as visibility on the row's
|
||||||
|
// four labels. widget.List caches the row template's MinSize, and
|
||||||
|
// list.Refresh() re-creates the template and recomputes it, so hiding lines
|
||||||
|
// is what actually shrinks the rows: layout.NewCustomPaddedVBoxLayout and the
|
||||||
|
// border layout both skip hidden children when measuring.
|
||||||
|
func (v *jobsView) applyRowMode(inlineStatus, meta, status fyne.CanvasObject) {
|
||||||
|
if v.listView.IsCompact() {
|
||||||
|
inlineStatus.Show()
|
||||||
|
meta.Hide()
|
||||||
|
status.Hide()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
inlineStatus.Hide()
|
||||||
|
meta.Show()
|
||||||
|
status.Show()
|
||||||
|
}
|
||||||
|
|
||||||
|
// newViewToggle builds the compact/detailed switch that sits at the right edge
|
||||||
|
// of the filter row.
|
||||||
|
func (v *jobsView) newViewToggle() *widget.Button {
|
||||||
|
button := widget.NewButtonWithIcon(viewToggleText(v.listView), viewToggleIcon(v.listView), nil)
|
||||||
|
button.OnTapped = func() {
|
||||||
|
next := nextJobListView(v.listView)
|
||||||
|
v.listView = next
|
||||||
|
if err := v.svc.SetJobListView(next); err != nil {
|
||||||
|
// Roll the mode back and leave the button as it was, so the button
|
||||||
|
// never claims a preference that did not reach disk.
|
||||||
|
v.listView = nextJobListView(next)
|
||||||
|
dialog.ShowError(err, v.w)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
button.SetText(viewToggleText(v.listView))
|
||||||
|
button.SetIcon(viewToggleIcon(v.listView))
|
||||||
|
// Refresh re-creates the row template, which is what recomputes the
|
||||||
|
// cached row height for the new mode. Selection is untouched.
|
||||||
|
v.list.Refresh()
|
||||||
|
}
|
||||||
|
return button
|
||||||
|
}
|
||||||
|
|
||||||
|
// viewToggleIcon pairs with viewToggleText: both name the action the button
|
||||||
|
// performs, not the state it is in, matching stopAllButton's convention.
|
||||||
|
func viewToggleIcon(current domain.JobListView) fyne.Resource {
|
||||||
|
if current.IsCompact() {
|
||||||
|
return theme.ViewFullScreenIcon()
|
||||||
|
}
|
||||||
|
return theme.ListIcon()
|
||||||
|
}
|
||||||
@@ -0,0 +1,156 @@
|
|||||||
|
package ui
|
||||||
|
|
||||||
|
import (
|
||||||
|
"gitea.mixdep.ru/mix/gosentry/src/app"
|
||||||
|
"gitea.mixdep.ru/mix/gosentry/src/domain"
|
||||||
|
)
|
||||||
|
|
||||||
|
// jobsViewState is the model behind the Jobs tab: the snapshot of the Service's
|
||||||
|
// jobs and runtimes, the folder filter, and the selection. The widgets in
|
||||||
|
// jobsView read it and never keep a second copy of any of it.
|
||||||
|
//
|
||||||
|
// The selection is a job ID, not an index into the snapshot. Every path that
|
||||||
|
// changes the job list replaces that snapshot underneath the view — create,
|
||||||
|
// delete, and edit do it from this view's own handlers, but adopting a different
|
||||||
|
// jobs file does it from the Service, and the view only learns about it through
|
||||||
|
// the refresh that JobsLoaded triggers. An index that outlives its snapshot then
|
||||||
|
// points at whichever job happens to sit there now, so the details pane
|
||||||
|
// describes one job while the list highlights another. Indexes are derived from
|
||||||
|
// the ID at render time instead (selectedIndex, displayRow).
|
||||||
|
type jobsViewState struct {
|
||||||
|
svc *app.Service
|
||||||
|
jobs []job
|
||||||
|
runtimes map[int]*domain.JobRuntime
|
||||||
|
folder string
|
||||||
|
// selectedID is 0 when nothing is selected; job IDs start at 1.
|
||||||
|
selectedID int
|
||||||
|
// filtered holds the indexes into jobs that the folder filter shows, in list
|
||||||
|
// row order: filtered[row] is the index of the job drawn in that row.
|
||||||
|
filtered []int
|
||||||
|
}
|
||||||
|
|
||||||
|
func newJobsViewState(svc *app.Service) *jobsViewState {
|
||||||
|
s := &jobsViewState{
|
||||||
|
svc: svc,
|
||||||
|
runtimes: map[int]*domain.JobRuntime{},
|
||||||
|
folder: allFolders,
|
||||||
|
}
|
||||||
|
s.sync()
|
||||||
|
return s
|
||||||
|
}
|
||||||
|
|
||||||
|
// sync re-reads the Service snapshot, re-applies the folder filter, and
|
||||||
|
// re-resolves the selection against the new list. It is the only place the view
|
||||||
|
// reads job state from the Service.
|
||||||
|
func (s *jobsViewState) sync() {
|
||||||
|
s.jobs = s.svc.Jobs()
|
||||||
|
clear(s.runtimes)
|
||||||
|
for _, current := range s.jobs {
|
||||||
|
if rt := s.svc.Runtime(current.ID); rt != nil {
|
||||||
|
s.runtimes[current.ID] = rt
|
||||||
|
}
|
||||||
|
}
|
||||||
|
s.filtered = filteredJobIndexes(s.jobs, s.folder)
|
||||||
|
s.resolveSelection()
|
||||||
|
}
|
||||||
|
|
||||||
|
// applyFilter switches the folder filter, keeping the current selection when the
|
||||||
|
// new filter still shows it. A filter that matches nothing — "No folder" with no
|
||||||
|
// such job — is a real filter choice, not an error state, so it simply leaves
|
||||||
|
// nothing selected.
|
||||||
|
func (s *jobsViewState) applyFilter(folder string) {
|
||||||
|
s.folder = folder
|
||||||
|
s.filtered = filteredJobIndexes(s.jobs, s.folder)
|
||||||
|
if !s.visible(s.selectedID) {
|
||||||
|
s.selectedID = 0
|
||||||
|
}
|
||||||
|
s.resolveSelection()
|
||||||
|
}
|
||||||
|
|
||||||
|
// resolveSelection drops a selection whose job is gone and falls back to the
|
||||||
|
// first visible row, so the details pane never describes a job the current
|
||||||
|
// snapshot no longer holds.
|
||||||
|
func (s *jobsViewState) resolveSelection() {
|
||||||
|
if s.selectedID != 0 && indexOfID(s.jobs, s.selectedID) < 0 {
|
||||||
|
s.selectedID = 0
|
||||||
|
}
|
||||||
|
if s.selectedID == 0 && len(s.filtered) > 0 {
|
||||||
|
s.selectedID = s.jobs[s.filtered[0]].ID
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// selectByID records the selection directly, for handlers that know the job they
|
||||||
|
// want selected (a newly created job, for instance) rather than its row.
|
||||||
|
func (s *jobsViewState) selectByID(id int) {
|
||||||
|
s.selectedID = id
|
||||||
|
}
|
||||||
|
|
||||||
|
// selectRow records the selection from a list row, which is what widget.List
|
||||||
|
// reports through OnSelected.
|
||||||
|
func (s *jobsViewState) selectRow(row int) {
|
||||||
|
current, ok := s.jobAt(row)
|
||||||
|
if !ok {
|
||||||
|
s.selectedID = 0
|
||||||
|
return
|
||||||
|
}
|
||||||
|
s.selectedID = current.ID
|
||||||
|
}
|
||||||
|
|
||||||
|
// selected returns the selected job, or false when nothing is selected.
|
||||||
|
func (s *jobsViewState) selected() (job, bool) {
|
||||||
|
index := s.selectedIndex()
|
||||||
|
if index < 0 {
|
||||||
|
return job{}, false
|
||||||
|
}
|
||||||
|
return s.jobs[index], true
|
||||||
|
}
|
||||||
|
|
||||||
|
// selectedIndex resolves the selected ID to an index into the current snapshot,
|
||||||
|
// or -1 when nothing is selected.
|
||||||
|
func (s *jobsViewState) selectedIndex() int {
|
||||||
|
if s.selectedID == 0 {
|
||||||
|
return -1
|
||||||
|
}
|
||||||
|
return indexOfID(s.jobs, s.selectedID)
|
||||||
|
}
|
||||||
|
|
||||||
|
// displayRow maps the selection onto a list row, or -1 when nothing is selected
|
||||||
|
// or the filter hides the selected job — so a caller unselects rather than
|
||||||
|
// highlighting an unrelated row.
|
||||||
|
func (s *jobsViewState) displayRow() int {
|
||||||
|
index := s.selectedIndex()
|
||||||
|
if index < 0 || !s.visible(s.selectedID) {
|
||||||
|
return -1
|
||||||
|
}
|
||||||
|
return app.DisplayIndex(s.filtered, index)
|
||||||
|
}
|
||||||
|
|
||||||
|
// jobAt returns the job drawn in the given list row.
|
||||||
|
func (s *jobsViewState) jobAt(row int) (job, bool) {
|
||||||
|
if row < 0 || row >= len(s.filtered) {
|
||||||
|
return job{}, false
|
||||||
|
}
|
||||||
|
return s.jobs[s.filtered[row]], true
|
||||||
|
}
|
||||||
|
|
||||||
|
// runtime returns a job's runtime, or an empty one when the Service has none
|
||||||
|
// yet, so callers can read it without a nil check.
|
||||||
|
func (s *jobsViewState) runtime(id int) *domain.JobRuntime {
|
||||||
|
if rt := s.runtimes[id]; rt != nil {
|
||||||
|
return rt
|
||||||
|
}
|
||||||
|
return &domain.JobRuntime{}
|
||||||
|
}
|
||||||
|
|
||||||
|
// visible reports whether the folder filter shows the given job.
|
||||||
|
func (s *jobsViewState) visible(id int) bool {
|
||||||
|
if id == 0 {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
for _, index := range s.filtered {
|
||||||
|
if s.jobs[index].ID == id {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
}
|
||||||
@@ -0,0 +1,198 @@
|
|||||||
|
package ui
|
||||||
|
|
||||||
|
import (
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"gitea.mixdep.ru/mix/gosentry/src/app"
|
||||||
|
"gitea.mixdep.ru/mix/gosentry/src/domain"
|
||||||
|
)
|
||||||
|
|
||||||
|
// newStateForTest builds a jobsViewState over a Service holding the given jobs.
|
||||||
|
// No Fyne app is needed: the state is the view's model and touches no widgets.
|
||||||
|
func newStateForTest(t *testing.T, jobs []domain.Job) (*jobsViewState, *app.Service) {
|
||||||
|
t.Helper()
|
||||||
|
svc := app.NewService(newTestStore(t), jobs)
|
||||||
|
t.Cleanup(svc.Stop)
|
||||||
|
return newJobsViewState(svc), svc
|
||||||
|
}
|
||||||
|
|
||||||
|
func threeJobs() []domain.Job {
|
||||||
|
return []domain.Job{
|
||||||
|
{ID: 1, Name: "First", Folder: "Maintenance", Schedule: "@every 1m", Command: "echo one", Enabled: true},
|
||||||
|
{ID: 2, Name: "Second", Schedule: "@every 2m", Command: "echo two", Enabled: true},
|
||||||
|
{ID: 3, Name: "Third", Folder: "Reports", Schedule: "@every 3m", Command: "echo three", Enabled: true},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func selectedName(t *testing.T, s *jobsViewState) string {
|
||||||
|
t.Helper()
|
||||||
|
current, ok := s.selected()
|
||||||
|
if !ok {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
return current.Name
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestJobsViewStateSelectsTheFirstJob pins the opening state: the first row is
|
||||||
|
// selected so the details pane is never blank when there is something to show.
|
||||||
|
func TestJobsViewStateSelectsTheFirstJob(t *testing.T) {
|
||||||
|
s, _ := newStateForTest(t, threeJobs())
|
||||||
|
if got := selectedName(t, s); got != "First" {
|
||||||
|
t.Errorf("selected job = %q, want %q", got, "First")
|
||||||
|
}
|
||||||
|
if got := s.displayRow(); got != 0 {
|
||||||
|
t.Errorf("displayRow = %d, want 0", got)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestJobsViewStateEmptyListSelectsNothing(t *testing.T) {
|
||||||
|
s, _ := newStateForTest(t, nil)
|
||||||
|
if _, ok := s.selected(); ok {
|
||||||
|
t.Error("an empty job list should leave nothing selected")
|
||||||
|
}
|
||||||
|
if got := s.displayRow(); got != -1 {
|
||||||
|
t.Errorf("displayRow with nothing selected = %d, want -1", got)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestJobsViewStateSelectionFollowsTheJobNotTheRow is the regression guard for
|
||||||
|
// the selection defect: the selection is a job ID, so a job removed above the
|
||||||
|
// selected one must not slide the selection onto its neighbour. The deletion
|
||||||
|
// goes through the Service rather than the Delete button, which is how the view
|
||||||
|
// learns about a job list that changed underneath it (a different jobs file
|
||||||
|
// adopted, or any other broad JobChanged).
|
||||||
|
func TestJobsViewStateSelectionFollowsTheJobNotTheRow(t *testing.T) {
|
||||||
|
s, svc := newStateForTest(t, threeJobs())
|
||||||
|
|
||||||
|
s.selectRow(2)
|
||||||
|
if got := selectedName(t, s); got != "Third" {
|
||||||
|
t.Fatalf("selected job after selecting row 2 = %q, want %q", got, "Third")
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := svc.DeleteJob(1); err != nil {
|
||||||
|
t.Fatalf("DeleteJob: %v", err)
|
||||||
|
}
|
||||||
|
s.sync()
|
||||||
|
|
||||||
|
if got := selectedName(t, s); got != "Third" {
|
||||||
|
t.Errorf("selected job after the first job was removed = %q, want it still on %q", got, "Third")
|
||||||
|
}
|
||||||
|
if got := s.displayRow(); got != 1 {
|
||||||
|
t.Errorf("displayRow = %d, want the row %q moved to (1)", got, "Third")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestJobsViewStateDropsSelectionWhenItsJobIsGone covers the other half: a
|
||||||
|
// selected job that no longer exists falls back to the first visible row instead
|
||||||
|
// of describing whichever job inherited its position.
|
||||||
|
func TestJobsViewStateDropsSelectionWhenItsJobIsGone(t *testing.T) {
|
||||||
|
s, svc := newStateForTest(t, threeJobs())
|
||||||
|
|
||||||
|
s.selectRow(1)
|
||||||
|
if err := svc.DeleteJob(2); err != nil {
|
||||||
|
t.Fatalf("DeleteJob: %v", err)
|
||||||
|
}
|
||||||
|
s.sync()
|
||||||
|
|
||||||
|
if got := selectedName(t, s); got != "First" {
|
||||||
|
t.Errorf("selected job after deleting the selected one = %q, want the fallback %q", got, "First")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestJobsViewStateApplyFilter(t *testing.T) {
|
||||||
|
s, _ := newStateForTest(t, threeJobs())
|
||||||
|
|
||||||
|
// The selected job is in the folder being filtered to, so it stays selected.
|
||||||
|
s.selectRow(2)
|
||||||
|
s.applyFilter("Reports")
|
||||||
|
if got := selectedName(t, s); got != "Third" {
|
||||||
|
t.Errorf("selection after filtering to its own folder = %q, want %q", got, "Third")
|
||||||
|
}
|
||||||
|
if got := s.displayRow(); got != 0 {
|
||||||
|
t.Errorf("displayRow inside the filter = %d, want 0", got)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Filtering to a folder that hides it moves the selection to the first row
|
||||||
|
// that folder does show.
|
||||||
|
s.applyFilter("Maintenance")
|
||||||
|
if got := selectedName(t, s); got != "First" {
|
||||||
|
t.Errorf("selection after filtering it away = %q, want %q", got, "First")
|
||||||
|
}
|
||||||
|
|
||||||
|
// "No folder" matches the one job without one.
|
||||||
|
s.applyFilter(noFolder)
|
||||||
|
if got := selectedName(t, s); got != "Second" {
|
||||||
|
t.Errorf("selection under the %q filter = %q, want %q", noFolder, got, "Second")
|
||||||
|
}
|
||||||
|
|
||||||
|
s.applyFilter(allFolders)
|
||||||
|
if got := len(s.filtered); got != 3 {
|
||||||
|
t.Errorf("rows under %q = %d, want 3", allFolders, got)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestJobsViewStateEmptyFilterSelectsNothing pins that a filter matching no job
|
||||||
|
// is a filter choice, not an error state: nothing is selected, and nothing is
|
||||||
|
// highlighted either.
|
||||||
|
func TestJobsViewStateEmptyFilterSelectsNothing(t *testing.T) {
|
||||||
|
s, _ := newStateForTest(t, []domain.Job{
|
||||||
|
{ID: 1, Name: "First", Folder: "Maintenance", Schedule: "@every 1m", Command: "echo one", Enabled: true},
|
||||||
|
})
|
||||||
|
|
||||||
|
s.applyFilter(noFolder)
|
||||||
|
if _, ok := s.selected(); ok {
|
||||||
|
t.Error("a filter that matches nothing should leave nothing selected")
|
||||||
|
}
|
||||||
|
if got := s.displayRow(); got != -1 {
|
||||||
|
t.Errorf("displayRow under an empty filter = %d, want -1", got)
|
||||||
|
}
|
||||||
|
|
||||||
|
// The selection comes back when the filter does.
|
||||||
|
s.applyFilter(allFolders)
|
||||||
|
if got := selectedName(t, s); got != "First" {
|
||||||
|
t.Errorf("selection after clearing the filter = %q, want %q", got, "First")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestJobsViewStateHiddenSelectionIsNotHighlighted covers the case the list
|
||||||
|
// widget cannot express: the selected job still exists but the filter hides it,
|
||||||
|
// so there is no row to highlight and displayRow must say so rather than fall
|
||||||
|
// back to row 0.
|
||||||
|
func TestJobsViewStateHiddenSelectionIsNotHighlighted(t *testing.T) {
|
||||||
|
s, _ := newStateForTest(t, threeJobs())
|
||||||
|
|
||||||
|
s.applyFilter("Maintenance")
|
||||||
|
// Selecting by ID is how the create handler points the view at a job it just
|
||||||
|
// made; here it reaches the state a hidden-but-selected job would be in.
|
||||||
|
s.selectByID(3)
|
||||||
|
if s.visible(3) {
|
||||||
|
t.Fatal("job 3 should be hidden by the Maintenance filter")
|
||||||
|
}
|
||||||
|
if got := s.displayRow(); got != -1 {
|
||||||
|
t.Errorf("displayRow for a hidden selection = %d, want -1", got)
|
||||||
|
}
|
||||||
|
if got := selectedName(t, s); got != "Third" {
|
||||||
|
t.Errorf("selected job = %q, want it still %q", got, "Third")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestJobsViewStateRuntimeIsNeverNil(t *testing.T) {
|
||||||
|
s, _ := newStateForTest(t, threeJobs())
|
||||||
|
if rt := s.runtime(99); rt == nil {
|
||||||
|
t.Error("runtime for an unknown job returned nil, want an empty runtime")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestJobsViewStateJobAtRejectsRowsOutsideTheFilter(t *testing.T) {
|
||||||
|
s, _ := newStateForTest(t, threeJobs())
|
||||||
|
s.applyFilter("Reports")
|
||||||
|
if current, ok := s.jobAt(0); !ok || current.Name != "Third" {
|
||||||
|
t.Errorf("jobAt(0) = (%q, %v), want (%q, true)", current.Name, ok, "Third")
|
||||||
|
}
|
||||||
|
if _, ok := s.jobAt(1); ok {
|
||||||
|
t.Error("jobAt past the last filtered row should report no job")
|
||||||
|
}
|
||||||
|
if _, ok := s.jobAt(-1); ok {
|
||||||
|
t.Error("jobAt(-1) should report no job")
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,6 +1,9 @@
|
|||||||
package ui
|
package ui
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"encoding/json"
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
"testing"
|
"testing"
|
||||||
|
|
||||||
"gitea.mixdep.ru/mix/gosentry/src/app"
|
"gitea.mixdep.ru/mix/gosentry/src/app"
|
||||||
@@ -378,7 +381,7 @@ func TestJobsSplitOpensAtTheSidebarWidth(t *testing.T) {
|
|||||||
|
|
||||||
// TestToolbarButtonRedrawsRowAndDetails is the regression guard for F12: the
|
// TestToolbarButtonRedrawsRowAndDetails is the regression guard for F12: the
|
||||||
// toolbar handlers no longer re-read the service or refresh the list
|
// toolbar handlers no longer re-read the service or refresh the list
|
||||||
// themselves, so refreshView alone has to re-snapshot the jobs and repopulate
|
// themselves, so jobsView.refresh alone has to re-snapshot the jobs and repopulate
|
||||||
// the details pane. If it ever stops doing either, the row renders a stale
|
// the details pane. If it ever stops doing either, the row renders a stale
|
||||||
// status and the details lose the selection — neither is a compile error.
|
// status and the details lose the selection — neither is a compile error.
|
||||||
func TestToolbarButtonRedrawsRowAndDetails(t *testing.T) {
|
func TestToolbarButtonRedrawsRowAndDetails(t *testing.T) {
|
||||||
@@ -441,6 +444,78 @@ func TestToolbarButtonRedrawsRowAndDetails(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// TestJobsViewSelectionSurvivesAJobsFileSwitch is the view-level regression
|
||||||
|
// guard for the selection defect. Adopting a different jobs file replaces the
|
||||||
|
// whole list from the Service; the view only hears about it through the refresh
|
||||||
|
// that JobsLoaded triggers, which is exactly what this test calls. With the
|
||||||
|
// selection held as a row index, that refresh redrew the details pane from the
|
||||||
|
// old index — describing whichever job now sat there, or clearing the pane when
|
||||||
|
// the new list was shorter — while the list's highlight stayed where it was.
|
||||||
|
func TestJobsViewSelectionSurvivesAJobsFileSwitch(t *testing.T) {
|
||||||
|
testApp := test.NewApp()
|
||||||
|
defer testApp.Quit()
|
||||||
|
w := testApp.NewWindow("test")
|
||||||
|
defer w.Close()
|
||||||
|
|
||||||
|
store := newTestStore(t)
|
||||||
|
svc := app.NewService(store, []domain.Job{
|
||||||
|
{ID: 1, Name: "First", Schedule: "@every 1m", Command: "echo one", Enabled: true},
|
||||||
|
{ID: 2, Name: "Second", Schedule: "@every 2m", Command: "echo two", Enabled: true},
|
||||||
|
{ID: 3, Name: "Third", Schedule: "@every 3m", Command: "echo three", Enabled: true},
|
||||||
|
})
|
||||||
|
defer svc.Stop()
|
||||||
|
|
||||||
|
content, refresh := newJobsView(w, svc)
|
||||||
|
w.SetContent(content)
|
||||||
|
|
||||||
|
list := jobsList(t, content)
|
||||||
|
list.Select(2)
|
||||||
|
if got := jobsDetailsTitle(t, content); got != "Third" {
|
||||||
|
t.Fatalf("details title after selecting row 2 = %q, want %q", got, "Third")
|
||||||
|
}
|
||||||
|
|
||||||
|
// A second jobs file with different jobs and different IDs, so nothing about
|
||||||
|
// the old selection can resolve into the new list.
|
||||||
|
other := []domain.Job{
|
||||||
|
{ID: 10, Name: "Alpha", Schedule: "@every 1m", Command: "echo alpha", Enabled: true},
|
||||||
|
{ID: 11, Name: "Beta", Schedule: "@every 2m", Command: "echo beta", Enabled: true},
|
||||||
|
}
|
||||||
|
payload, err := json.Marshal(domain.JobsFile{Jobs: other})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("marshal jobs: %v", err)
|
||||||
|
}
|
||||||
|
if err := os.WriteFile(filepath.Join(store.Paths.AppDir, "other.json"), payload, 0o644); err != nil {
|
||||||
|
t.Fatalf("write jobs file: %v", err)
|
||||||
|
}
|
||||||
|
config := svc.Config()
|
||||||
|
config.JobsFile = "other.json"
|
||||||
|
if err := svc.UpdateSettings(config); err != nil {
|
||||||
|
t.Fatalf("UpdateSettings: %v", err)
|
||||||
|
}
|
||||||
|
refresh()
|
||||||
|
|
||||||
|
if got := jobsDetailsTitle(t, content); got != "Alpha" {
|
||||||
|
t.Errorf("details title after the switch = %q, want the first job of the new file %q", got, "Alpha")
|
||||||
|
}
|
||||||
|
if got := list.Length(); got != len(other) {
|
||||||
|
t.Fatalf("list length after the switch = %d, want %d", got, len(other))
|
||||||
|
}
|
||||||
|
// widget.List.Select returns without calling OnSelected when the row is
|
||||||
|
// already highlighted, so a silent Select(0) is what proves the highlight and
|
||||||
|
// the details pane are describing the same job.
|
||||||
|
reselected := false
|
||||||
|
inner := list.OnSelected
|
||||||
|
list.OnSelected = func(id widget.ListItemID) {
|
||||||
|
reselected = true
|
||||||
|
inner(id)
|
||||||
|
}
|
||||||
|
defer func() { list.OnSelected = inner }()
|
||||||
|
list.Select(0)
|
||||||
|
if reselected {
|
||||||
|
t.Error("row 0 was not the highlighted row after the switch, so the highlight and the details pane disagree")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// TestDetailCaptionWidthCoversEveryCaption is the guard that makes the single
|
// TestDetailCaptionWidthCoversEveryCaption is the guard that makes the single
|
||||||
// metadataRows list self-enforcing (F10): every caption it returns must
|
// metadataRows list self-enforcing (F10): every caption it returns must
|
||||||
// measure no wider than captionColumnWidth's result for that same list, or a
|
// measure no wider than captionColumnWidth's result for that same list, or a
|
||||||
|
|||||||
@@ -0,0 +1,136 @@
|
|||||||
|
package ui
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
|
||||||
|
"fyne.io/fyne/v2"
|
||||||
|
"fyne.io/fyne/v2/container"
|
||||||
|
"fyne.io/fyne/v2/dialog"
|
||||||
|
"fyne.io/fyne/v2/layout"
|
||||||
|
"fyne.io/fyne/v2/theme"
|
||||||
|
"fyne.io/fyne/v2/widget"
|
||||||
|
)
|
||||||
|
|
||||||
|
// newToolbar builds the per-job button row under the folder filter. Every
|
||||||
|
// handler works from the selected job — never from a row index — and ends in
|
||||||
|
// refresh, which is what re-reads the Service and redraws the row, the details
|
||||||
|
// pane, and the list highlight.
|
||||||
|
func (v *jobsView) newToolbar() fyne.CanvasObject {
|
||||||
|
return container.NewHBox(
|
||||||
|
v.newAddButton(),
|
||||||
|
v.newEditButton(),
|
||||||
|
v.newRunButton(),
|
||||||
|
v.newPauseButton(),
|
||||||
|
v.newDeleteButton(),
|
||||||
|
layout.NewSpacer(),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (v *jobsView) newAddButton() *widget.Button {
|
||||||
|
return widget.NewButtonWithIcon("New job", theme.ContentAddIcon(), func() {
|
||||||
|
blank := job{Schedule: "@every 1m", Command: "echo GoSentry job ran", Enabled: true}
|
||||||
|
showJobDialog(v.w, "New job", blank, func(saved job) {
|
||||||
|
created, err := v.svc.CreateJob(saved)
|
||||||
|
if err != nil {
|
||||||
|
dialog.ShowError(err, v.w)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
v.state.sync()
|
||||||
|
// The new job may have introduced a folder, so the options are rebuilt
|
||||||
|
// before the filter is pointed at it.
|
||||||
|
v.rebuildFolders()
|
||||||
|
v.state.selectByID(created.ID)
|
||||||
|
if target := filterValue(created.Folder); v.state.folder != allFolders && v.state.folder != target {
|
||||||
|
// The current filter would hide the job the user just created. Switch
|
||||||
|
// to its folder; SetSelected fires OnChanged, which applies the filter
|
||||||
|
// and refreshes.
|
||||||
|
v.folderSelect.SetSelected(target)
|
||||||
|
}
|
||||||
|
v.refresh()
|
||||||
|
})
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
func (v *jobsView) newEditButton() *widget.Button {
|
||||||
|
return widget.NewButtonWithIcon("Edit", theme.DocumentCreateIcon(), func() {
|
||||||
|
current, ok := v.state.selected()
|
||||||
|
if !ok {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
showJobDialog(v.w, "Edit job", current, func(saved job) {
|
||||||
|
// The ID comes from the job the dialog was opened on, so a list that
|
||||||
|
// changed underneath the open dialog cannot redirect the save.
|
||||||
|
saved.ID = current.ID
|
||||||
|
if err := v.svc.UpdateJob(saved); err != nil {
|
||||||
|
dialog.ShowError(err, v.w)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
v.state.sync()
|
||||||
|
// An edit can rename the job's folder, add a new one, or empty the last
|
||||||
|
// job out of an existing one.
|
||||||
|
v.rebuildFolders()
|
||||||
|
v.refresh()
|
||||||
|
})
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
func (v *jobsView) newRunButton() *widget.Button {
|
||||||
|
return widget.NewButtonWithIcon("Run now", theme.MediaPlayIcon(), func() {
|
||||||
|
current, ok := v.state.selected()
|
||||||
|
if !ok {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
// A manual run is allowed even while the scheduler is paused: pause only
|
||||||
|
// stops automatic scheduled runs, not the user's explicit "Run now".
|
||||||
|
if err := v.svc.RunNow(current.ID); err != nil {
|
||||||
|
dialog.ShowError(err, v.w)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
v.refresh()
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
func (v *jobsView) newPauseButton() *widget.Button {
|
||||||
|
return widget.NewButtonWithIcon("Pause", theme.MediaPauseIcon(), func() {
|
||||||
|
current, ok := v.state.selected()
|
||||||
|
if !ok {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if err := v.svc.SetEnabled(current.ID, !current.Enabled); err != nil {
|
||||||
|
dialog.ShowError(err, v.w)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
v.refresh()
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
func (v *jobsView) newDeleteButton() *widget.Button {
|
||||||
|
return widget.NewButtonWithIcon("Delete", theme.DeleteIcon(), func() {
|
||||||
|
deleted, ok := v.state.selected()
|
||||||
|
if !ok {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
// Deletion is confirmed because jobs can represent real system actions.
|
||||||
|
// There is no undo yet, so accidental removal should require one more click.
|
||||||
|
dialog.ShowConfirm("Delete job", fmt.Sprintf("Delete %q?", deleted.Name), func(confirm bool) {
|
||||||
|
if !confirm {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if err := v.svc.DeleteJob(deleted.ID); err != nil {
|
||||||
|
dialog.ShowError(err, v.w)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
// sync drops the deleted job's selection and falls back to the first row
|
||||||
|
// the filter still shows.
|
||||||
|
v.state.sync()
|
||||||
|
v.rebuildFolders()
|
||||||
|
if len(v.state.filtered) == 0 && v.state.folder != allFolders {
|
||||||
|
// The deleted job was the last one in its folder, and that folder is
|
||||||
|
// no longer an option. Fall back to "All" rather than leaving the user
|
||||||
|
// on an empty filter they did not choose.
|
||||||
|
v.folderSelect.SetSelected(allFolders)
|
||||||
|
}
|
||||||
|
v.refresh()
|
||||||
|
}, v.w)
|
||||||
|
})
|
||||||
|
}
|
||||||
+17
-18
@@ -65,43 +65,42 @@ func newMainView(w fyne.Window, svc *app.Service) (fyne.CanvasObject, func(time.
|
|||||||
// the main thread in both cases, so the engine never mutates Fyne state off
|
// the main thread in both cases, so the engine never mutates Fyne state off
|
||||||
// the UI thread. This is the sole place events touch widgets. (Resolves #4.)
|
// the UI thread. This is the sole place events touch widgets. (Resolves #4.)
|
||||||
svc.Subscribe(app.ObserverFunc(func(ev app.Event) {
|
svc.Subscribe(app.ObserverFunc(func(ev app.Event) {
|
||||||
recorded, isRecorded := ev.(app.RunRecorded)
|
|
||||||
errOccurred, isError := ev.(app.ErrorOccurred)
|
|
||||||
jobsLoaded, isJobsLoaded := ev.(app.JobsLoaded)
|
|
||||||
fyne.Do(func() {
|
fyne.Do(func() {
|
||||||
if isRecorded {
|
// A type switch does not get compiler-enforced exhaustiveness (see
|
||||||
events.add(recorded.Record)
|
// app.Event's doc comment) — JobChanged and SchedulerStateChanged
|
||||||
r := recorded.Record
|
// intentionally fall through to the unconditional refresh() below
|
||||||
if r.State == "Failed" &&
|
// without their own case, since a broad state re-read is all they need.
|
||||||
(r.Trigger == "Manual" || r.Trigger == "Schedule") &&
|
switch e := ev.(type) {
|
||||||
|
case app.RunRecorded:
|
||||||
|
events.add(e.Record)
|
||||||
|
if e.Record.State == "Failed" &&
|
||||||
|
(e.Record.Trigger == "Manual" || e.Record.Trigger == "Schedule") &&
|
||||||
svc.ShouldNotifyOnFailure() {
|
svc.ShouldNotifyOnFailure() {
|
||||||
timing := notificationTiming{
|
timing := notificationTiming{
|
||||||
JobName: r.JobName,
|
JobName: e.Record.JobName,
|
||||||
EmittedAt: time.Now(),
|
EmittedAt: time.Now(),
|
||||||
}
|
}
|
||||||
if finished, err := time.ParseInLocation(runRecordTimeLayout, r.Time, time.Local); err == nil {
|
if finished, err := time.ParseInLocation(runRecordTimeLayout, e.Record.Time, time.Local); err == nil {
|
||||||
timing.RunFinished = finished
|
timing.RunFinished = finished
|
||||||
}
|
}
|
||||||
fyne.Do(func() {
|
fyne.Do(func() {
|
||||||
timing.UIQueuedAt = time.Now()
|
timing.UIQueuedAt = time.Now()
|
||||||
fyne.CurrentApp().SendNotification(&fyne.Notification{
|
fyne.CurrentApp().SendNotification(&fyne.Notification{
|
||||||
Title: "GoSentry: Job Failed",
|
Title: "GoSentry: Job Failed",
|
||||||
Content: r.JobName + ": " + r.Detail,
|
Content: e.Record.JobName + ": " + e.Record.Detail,
|
||||||
})
|
})
|
||||||
timing.AfterSendAt = time.Now()
|
timing.AfterSendAt = time.Now()
|
||||||
if err := appendNotificationTimingLog(svc.Store().Paths.LogsDir, timing); err != nil {
|
if err := appendNotificationTimingLog(svc.Paths().LogsDir, timing); err != nil {
|
||||||
fyne.LogError("Failed to write notification timing log", err)
|
fyne.LogError("Failed to write notification timing log", err)
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
}
|
case app.ErrorOccurred:
|
||||||
if isError {
|
events.add(newEvent(0, "Service", "Error", e.Err.Error()))
|
||||||
events.add(newEvent(0, "Service", "Error", errOccurred.Err.Error()))
|
case app.JobsLoaded:
|
||||||
}
|
|
||||||
if isJobsLoaded {
|
|
||||||
// Selecting an existing jobs file replaces the job list without a
|
// Selecting an existing jobs file replaces the job list without a
|
||||||
// prompt, so History carries the receipt: how many jobs, from where.
|
// prompt, so History carries the receipt: how many jobs, from where.
|
||||||
detail := strconv.Itoa(jobsLoaded.Count) + " jobs from " + jobsLoaded.Path
|
detail := strconv.Itoa(e.Count) + " jobs from " + e.Path
|
||||||
events.add(newEvent(0, "Service", "Jobs loaded", detail))
|
events.add(newEvent(0, "Service", "Jobs loaded", detail))
|
||||||
}
|
}
|
||||||
refresh()
|
refresh()
|
||||||
|
|||||||
+3
-2
@@ -70,12 +70,13 @@ func Run(startInTray bool) {
|
|||||||
a.Run()
|
a.Run()
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
keepInTray = svc.Store().Config.KeepRunningInTray
|
config := svc.Config()
|
||||||
|
keepInTray = config.KeepRunningInTray
|
||||||
startHidden = resolveStartHidden(startInTray, keepInTray)
|
startHidden = resolveStartHidden(startInTray, keepInTray)
|
||||||
applyTrayBehavior(a, w, keepInTray, false)
|
applyTrayBehavior(a, w, keepInTray, false)
|
||||||
// Apply the persisted theme before building content so the window renders in
|
// Apply the persisted theme before building content so the window renders in
|
||||||
// the chosen theme from the first frame rather than flashing the default one.
|
// the chosen theme from the first frame rather than flashing the default one.
|
||||||
applyTheme(a, svc.Store().Config.Theme)
|
applyTheme(a, config.Theme)
|
||||||
content, recordStartup := newMainView(w, svc)
|
content, recordStartup := newMainView(w, svc)
|
||||||
w.SetContent(content)
|
w.SetContent(content)
|
||||||
serveSingleInstance(instanceListener, w)
|
serveSingleInstance(instanceListener, w)
|
||||||
|
|||||||
+40
-26
@@ -27,7 +27,14 @@ var settingsCaptions = []string{
|
|||||||
}
|
}
|
||||||
|
|
||||||
func settingsView(w fyne.Window, svc *app.Service) fyne.CanvasObject {
|
func settingsView(w fyne.Window, svc *app.Service) fyne.CanvasObject {
|
||||||
store := svc.Store()
|
// saved mirrors the config as last persisted (or freshly loaded at
|
||||||
|
// construction); it is a local copy the closures below compare the form
|
||||||
|
// against and reassign after a successful save, rather than holding onto
|
||||||
|
// the live *storage.Store the Service owns (see app.Service.Config).
|
||||||
|
// paths never changes after construction of this view — AppDir and
|
||||||
|
// ConfigPath are fixed for the process — so it is read once, not refreshed.
|
||||||
|
saved := svc.Config()
|
||||||
|
paths := svc.Paths()
|
||||||
// updateSaveState compares the form to the saved config and enables Save only
|
// updateSaveState compares the form to the saved config and enables Save only
|
||||||
// when something differs. It is defined below (once Save and every field
|
// when something differs. It is defined below (once Save and every field
|
||||||
// exist) but declared here so the field change handlers can reference it.
|
// exist) but declared here so the field change handlers can reference it.
|
||||||
@@ -36,14 +43,14 @@ func settingsView(w fyne.Window, svc *app.Service) fyne.CanvasObject {
|
|||||||
// both the initial load and the Cancel/Defaults buttons below.
|
// both the initial load and the Cancel/Defaults buttons below.
|
||||||
var loadFields func(domain.Config)
|
var loadFields func(domain.Config)
|
||||||
startOnLogin := widget.NewCheck("Start on login", nil)
|
startOnLogin := widget.NewCheck("Start on login", nil)
|
||||||
startOnLogin.SetChecked(store.Config.StartOnLogin)
|
startOnLogin.SetChecked(saved.StartOnLogin)
|
||||||
minimizeToTray := widget.NewCheck("Keep running in the system tray", nil)
|
minimizeToTray := widget.NewCheck("Keep running in the system tray", nil)
|
||||||
minimizeToTray.SetChecked(store.Config.KeepRunningInTray)
|
minimizeToTray.SetChecked(saved.KeepRunningInTray)
|
||||||
autostartStatus := widget.NewLabel("")
|
autostartStatus := widget.NewLabel("")
|
||||||
trayRestartHint := widget.NewLabel("")
|
trayRestartHint := widget.NewLabel("")
|
||||||
trayRestartHint.Truncation = fyne.TextTruncateClip
|
trayRestartHint.Truncation = fyne.TextTruncateClip
|
||||||
refreshAutostartStatus := func() {
|
refreshAutostartStatus := func() {
|
||||||
if settingsPendingAutostart(startOnLogin, minimizeToTray, store.Config) {
|
if settingsPendingAutostart(startOnLogin, minimizeToTray, saved) {
|
||||||
autostartStatus.SetText("Pending: save settings to apply")
|
autostartStatus.SetText("Pending: save settings to apply")
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
@@ -67,15 +74,15 @@ func settingsView(w fyne.Window, svc *app.Service) fyne.CanvasObject {
|
|||||||
}
|
}
|
||||||
minimizeToTray.OnChanged = func(bool) {
|
minimizeToTray.OnChanged = func(bool) {
|
||||||
refreshAutostartStatus()
|
refreshAutostartStatus()
|
||||||
refreshTrayRestartHint(minimizeToTray.Checked != store.Config.KeepRunningInTray)
|
refreshTrayRestartHint(minimizeToTray.Checked != saved.KeepRunningInTray)
|
||||||
updateSaveState()
|
updateSaveState()
|
||||||
}
|
}
|
||||||
refreshAutostartStatus()
|
refreshAutostartStatus()
|
||||||
notifications := widget.NewCheck("Show desktop notifications for failed jobs", nil)
|
notifications := widget.NewCheck("Show desktop notifications for failed jobs", nil)
|
||||||
notifications.SetChecked(store.Config.NotifyOnFailure)
|
notifications.SetChecked(saved.NotifyOnFailure)
|
||||||
notifications.OnChanged = func(bool) { updateSaveState() }
|
notifications.OnChanged = func(bool) { updateSaveState() }
|
||||||
themeSelect := widget.NewSelect([]string{themeLabelSystem, themeLabelGoSentry}, nil)
|
themeSelect := widget.NewSelect([]string{themeLabelSystem, themeLabelGoSentry}, nil)
|
||||||
themeSelect.SetSelected(themeLabel(store.Config.Theme))
|
themeSelect.SetSelected(themeLabel(saved.Theme))
|
||||||
// Preview the theme the moment it is picked so the choice is visible before
|
// Preview the theme the moment it is picked so the choice is visible before
|
||||||
// saving; Save persists it. Reverting the selection reverts the preview, and
|
// saving; Save persists it. Reverting the selection reverts the preview, and
|
||||||
// closing without saving falls back to the stored theme on next launch.
|
// closing without saving falls back to the stored theme on next launch.
|
||||||
@@ -87,20 +94,20 @@ func settingsView(w fyne.Window, svc *app.Service) fyne.CanvasObject {
|
|||||||
[]string{string(domain.ExecutionModeParallel), string(domain.ExecutionModeSequential)},
|
[]string{string(domain.ExecutionModeParallel), string(domain.ExecutionModeSequential)},
|
||||||
nil,
|
nil,
|
||||||
)
|
)
|
||||||
executionModeSelect.SetSelected(string(store.Config.ExecutionMode))
|
executionModeSelect.SetSelected(string(saved.ExecutionMode))
|
||||||
executionModeSelect.OnChanged = func(string) { updateSaveState() }
|
executionModeSelect.OnChanged = func(string) { updateSaveState() }
|
||||||
overlapPolicySelect := widget.NewSelect(
|
overlapPolicySelect := widget.NewSelect(
|
||||||
[]string{string(domain.OverlapPolicySkip), string(domain.OverlapPolicyQueue)},
|
[]string{string(domain.OverlapPolicySkip), string(domain.OverlapPolicyQueue)},
|
||||||
nil,
|
nil,
|
||||||
)
|
)
|
||||||
overlapPolicySelect.SetSelected(string(store.Config.OverlapPolicy))
|
overlapPolicySelect.SetSelected(string(saved.OverlapPolicy))
|
||||||
overlapPolicySelect.OnChanged = func(string) { updateSaveState() }
|
overlapPolicySelect.OnChanged = func(string) { updateSaveState() }
|
||||||
defaultTimeout := widget.NewEntry()
|
defaultTimeout := widget.NewEntry()
|
||||||
defaultTimeout.SetPlaceHolder("0 = no timeout")
|
defaultTimeout.SetPlaceHolder("0 = no timeout")
|
||||||
defaultTimeout.SetText(strconv.Itoa(store.Config.DefaultTimeoutSeconds))
|
defaultTimeout.SetText(strconv.Itoa(saved.DefaultTimeoutSeconds))
|
||||||
defaultTimeout.OnChanged = func(string) { updateSaveState() }
|
defaultTimeout.OnChanged = func(string) { updateSaveState() }
|
||||||
jobsFile := widget.NewEntry()
|
jobsFile := widget.NewEntry()
|
||||||
jobsFile.SetText(store.Config.JobsFile)
|
jobsFile.SetText(saved.JobsFile)
|
||||||
jobsFile.OnChanged = func(string) { updateSaveState() }
|
jobsFile.OnChanged = func(string) { updateSaveState() }
|
||||||
// The picker only offers existing files; a jobs file that does not exist yet
|
// The picker only offers existing files; a jobs file that does not exist yet
|
||||||
// is entered by typing its path, which Save then creates.
|
// is entered by typing its path, which Save then creates.
|
||||||
@@ -108,7 +115,7 @@ func settingsView(w fyne.Window, svc *app.Service) fyne.CanvasObject {
|
|||||||
chooseJSONFile(w, jobsFile)
|
chooseJSONFile(w, jobsFile)
|
||||||
})
|
})
|
||||||
logsDir := widget.NewEntry()
|
logsDir := widget.NewEntry()
|
||||||
logsDir.SetText(store.Config.LogsDir)
|
logsDir.SetText(saved.LogsDir)
|
||||||
logsDir.OnChanged = func(string) { updateSaveState() }
|
logsDir.OnChanged = func(string) { updateSaveState() }
|
||||||
logsDirBrowse := widget.NewButtonWithIcon("Browse", theme.FolderOpenIcon(), func() {
|
logsDirBrowse := widget.NewButtonWithIcon("Browse", theme.FolderOpenIcon(), func() {
|
||||||
chooseFolder(w, logsDir)
|
chooseFolder(w, logsDir)
|
||||||
@@ -118,13 +125,15 @@ func settingsView(w fyne.Window, svc *app.Service) fyne.CanvasObject {
|
|||||||
// manager. It reveals whatever the field currently holds, so an edit can be
|
// manager. It reveals whatever the field currently holds, so an edit can be
|
||||||
// checked before Save.
|
// checked before Save.
|
||||||
logsDirOpen := widget.NewButtonWithIcon("Open", theme.FolderIcon(), func() {
|
logsDirOpen := widget.NewButtonWithIcon("Open", theme.FolderIcon(), func() {
|
||||||
openFolder(w, settingsFolderPath(store.Paths.AppDir, logsDir.Text))
|
openFolder(w, settingsFolderPath(paths.AppDir, logsDir.Text))
|
||||||
})
|
})
|
||||||
maxLogFiles := widget.NewEntry()
|
maxLogFiles := widget.NewEntry()
|
||||||
maxLogFiles.SetText(strconv.Itoa(store.Config.MaxLogFiles))
|
maxLogFiles.SetPlaceHolder("0 = unlimited")
|
||||||
|
maxLogFiles.SetText(strconv.Itoa(saved.MaxLogFiles))
|
||||||
maxLogFiles.OnChanged = func(string) { updateSaveState() }
|
maxLogFiles.OnChanged = func(string) { updateSaveState() }
|
||||||
maxLogAgeDays := widget.NewEntry()
|
maxLogAgeDays := widget.NewEntry()
|
||||||
maxLogAgeDays.SetText(strconv.Itoa(store.Config.MaxLogAgeDays))
|
maxLogAgeDays.SetPlaceHolder("0 = unlimited")
|
||||||
|
maxLogAgeDays.SetText(strconv.Itoa(saved.MaxLogAgeDays))
|
||||||
maxLogAgeDays.OnChanged = func(string) { updateSaveState() }
|
maxLogAgeDays.OnChanged = func(string) { updateSaveState() }
|
||||||
// Autostart status sits on its own row beneath the checkbox (rather than
|
// Autostart status sits on its own row beneath the checkbox (rather than
|
||||||
// beside it) so the Application section fits within a half-width column.
|
// beside it) so the Application section fits within a half-width column.
|
||||||
@@ -134,13 +143,13 @@ func settingsView(w fyne.Window, svc *app.Service) fyne.CanvasObject {
|
|||||||
|
|
||||||
saveSettings := widget.NewButtonWithIcon("Save settings", theme.DocumentSaveIcon(), func() {
|
saveSettings := widget.NewButtonWithIcon("Save settings", theme.DocumentSaveIcon(), func() {
|
||||||
files, err := strconv.Atoi(strings.TrimSpace(maxLogFiles.Text))
|
files, err := strconv.Atoi(strings.TrimSpace(maxLogFiles.Text))
|
||||||
if err != nil || files <= 0 {
|
if err != nil || files < 0 {
|
||||||
settingsStatus.SetText("Max log files must be a positive number")
|
settingsStatus.SetText("Max log files must be zero (unlimited) or a positive number")
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
days, err := strconv.Atoi(strings.TrimSpace(maxLogAgeDays.Text))
|
days, err := strconv.Atoi(strings.TrimSpace(maxLogAgeDays.Text))
|
||||||
if err != nil || days <= 0 {
|
if err != nil || days < 0 {
|
||||||
settingsStatus.SetText("Max log age days must be a positive number")
|
settingsStatus.SetText("Max log age days must be zero (unlimited) or a positive number")
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
if strings.TrimSpace(jobsFile.Text) == "" {
|
if strings.TrimSpace(jobsFile.Text) == "" {
|
||||||
@@ -159,7 +168,7 @@ func settingsView(w fyne.Window, svc *app.Service) fyne.CanvasObject {
|
|||||||
// Build the new config from the form and hand it to the Service, which
|
// Build the new config from the form and hand it to the Service, which
|
||||||
// validates it, persists config and jobs to the (possibly new) directory,
|
// validates it, persists config and jobs to the (possibly new) directory,
|
||||||
// and runs log cleanup so tightened retention limits take effect at once.
|
// and runs log cleanup so tightened retention limits take effect at once.
|
||||||
config := store.Config
|
config := saved
|
||||||
config.JobsFile = strings.TrimSpace(jobsFile.Text)
|
config.JobsFile = strings.TrimSpace(jobsFile.Text)
|
||||||
config.LogsDir = strings.TrimSpace(logsDir.Text)
|
config.LogsDir = strings.TrimSpace(logsDir.Text)
|
||||||
config.MaxLogFiles = files
|
config.MaxLogFiles = files
|
||||||
@@ -171,11 +180,16 @@ func settingsView(w fyne.Window, svc *app.Service) fyne.CanvasObject {
|
|||||||
config.OverlapPolicy = domain.OverlapPolicy(overlapPolicySelect.Selected)
|
config.OverlapPolicy = domain.OverlapPolicy(overlapPolicySelect.Selected)
|
||||||
config.DefaultTimeoutSeconds = timeout
|
config.DefaultTimeoutSeconds = timeout
|
||||||
config.Theme = themeFromLabel(themeSelect.Selected)
|
config.Theme = themeFromLabel(themeSelect.Selected)
|
||||||
previousKeepInTray := store.Config.KeepRunningInTray
|
previousKeepInTray := saved.KeepRunningInTray
|
||||||
if err := svc.UpdateSettings(config); err != nil {
|
if err := svc.UpdateSettings(config); err != nil {
|
||||||
settingsStatus.SetText("Save failed: " + err.Error())
|
settingsStatus.SetText("Save failed: " + err.Error())
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
// UpdateSettings may re-resolve paths (a jobs-file switch adopts a
|
||||||
|
// different directory), so pick up the fresh copy rather than assuming
|
||||||
|
// config is exactly what landed.
|
||||||
|
saved = svc.Config()
|
||||||
|
paths = svc.Paths()
|
||||||
if err := svc.ApplyAutostart(); err != nil {
|
if err := svc.ApplyAutostart(); err != nil {
|
||||||
refreshAutostartStatus()
|
refreshAutostartStatus()
|
||||||
settingsStatus.SetText("Saved, autostart failed: " + err.Error())
|
settingsStatus.SetText("Saved, autostart failed: " + err.Error())
|
||||||
@@ -198,7 +212,7 @@ func settingsView(w fyne.Window, svc *app.Service) fyne.CanvasObject {
|
|||||||
// fields compare against their canonical string form; any unparsable text
|
// fields compare against their canonical string form; any unparsable text
|
||||||
// counts as a change so the user can click Save and see the validation error.
|
// counts as a change so the user can click Save and see the validation error.
|
||||||
updateSaveState = func() {
|
updateSaveState = func() {
|
||||||
c := store.Config
|
c := saved
|
||||||
changed := startOnLogin.Checked != c.StartOnLogin ||
|
changed := startOnLogin.Checked != c.StartOnLogin ||
|
||||||
minimizeToTray.Checked != c.KeepRunningInTray ||
|
minimizeToTray.Checked != c.KeepRunningInTray ||
|
||||||
notifications.Checked != c.NotifyOnFailure ||
|
notifications.Checked != c.NotifyOnFailure ||
|
||||||
@@ -235,17 +249,17 @@ func settingsView(w fyne.Window, svc *app.Service) fyne.CanvasObject {
|
|||||||
logsDir.SetText(c.LogsDir)
|
logsDir.SetText(c.LogsDir)
|
||||||
maxLogFiles.SetText(strconv.Itoa(c.MaxLogFiles))
|
maxLogFiles.SetText(strconv.Itoa(c.MaxLogFiles))
|
||||||
maxLogAgeDays.SetText(strconv.Itoa(c.MaxLogAgeDays))
|
maxLogAgeDays.SetText(strconv.Itoa(c.MaxLogAgeDays))
|
||||||
if settingsPendingAutostart(startOnLogin, minimizeToTray, store.Config) {
|
if settingsPendingAutostart(startOnLogin, minimizeToTray, saved) {
|
||||||
autostartStatus.SetText("Pending: save settings to apply")
|
autostartStatus.SetText("Pending: save settings to apply")
|
||||||
} else {
|
} else {
|
||||||
refreshAutostartStatus()
|
refreshAutostartStatus()
|
||||||
}
|
}
|
||||||
refreshTrayRestartHint(minimizeToTray.Checked != store.Config.KeepRunningInTray)
|
refreshTrayRestartHint(minimizeToTray.Checked != saved.KeepRunningInTray)
|
||||||
settingsStatus.SetText("")
|
settingsStatus.SetText("")
|
||||||
updateSaveState()
|
updateSaveState()
|
||||||
}
|
}
|
||||||
cancelSettings := widget.NewButtonWithIcon("Cancel", theme.CancelIcon(), func() {
|
cancelSettings := widget.NewButtonWithIcon("Cancel", theme.CancelIcon(), func() {
|
||||||
loadFields(store.Config)
|
loadFields(saved)
|
||||||
})
|
})
|
||||||
restoreDefaults := widget.NewButtonWithIcon("Defaults", theme.MediaReplayIcon(), func() {
|
restoreDefaults := widget.NewButtonWithIcon("Defaults", theme.MediaReplayIcon(), func() {
|
||||||
loadFields(domain.DefaultConfig())
|
loadFields(domain.DefaultConfig())
|
||||||
@@ -261,7 +275,7 @@ func settingsView(w fyne.Window, svc *app.Service) fyne.CanvasObject {
|
|||||||
executionModeSelect: executionModeSelect,
|
executionModeSelect: executionModeSelect,
|
||||||
overlapPolicySelect: overlapPolicySelect,
|
overlapPolicySelect: overlapPolicySelect,
|
||||||
defaultTimeout: defaultTimeout,
|
defaultTimeout: defaultTimeout,
|
||||||
configPath: store.Paths.ConfigPath,
|
configPath: paths.ConfigPath,
|
||||||
jobsFile: jobsFile,
|
jobsFile: jobsFile,
|
||||||
jobsFileBrowse: jobsFileBrowse,
|
jobsFileBrowse: jobsFileBrowse,
|
||||||
logsDir: logsDir,
|
logsDir: logsDir,
|
||||||
|
|||||||
Reference in New Issue
Block a user