Compare commits
16 Commits
0a50f3c66b
..
v1.0.3
| Author | SHA1 | Date | |
|---|---|---|---|
| 6a03ea4a20 | |||
| 4fb9bf6ff3 | |||
| 9b7a0565ba | |||
| da8fe10365 | |||
| 18da021526 | |||
| bd7ebde68e | |||
| ca2a8c8aa7 | |||
| 0c8442a8d1 | |||
| 98c820e3bd | |||
| 263717874c | |||
| 1242b22e4f | |||
| 89be009040 | |||
| eaa37a1192 | |||
| c8a4d31441 | |||
| 1e3d14bef2 | |||
| 0aab9d8db6 |
@@ -1,26 +0,0 @@
|
||||
---
|
||||
description: Review the project as a whole against the agenda in docs/REVIEW.md
|
||||
---
|
||||
|
||||
Perform a whole-project review of GoSentry.
|
||||
|
||||
Read [docs/REVIEW.md](../../docs/REVIEW.md) first — it is the agenda, and its
|
||||
nine sections are the areas to cover. Read [docs/STANDARDS.md](../../docs/STANDARDS.md)
|
||||
and [docs/ARCHITECTURE.md](../../docs/ARCHITECTURE.md) for the rules and
|
||||
contracts the code is checked against.
|
||||
|
||||
$ARGUMENTS narrows the review when given — a package path, a file, or the name
|
||||
of an agenda section. With no arguments, sweep the whole `src/` tree.
|
||||
|
||||
Rules for the report:
|
||||
|
||||
- Anything listed under "Intentional behavior" in STANDARDS.md is not a finding.
|
||||
If you believe such an entry is now wrong, say so explicitly as a challenge to
|
||||
the decision rather than reporting it as a bug.
|
||||
- Verify before reporting. Read the surrounding code and, where cheap, confirm
|
||||
the behavior with a test rather than reasoning about it alone.
|
||||
- Group findings by agenda section, most severe first, each with the file and
|
||||
line and what would actually go wrong.
|
||||
- Report honestly that a section is clean rather than inventing something for it.
|
||||
- Do not fix anything during the review. Report first; apply fixes only when
|
||||
asked, following "What happens to the findings" in REVIEW.md.
|
||||
@@ -13,12 +13,6 @@ application service, scheduler, storage, and command runner in one binary.
|
||||
- [docs/TESTS.md](docs/TESTS.md) — test layout and conventions.
|
||||
- [docs/ROADMAP.md](docs/ROADMAP.md) — deliberately out of scope.
|
||||
|
||||
## Reviewing the project
|
||||
|
||||
When the user asks for a review of the project (rather than of a specific
|
||||
diff), follow [docs/REVIEW.md](docs/REVIEW.md) — it is the agenda, and the
|
||||
`/review-project` command runs the same thing. Do not improvise a checklist.
|
||||
|
||||
## Key rules (full list in STANDARDS.md)
|
||||
|
||||
- `src/app.Service` is the sole owner of job and runtime state; the UI reads it
|
||||
|
||||
@@ -14,8 +14,11 @@ creating, grouping, pausing, running, and monitoring scheduled shell commands.
|
||||
|
||||
<table>
|
||||
<tr>
|
||||
<td align="center"><img src="images/screenshot_jobs.PNG" alt="Jobs tab"><br><em>Jobs tab — job list with details panel and run statistics.</em></td>
|
||||
<td align="center"><img src="images/screenshot_settings.PNG" alt="Settings tab"><br><em>Settings tab — application, queue, storage, and version info.</em></td>
|
||||
<td align="center"><img src="docs/screenshots/screenshot_jobs.PNG" alt="Jobs tab"><br><em>Jobs tab — job list with details panel and run statistics.</em></td>
|
||||
<td align="center"><img src="docs/screenshots/screenshot_history.PNG" alt="History tab"><br><em>History tab — past runs with trigger, state, and log file.</em></td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td align="center" colspan="2"><img src="docs/screenshots/screenshot_settings.PNG" alt="Settings tab"><br><em>Settings tab — application, queue, storage, and version info.</em></td>
|
||||
</tr>
|
||||
</table>
|
||||
|
||||
@@ -51,7 +54,6 @@ GoSentry is built and tested on **Windows** and **Linux**:
|
||||
- [Roadmap](docs/ROADMAP.md) — planned work larger than a single bug fix
|
||||
- [Architecture](docs/ARCHITECTURE.md) — component interaction model
|
||||
- [Standards](docs/STANDARDS.md) — quality rules and intentional behavior
|
||||
- [Review](docs/REVIEW.md) — what a whole-project review looks at
|
||||
- [Development](docs/DEVELOPMENT.md) — build instructions, project layout, dependencies
|
||||
- [Tests](docs/TESTS.md) — test suite layout and how to run it
|
||||
- [Performance](docs/PERFORMANCE.md) — measured performance findings
|
||||
@@ -182,7 +184,7 @@ Named descriptors are also accepted: `@hourly`, `@daily`, `@weekly`,
|
||||
3. Set **Schedule**, **Command**, optional **Arguments**, **Folder**, and **Enabled**.
|
||||
4. Use **Run now** for a one-off manual run without waiting for the schedule.
|
||||
5. Use **Pause** on a single job to suspend it without deleting it.
|
||||
6. Use **Pause all** as a global stop switch for all scheduled runs.
|
||||
6. Use **Disable auto** as a global stop switch for all scheduled runs.
|
||||
7. Open **History** to see past runs, their trigger (`Manual`, `Schedule`, or `UI`), state, and log file.
|
||||
8. Open **Settings** to change the storage paths, log cleanup limits, queue behavior, and notifications.
|
||||
|
||||
@@ -242,8 +244,9 @@ sets one.
|
||||
## Notifications
|
||||
|
||||
When **Notify on failure** is enabled in Settings, GoSentry sends a desktop
|
||||
notification whenever a scheduled or manual run exits with a non-zero exit code.
|
||||
The notification shows the job name and the exit code.
|
||||
notification whenever a scheduled or manual run ends in the `Failed` state —
|
||||
a non-zero exit code, a timeout, or a process that failed to start.
|
||||
The notification shows the job name and the failure detail.
|
||||
|
||||
## Autostart
|
||||
|
||||
|
||||
+3
-1
@@ -17,7 +17,7 @@ import (
|
||||
// The hard constraint: Fyne's a.SetIcon and SetSystemTrayIcon each take ONE
|
||||
// image, which the OS then scales to every size it needs — titlebar (~16px),
|
||||
// taskbar/dock (~32-48px), and tray. Neither source survives that scaling:
|
||||
// downscaling the 1254px gosentry-icon-big.png to 16px is muddy, and upscaling
|
||||
// downscaling the 1254px gosentry-icon-large.png to 16px is muddy, and upscaling
|
||||
// the 16px icon to 32px is blurry. The fix is to feed each surface a
|
||||
// size-appropriate source — which differs per platform because each platform
|
||||
// exposes different icon channels.
|
||||
@@ -44,6 +44,8 @@ import (
|
||||
// - Tray: SetSystemTrayIcon(IconSmallICO()). The notification area is ICO-native
|
||||
// and renders at 16-24px; a single-frame 16x16 .ico pins the hand-tuned glyph
|
||||
// (a multi-size .ico made the tray pick and downscale a larger frame).
|
||||
// - Desktop toasts: AppMetadata.Icon (set after NewWindow in run.go) feeds
|
||||
// SendNotification without calling SetIcon, which would override GLFW_ICON.
|
||||
//
|
||||
// Linux / other non-Windows (no PE icon resource exists):
|
||||
// - Window titlebar: a.SetIcon(IconSmall()) in run.go feeds the resource to
|
||||
|
||||
+40
-19
@@ -41,7 +41,7 @@ flowchart LR
|
||||
|
||||
user -->|"edits jobs, settings, runs commands"| ui
|
||||
ui -->|"CreateJob, UpdateJob, DeleteJob, RunNow, UpdateSettings, AutostartStatus, …"| svc
|
||||
svc -->|"SaveJobs, SaveConfig, LoadJobs, LoadConfig"| store
|
||||
svc -->|"OpenStore, PrepareSaveJobs, PrepareSaveConfig, LoadJobsFile"| store
|
||||
store -->|"read/write"| config
|
||||
store -->|"read/write"| jobs
|
||||
|
||||
@@ -119,10 +119,13 @@ example window-maximized detection, which would need per-OS native calls).
|
||||
## Main Flows
|
||||
|
||||
1. Startup:
|
||||
`cmd/gosentry` calls `ui.Run`, which creates an `app.Service`, opens the
|
||||
store, loads `gosentry.json` and `jobs.json`, subscribes the UI to service
|
||||
events, builds the main window, and calls `Service.Start` to begin the
|
||||
scheduler loop. On every launch the service seeds per-job run-time statistics
|
||||
`cmd/gosentry` calls `ui.Run`, which owns the process lifecycle: it calls
|
||||
`app.Open()` to open the store, load `gosentry.json` and `jobs.json`, and
|
||||
build the `app.Service`, then hands that Service to `newMainView`
|
||||
(`mainwindow.go`), which subscribes the UI to service events and calls
|
||||
`Service.Start` to begin the scheduler loop before assembling the tabs.
|
||||
`Run` shows the window and, on quit, calls `Service.Stop`.
|
||||
On every launch the service seeds per-job run-time statistics
|
||||
from existing log files so the details panel reflects accumulated history
|
||||
immediately (see §Statistics below).
|
||||
|
||||
@@ -136,10 +139,13 @@ example window-maximized detection, which would need per-OS native calls).
|
||||
`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
|
||||
Service loads it, calls `adoptJobsLocked` to rebuild the jobs slice, runtime
|
||||
map, schedule cache, next-run times, and log-seeded statistics around it, and
|
||||
emits `JobsLoaded` plus a broad `JobChanged`. A path with no file behind it
|
||||
receives the current jobs instead. Adoption drops all runtime state, so it is
|
||||
refused while a job is running.
|
||||
map, schedule cache, and next-run times around it, applies the statistics
|
||||
seeded from the new logs directory, and emits `JobsLoaded` plus a broad
|
||||
`JobChanged`. A path with no file behind it receives the current jobs instead.
|
||||
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:
|
||||
`scheduler.Scheduler` fires a tick every second. On each tick it calls
|
||||
@@ -163,8 +169,9 @@ example window-maximized detection, which would need per-OS native calls).
|
||||
|
||||
6. History update:
|
||||
When a run goroutine completes, `Service` updates the job's runtime
|
||||
(including the statistics aggregate), saves JSON, triggers log cleanup, and
|
||||
emits `RunRecorded`. The UI observer appends the record to the History tab.
|
||||
(including the statistics aggregate) under `mu`, then — after releasing it —
|
||||
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
|
||||
clears the table (aggregate stats in the details panel are still seeded from
|
||||
log files).
|
||||
@@ -220,9 +227,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
|
||||
config: a positive duration applies the timeout via `context.WithTimeout` and
|
||||
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
|
||||
the untimed context and so measure launch latency only, unaffected by the run
|
||||
timeout.
|
||||
without a deadline, bounded only by `ctx` (app shutdown). `StartOnly` jobs are
|
||||
built on `context.Background()` instead — neither the timeout nor app shutdown
|
||||
applies to them — and so measure launch latency only.
|
||||
|
||||
### Run-time statistics
|
||||
|
||||
@@ -233,8 +240,10 @@ timeout.
|
||||
| `RunCount` | total runs recorded |
|
||||
| `FailCount` | runs that exited non-zero |
|
||||
| `LastDurationMS` | wall-clock time of the most recent run (launch latency for `StartOnly`) |
|
||||
| `AvgDurationMS` | mean over all runs with a recorded duration |
|
||||
| `AvgDurationMS` | mean over all runs with a recorded duration, computed as `DurationSumMS / TimedRunCount` on every update rather than folded incrementally, so it never disagrees with the exact sum/count average `runner.aggregateLogStats` computes when seeding from logs |
|
||||
| `MaxDurationMS` | longest recorded run |
|
||||
| `TimedRunCount` | runs that carried a duration, and so contributed to the aggregates above; a legacy log without a `duration` header counts toward `RunCount` but not this |
|
||||
| `DurationSumMS` | running total of every timed run's duration; the source `AvgDurationMS` is divided from |
|
||||
|
||||
`runner.RunJob` measures the wall-clock start→finish and sets `DurationMS` on
|
||||
the returned `RunRecord`. `runner/logfile.go` writes a `duration` line into the
|
||||
@@ -266,16 +275,28 @@ the moment the window opens.
|
||||
### `jobs_view.go` file structure
|
||||
|
||||
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
|
||||
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:
|
||||
`src/ui/jobs_view.go` is split across six files along these seams:
|
||||
|
||||
| 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_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
|
||||
|
||||
`src/ui/settings_view.go` is split across three files the same way, once its
|
||||
|
||||
+167
-10
@@ -2,21 +2,154 @@
|
||||
|
||||
All notable GoSentry changes are recorded in this file.
|
||||
|
||||
## 1.0.1 - 2026-08-04
|
||||
## 1.0.3 - 2026-08-07
|
||||
|
||||
**Sample jobs include a disabled failure test for desktop notifications.**
|
||||
**The findings of a whole-project review: durable JSON and log writes, bounded
|
||||
History and overlap queues, and a Jobs selection that follows the job.**
|
||||
|
||||
- **`storage.defaultJobs`** — new disabled example *Failure notification test*
|
||||
(folder Examples). Run it manually to trigger a failed run and verify
|
||||
Settings → Notifications without waiting on the scheduler.
|
||||
**Application:**
|
||||
|
||||
**Platform layer rationale is documented in ARCHITECTURE.md.**
|
||||
- Fixed a Windows quoting bug where a job whose **Command** field held a whole
|
||||
command line (a `.bat`/`.cmd` wrapper followed by an argument that itself
|
||||
ended in `.exe`) had its entire command line mistaken for the program path,
|
||||
so the run failed with an unmappable shell error. The program path is now
|
||||
found by the earliest file-extension match at a word boundary, not the first
|
||||
extension in list order.
|
||||
- `gosentry.json` and `jobs.json` (and run log files) are now written
|
||||
atomically — to a temp file, then renamed into place — so a crash or power
|
||||
loss mid-write can no longer leave a truncated or empty file. `Service.Stop()`
|
||||
is now called when the app quits, which also makes the run context
|
||||
cancellation reach in-flight runs on shutdown.
|
||||
- Fixed the "queue" overlap policy's backlog (`PendingRuns`): it no longer
|
||||
survives a global pause or a job being disabled, so resuming or re-enabling a
|
||||
job can no longer replay a deferred run left over from before the pause/
|
||||
disable. It is also capped at 10 queued occurrences, so a job whose runs take
|
||||
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
|
||||
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
|
||||
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
|
||||
re-measured across every row on every event, so recording a run no longer
|
||||
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
|
||||
the width rescan alone accounted for 1.5 ms of every redraw.
|
||||
- Two runs of the same job that start within the same second no longer share a
|
||||
log file name. The later one gets a `-2`, `-3`, … suffix instead of silently
|
||||
overwriting the earlier one's log — reachable with a fast manual re-run or a
|
||||
sub-second queue drain.
|
||||
- A hand-edited `jobs.json` in which two entries carry the same `id` no longer
|
||||
leaves them sharing one runtime, one parsed schedule, and one statistics
|
||||
bucket; the duplicate is reassigned a free ID on load, as an absent ID always
|
||||
was.
|
||||
- The **average run duration** shown in Statistics is now the exact sum divided
|
||||
by the timed-run count rather than an incrementally folded integer mean. The
|
||||
old form truncated on every run and the error compounded over a job's life,
|
||||
so the live figure drifted away from the one rebuilt from log files after a
|
||||
restart.
|
||||
- On Linux, a failure to install the `.desktop` file or icon is now reported in
|
||||
History instead of being discarded, so the visible symptom — a generic dock
|
||||
icon — has an explanation.
|
||||
|
||||
- **`docs/ARCHITECTURE.md`** — new §Platform layer: why autostart, file manager,
|
||||
shell, and winproc are OS-specific; compile-time vs runtime branching; rules
|
||||
for adding platform code.
|
||||
**Jobs:**
|
||||
|
||||
**KeepRunningInTray is wired to runtime; autostart respects the tray setting.**
|
||||
- **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.
|
||||
- Switching the **Folder** filter now keeps the current selection when the new
|
||||
filter still shows that job, instead of always jumping to the folder's first
|
||||
job.
|
||||
|
||||
**Settings:**
|
||||
|
||||
- **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.
|
||||
- Opening the tab and saving no longer block the window while the autostart
|
||||
status is read — on Windows that check shells out to PowerShell, and it now
|
||||
runs off the UI thread.
|
||||
- Two spellings of the same absolute **Jobs file** path (mixed separators, a
|
||||
trailing separator) no longer read as a change of file, so saving no longer
|
||||
triggers a spurious reload of the file already in use.
|
||||
|
||||
**Documentation:**
|
||||
|
||||
- Documentation audited against the code. `ARCHITECTURE.md` — the `jobs_view.go`
|
||||
split is six files, not five (the state extraction was never counted), the
|
||||
statistics table lists `TimedRunCount`, the store edge of the diagram names
|
||||
the methods that exist, and startup says where `Service.Start` is actually
|
||||
called. `TESTS.md` — three tests that had no entry are described
|
||||
(`TestLoadOrCreateConfigPreservesZeroRetentionLimits`,
|
||||
`TestWriteJSONReplacesFileAtomically`,
|
||||
`TestQuoteLeadingWindowsProgramPathPicksEarliestBoundedExtension`), the
|
||||
deliberately-uncovered list covers everything the profile reports at 0%, and
|
||||
the coverage figure records how to read the total rather than the per-package
|
||||
lines. `ROADMAP.md` — the over-the-guideline table was re-measured.
|
||||
- README's scheduler wording caught up with the 0.11.2 rename of "Pause all" to
|
||||
**Disable auto**, and its notification description matches what the app sends.
|
||||
- `STANDARDS.md` records the rules the review settled: no file I/O under
|
||||
`Service.mu`, the History and pending-run caps, the zero-retention meaning,
|
||||
that a start-only process outlives GoSentry, the single-instance fallback's
|
||||
consequence, and the unauthenticated instance-channel port.
|
||||
- `docs/REVIEW.md` (the whole-project review agenda) and the working plan it
|
||||
produced are retired now that every item is either landed here or recorded in
|
||||
`ROADMAP.md`, the way the test review plan was in 1.0.1. `STANDARDS.md` is the
|
||||
surviving reference.
|
||||
- The screenshots moved to `docs/screenshots/`.
|
||||
|
||||
**Internal:**
|
||||
|
||||
- The failure-notification timing diagnostic added in 1.0.2 is now written to
|
||||
`logs/notify-timing.tsv`. The `.tsv` extension keeps it out of `CleanupLogs`,
|
||||
which manages only `.log` files, so it is neither deleted by age nor counted
|
||||
against **Max log files**, and the append now runs off the UI thread.
|
||||
- `jobs.json` is no longer rewritten twice per run. Starting and finishing a run
|
||||
touch only `JobRuntime`, which is never persisted, so both saves re-serialised
|
||||
identical bytes; `SetGlobalPause` did the same alongside its real `SaveConfig`.
|
||||
Removing them also removes the run-start rollback path and the save failure it
|
||||
reported, so `RunDue` no longer has a start error to surface at all.
|
||||
- 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`, joining the existing
|
||||
`jobs_view_details.go` and `jobs_view_helpers.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.
|
||||
- `Service.Store()` is replaced by typed `Service.Config()` and `Service.Paths()`
|
||||
accessors that copy under the lock, so the UI no longer reaches into a shared
|
||||
`*storage.Store`. The Jobs pause control is now driven by `refreshView`
|
||||
reading `svc.Config().Paused` on every event, making it a real consumer of
|
||||
`SchedulerStateChanged`, and the main window's event listener is a type switch.
|
||||
- Dead code removed: `collectActivity`, the `yaml` tags on `RunRecord`, the
|
||||
`logArguments`/`LogArguments` alias, the redundant package-level
|
||||
`SetAutostart`/`AutostartStatus` functions, and the Settings Save handler's
|
||||
second copy of the Service's validation rules. The
|
||||
`systemTrayRegistered`/`mainWindowHidden` globals are one `trayState` value
|
||||
that `Run` owns and threads through.
|
||||
- `scripts/test.bat` no longer prints mojibake for its checkmarks under a
|
||||
non-UTF-8 code page.
|
||||
|
||||
## 1.0.2 - 2026-08-05
|
||||
|
||||
**KeepRunningInTray is wired to runtime; Windows failure notifications can show
|
||||
the app icon (experimental).**
|
||||
|
||||
**Application:**
|
||||
|
||||
@@ -29,6 +162,30 @@ All notable GoSentry changes are recorded in this file.
|
||||
remove the icon mid-session).
|
||||
- A stale autostart shortcut that still passes `--start-in-tray` no longer hides
|
||||
the window when the tray setting is off — saved config wins over the CLI flag.
|
||||
- On Windows, failure toasts can show the app icon: after `NewWindow`,
|
||||
`AppMetadata.Icon` is registered so Fyne picks up artwork without calling
|
||||
`SetIcon`, which would override the PE multi-size window/taskbar icon.
|
||||
|
||||
**Jobs:**
|
||||
|
||||
- New disabled example *Failure notification test* (folder Examples). Run it
|
||||
manually to trigger a failed run and verify Settings → Notifications without
|
||||
waiting on the scheduler.
|
||||
|
||||
**Documentation:**
|
||||
|
||||
- **`docs/ARCHITECTURE.md`** — new §Platform layer: why autostart, file manager,
|
||||
shell, and winproc are OS-specific; compile-time vs runtime branching; rules
|
||||
for adding platform code.
|
||||
|
||||
**Internal:**
|
||||
|
||||
- App-side failure-notification timing is appended to `logs/notify-timing.log`
|
||||
for diagnosing toast delay (OS latency excluded).
|
||||
`scripts/measure-windows-toast.ps1` measures the PowerShell baseline on
|
||||
Windows.
|
||||
|
||||
## 1.0.1 - 2026-08-04
|
||||
|
||||
**The branded theme is the default, Fyne's built-in theme is System, and the
|
||||
test suite is leaner.**
|
||||
|
||||
+3
-2
@@ -280,8 +280,9 @@ Before tagging:
|
||||
|
||||
1. Bump `src/app/version.go`. The tag must match it exactly.
|
||||
2. Add the version's [CHANGELOG.md](CHANGELOG.md) section.
|
||||
3. Retake the README screenshots (`images/screenshot_jobs.PNG`,
|
||||
`images/screenshot_settings.PNG`) if the GUI changed its appearance. This is
|
||||
3. Retake the README screenshots (`docs/screenshots/screenshot_jobs.PNG`,
|
||||
`docs/screenshots/screenshot_settings.PNG`,
|
||||
`docs/screenshots/screenshot_history.PNG`) if the GUI changed its appearance. This is
|
||||
easy to forget because nothing fails without it: `README.md` is packaged
|
||||
inside every release archive and is what the forge shows on the project page,
|
||||
so a stale shot advertises an application that no longer exists. Take them
|
||||
|
||||
@@ -1,86 +0,0 @@
|
||||
# GoSentry — Review Agenda
|
||||
|
||||
What to look at when reviewing the project as a whole, as opposed to a single
|
||||
diff. This is the agenda; the rules a review checks against live in
|
||||
[STANDARDS.md](STANDARDS.md) and [ARCHITECTURE.md](ARCHITECTURE.md).
|
||||
|
||||
Scope note: a normal pull-request review checks the change. This agenda is for
|
||||
a periodic sweep of the whole codebase, so a pass may legitimately end with
|
||||
"nothing to report" on most items.
|
||||
|
||||
## 1. Architecture and project structure
|
||||
|
||||
Does the code still match the package map and the event flow in
|
||||
[ARCHITECTURE.md](ARCHITECTURE.md)? Watch for the boundaries that matter here:
|
||||
`app.Service` as the sole owner of job and runtime state, the UI reading it
|
||||
through typed events, `domain` staying free of I/O, and platform-specific code
|
||||
staying behind the `platform/*` interfaces.
|
||||
|
||||
## 2. Complexity against the size of the project
|
||||
|
||||
GoSentry is a single-process desktop app with two direct dependencies. Flag
|
||||
abstraction that is not paying for itself: interfaces with one implementation
|
||||
and no test seam, indirection added for a use case nobody has asked for, a new
|
||||
dependency where thirty lines of standard library would do. Also check the
|
||||
opposite direction — files that have grown past the size guideline in
|
||||
ARCHITECTURE and should be split the way `jobs_view.go` was.
|
||||
|
||||
## 3. Code quality
|
||||
|
||||
The checkable rules are in [STANDARDS.md](STANDARDS.md) — error handling, unit
|
||||
tests for pure helpers, regression tests for fixes, `fyne.Do` for updates off
|
||||
the main thread. Beyond them: concurrency around `Service.mu`, goroutines whose
|
||||
lifetime is not obvious, and error paths that report something less useful than
|
||||
what they caught.
|
||||
|
||||
## 4. Documentation and comments
|
||||
|
||||
Does every documented behavior still exist, and does every non-obvious behavior
|
||||
get documented? Check the doc set against the code: README (user-facing
|
||||
behavior and config keys), ARCHITECTURE (packages and flows), STANDARDS
|
||||
(rules and intentional behavior), DEVELOPMENT (build), TESTS, PERFORMANCE,
|
||||
CHANGELOG (an entry per notable change). For comments, the bar is *why*, not
|
||||
*what* — a comment restating the line below it is noise; an unexplained
|
||||
workaround is a finding.
|
||||
|
||||
## 5. Readability and maintainability
|
||||
|
||||
Read a package as someone who has not seen it before. Can the next change be
|
||||
made without reverse-engineering? Naming that matches the domain vocabulary,
|
||||
functions that do one thing, and control flow that does not need a diagram.
|
||||
|
||||
## 6. Logical errors
|
||||
|
||||
Correctness independent of style: scheduling and timing edge cases (overlap
|
||||
policy, sequential mode, pause interactions), off-by-one and boundary handling,
|
||||
zero values that mean something (see the timeout rules in STANDARDS), state
|
||||
that can be observed mid-update, and error paths that leave state inconsistent.
|
||||
|
||||
## 7. Legacy code and migrations
|
||||
|
||||
The app has no database, so migration means file compatibility: `gosentry.json`
|
||||
and `jobs.json` written by an older version must keep working. Check that new
|
||||
`Config` fields are backward compatible, that normalization happens in one
|
||||
place, and that values which are meaningful zeros are not normalized away. Also
|
||||
look for code kept alive only for a case that no longer exists.
|
||||
|
||||
## 8. Undocumented or under-documented contentious decisions
|
||||
|
||||
Any decision a future reader would question needs its reasoning recorded where
|
||||
it lives: a comment at the code, an entry in the "Intentional behavior" section
|
||||
of [STANDARDS.md](STANDARDS.md), or — when the work is deferred rather than
|
||||
decided — a note in [ROADMAP.md](ROADMAP.md), which is where the frozen
|
||||
window-size work keeps its rationale.
|
||||
|
||||
## 9. Other improvement proposals
|
||||
|
||||
Anything that does not fit above: build and release ergonomics, test coverage
|
||||
gaps, dependency health, UX rough edges.
|
||||
|
||||
## What happens to the findings
|
||||
|
||||
- A defect → fix it, with a regression test when severity is medium or higher.
|
||||
- Behavior that turns out to be deliberate → record it under "Intentional
|
||||
behavior" in [STANDARDS.md](STANDARDS.md) so it is not re-reported.
|
||||
- Work larger than a single fix → [ROADMAP.md](ROADMAP.md), with the reasoning.
|
||||
- A new rule the review establishes → [STANDARDS.md](STANDARDS.md).
|
||||
+66
-19
@@ -5,6 +5,48 @@ Completed work is recorded in [CHANGELOG.md](CHANGELOG.md), not here.
|
||||
|
||||
## Open Items
|
||||
|
||||
### Faster Windows failure notifications
|
||||
|
||||
Fyne `SendNotification` on Windows does not call WinRT directly. Each toast
|
||||
writes a short script to `%TEMP%` and runs it through a **new PowerShell
|
||||
process** (`app/app_windows.go`), which typically adds **1–3 seconds** of cold
|
||||
start before the toast appears. GoSentry's own path from run completion through
|
||||
`SendNotification` is much smaller and is logged separately.
|
||||
|
||||
**Baseline (2026-08-05, `scripts/measure-windows-toast.ps1`, 3 runs on dev
|
||||
machine):** average **773 ms** per toast (695–874 ms), dominated by PowerShell
|
||||
cold start. Re-run the script when comparing after a native toast implementation.
|
||||
|
||||
**App-side timing:** each failure notification appends one line to
|
||||
`logs/notify-timing.tsv` (`ms_after_run`, `ms_fyne_do`, `ms_send`,
|
||||
`ms_app_total`). These columns end when Fyne returns from `SendNotification`; OS
|
||||
toast latency is not included. The `.tsv` extension keeps it out of
|
||||
`runner.CleanupLogs`, which only manages `.log` files — this file is
|
||||
diagnostic instrumentation for this item, not job output, and should be
|
||||
removed (or unified with the run-log retention policy under its own knob) once
|
||||
the native-toast direction below lands and the timing data is no longer
|
||||
needed.
|
||||
|
||||
**Direction:** add `src/platform/notify/` with a native Windows toast (WinRT or
|
||||
a maintained Go wrapper), used for failure notifications on Windows. Keep Fyne
|
||||
`SendNotification` on Linux (DBus / xdg-desktop-portal) unless profiling shows it
|
||||
needs the same treatment.
|
||||
|
||||
### Retire the config compatibility shims
|
||||
|
||||
Two read-only shims in `storage.loadOrCreateConfig` rewrite an old file into
|
||||
the current shape on the next save, so each becomes dead the moment a user's
|
||||
config has been saved once by a build that has it:
|
||||
|
||||
- `Config.JobsDir` (pre-0.15, superseded by `Config.JobsFile`).
|
||||
- `Theme == "default"` (pre-1.0.1, superseded by `ThemeSystem`).
|
||||
|
||||
Neither has an expiry. Remove both — the field, the migration branch, and
|
||||
`TestLoadOrCreateConfigMigratesJobsDir` /
|
||||
`TestLoadOrCreateConfigMigratesLegacyThemeDefault` — once a release has shipped
|
||||
long enough that a config file still carrying either old shape is not a
|
||||
realistic upgrade path GoSentry needs to support.
|
||||
|
||||
### Dynamic tray icon toggle
|
||||
|
||||
Fyne exposes `SetSystemTrayIcon` and related APIs only at application startup.
|
||||
@@ -101,24 +143,25 @@ Design notes / open questions:
|
||||
|
||||
[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
|
||||
examples. Six non-test files are over it at 1.0.0, including both files that
|
||||
were already split once:
|
||||
examples. `jobs_view.go` was split again in 1.0.3 — into view, state, list, and
|
||||
toolbar — because the selection defect it carried was a symptom of the size
|
||||
(one 330-line constructor over seven shared locals). Six non-test files are
|
||||
over the guideline:
|
||||
|
||||
| File | Lines |
|
||||
|------|-------|
|
||||
| `src/app/operations.go` | 490 |
|
||||
| `src/ui/jobs_view.go` | 355 |
|
||||
| `src/app/run.go` | 287 |
|
||||
| `src/ui/history_view.go` | 282 |
|
||||
| `src/ui/settings_view.go` | 277 |
|
||||
| `src/storage/store.go` | 265 |
|
||||
| `src/app/operations.go` | 529 |
|
||||
| `src/storage/store.go` | 382 |
|
||||
| `src/ui/history_view.go` | 355 |
|
||||
| `src/ui/settings_view.go` | 326 |
|
||||
| `src/app/run.go` | 275 |
|
||||
| `src/app/service.go` | 252 |
|
||||
|
||||
This is deliberately deferred to the next whole-project review rather than done
|
||||
piecemeal: [REVIEW.md](REVIEW.md) already asks item 2 to look for exactly this,
|
||||
a split touches every reader of the file, and doing all six in one pass keeps
|
||||
the seams consistent instead of settling them six different ways. Splitting is
|
||||
The remaining six are deliberately deferred rather than done piecemeal: a
|
||||
split touches every reader of the file, and doing them in one pass keeps the
|
||||
seams consistent instead of settling each one its own way. Splitting is
|
||||
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 a hurried one per file.
|
||||
|
||||
Seams visible today, as a starting point rather than a decision:
|
||||
|
||||
@@ -130,13 +173,17 @@ Seams visible today, as a starting point rather than a decision:
|
||||
- **`history_view.go`** — the column-measuring helpers (`textWidth` through
|
||||
`historyColumnWidths`) are pure, already unit-tested, and independent of the
|
||||
table they size.
|
||||
- **`jobs_view.go`** — nearly all of it is one `newJobsView` constructor, so the
|
||||
split has to break that function up (list template, toolbar handlers,
|
||||
assembly) rather than move whole functions. Larger judgement call than the
|
||||
others.
|
||||
- **`run.go`**, **`settings_view.go`**, **`store.go`** — barely over. Worth
|
||||
- **`store.go`** — path resolution, the config load/normalize path, and the jobs
|
||||
load/normalize path are three separate concerns in one file.
|
||||
- **`run.go`**, **`settings_view.go`**, **`service.go`** — barely over. Worth
|
||||
re-measuring at the time; if a pass elsewhere has shrunk them, leave them
|
||||
alone rather than splitting for the sake of the number.
|
||||
alone rather than splitting for the sake of the number. The counts above move
|
||||
a few lines either way with any edit, so re-measure before acting on them
|
||||
rather than treating the table as current.
|
||||
|
||||
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
|
||||
that is fine — a table-driven test file grows with the cases it covers.
|
||||
|
||||
+59
-2
@@ -1,8 +1,7 @@
|
||||
# GoSentry — Standards
|
||||
|
||||
Quality rules and intentional behavior for contributors. Package contracts live
|
||||
in [ARCHITECTURE.md](ARCHITECTURE.md); test conventions in [TESTS.md](TESTS.md);
|
||||
what a whole-project review looks at, in [REVIEW.md](REVIEW.md).
|
||||
in [ARCHITECTURE.md](ARCHITECTURE.md); test conventions in [TESTS.md](TESTS.md).
|
||||
|
||||
## Code quality
|
||||
|
||||
@@ -12,6 +11,15 @@ what a whole-project review looks at, in [REVIEW.md](REVIEW.md).
|
||||
- Fixes with severity ≥ medium → regression test.
|
||||
- Documented intentional behavior → section below, not a backlog bug.
|
||||
- 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 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
|
||||
@@ -63,9 +71,33 @@ change to their shape has to stay compatible on its own.
|
||||
= 0) and is overridable per job (`Job.TimeoutSeconds *int`: unset = inherit the
|
||||
global default, 0 = no timeout, positive = seconds). Neither zero may be
|
||||
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
|
||||
current process. Log files on disk feed aggregate statistics via `SeedStats`
|
||||
only. See [ARCHITECTURE.md](ARCHITECTURE.md).
|
||||
- **History is capped and its columns only widen.** The tab keeps the newest
|
||||
`maxHistoryRows` records and drops the oldest, the way `maxJobLogs` caps a
|
||||
job's own activity list — an app left in the tray records thousands of runs a
|
||||
day, each carrying the run's full captured output. Column widths are folded in
|
||||
one record at a time instead of rescanned from every row, so a column never
|
||||
narrows when a record ages out: the rows on screen were laid out against the
|
||||
wider value. A theme change is the one case that rescans, because every stored
|
||||
width was measured at the old text size.
|
||||
- Several tests share a coverage profile with another test on purpose, and a few
|
||||
functions sit at 0% on purpose. Both lists live in
|
||||
[TESTS.md](TESTS.md) — check them before reporting a test as redundant or a
|
||||
@@ -80,6 +112,31 @@ change to their shape has to stay compatible on its own.
|
||||
mid-session (see [ROADMAP.md](ROADMAP.md)).
|
||||
- **`--start-in-tray` defers to config.** A stale autostart shortcut that still
|
||||
passes the flag does not hide the window when `KeepRunningInTray` is off.
|
||||
- **`JobRuntime.PendingRuns` (the "queue" overlap policy's backlog) is capped at
|
||||
`maxPendingRuns` (10) and cleared on pause or disable.** A job whose runs take
|
||||
longer than its interval stops accumulating backlog once the cap is hit —
|
||||
further overlaps are dropped like the "skip" policy until the backlog drains
|
||||
below the cap. `SetGlobalPause(true)` and `SetEnabled(id, false)` both zero
|
||||
the counter, so resuming or re-enabling a job never replays a deferred run for
|
||||
an occurrence that fired before the pause/disable. The details pane appends
|
||||
", N queued" to the statistics line via `DisplayStats` whenever the count is
|
||||
non-zero.
|
||||
- **Single-instance arbitration falls back to "start anyway" when the port is
|
||||
held by something else.** `acquireSingleInstance` (`singleinstance.go`)
|
||||
binds `127.0.0.1:37653`; if that fails and a dial to the same address does
|
||||
not answer as GoSentry either, startup continues rather than refusing to
|
||||
open because of an unrelated local listener. The consequence is deliberate
|
||||
but worth spelling out: two GoSentry processes can then run two schedulers
|
||||
against the same `jobs.json` and the same logs directory, each overwriting
|
||||
the other's saves. Atomic writes (`writeFileAtomic`) prevent a *torn* file
|
||||
from a concurrent write, but not one process's save clobbering the other's.
|
||||
- **The single-instance channel is an unauthenticated localhost TCP port.**
|
||||
Port 37653 accepts one command, `"show"`, from any local process — including
|
||||
one running as a different user on a shared machine. This is a deliberate
|
||||
scope choice, not an oversight: the command only raises the existing window,
|
||||
so the impact of an unwelcome sender is a window popping up, not data
|
||||
exposure or control. Anything with a larger blast radius on that channel
|
||||
would need real authentication.
|
||||
|
||||
## Out of scope
|
||||
|
||||
|
||||
+104
-13
@@ -26,6 +26,12 @@ The GUI tests build the Fyne desktop backend, so CGO must be enabled; on Windows
|
||||
that means the MSYS2 UCRT64 toolchain described in
|
||||
[DEVELOPMENT.md](DEVELOPMENT.md).
|
||||
|
||||
`src/ui` dominates `go test -race ./...`'s wall time — around 229s in the
|
||||
2026-08-05 whole-project review, against under 8s for every other package
|
||||
combined. Budget iteration accordingly: a change confined to `domain`,
|
||||
`storage`, `runner`, `scheduler`, or `app` gets a fast feedback loop; a `ui`
|
||||
change does not.
|
||||
|
||||
### Manual test commands
|
||||
|
||||
Run all tests:
|
||||
@@ -60,11 +66,31 @@ exercised from another one's tests — `domain.NewRuntime`, for instance, is
|
||||
covered by the `app` tests. Measure the engine packages together instead:
|
||||
|
||||
```bash
|
||||
go test -coverpkg=./src/domain,./src/storage,./src/runner,./src/scheduler,./src/app ./src/domain ./src/storage ./src/runner ./src/scheduler ./src/app
|
||||
go test -coverprofile=cover.out -coverpkg=./src/domain,./src/storage,./src/runner,./src/scheduler,./src/app ./src/domain ./src/storage ./src/runner ./src/scheduler ./src/app
|
||||
```
|
||||
|
||||
That figure was 84.4% at the 2026-08-04 review, which is the number to compare
|
||||
against before concluding that coverage has slipped.
|
||||
In the PowerShell environment DEVELOPMENT.md prescribes on Windows, PowerShell
|
||||
splits the comma-separated `-coverpkg` list on its own and the command fails
|
||||
with `directory not found`. Use the stop-parsing token, or quote the whole
|
||||
flag — and note that `--%` swallows the rest of the line, so the profile has to
|
||||
be read by a second command:
|
||||
|
||||
```powershell
|
||||
go test --% -coverprofile=cover.out -coverpkg=./src/domain,./src/storage,./src/runner,./src/scheduler,./src/app ./src/domain ./src/storage ./src/runner ./src/scheduler ./src/app
|
||||
```
|
||||
|
||||
The total is the last line of the profile summary. It is **not** any of the
|
||||
per-package lines `go test` prints: with `-coverpkg` spanning five packages,
|
||||
each of those reports only what that one package's tests reached across the
|
||||
whole set, so all five are far below the real figure.
|
||||
|
||||
```powershell
|
||||
go tool cover -func=cover.out | Select-Object -Last 1
|
||||
```
|
||||
|
||||
That total was 84.4% at the 2026-08-04 review and 84.1% at the 2026-08-07
|
||||
documentation audit — the number to compare against before concluding that
|
||||
coverage has slipped.
|
||||
|
||||
---
|
||||
|
||||
@@ -100,7 +126,6 @@ Tests autostart argument helpers and the jobs-list density normalization rule.
|
||||
| `TestAutostartArguments` | Verifies `AutostartArguments` returns `--start-in-tray` when the tray is enabled and an empty string when it is off. |
|
||||
| `TestResolveStartHidden` | Verifies hidden autostart requires both the CLI flag and `KeepRunningInTray`. |
|
||||
| `TestJobListViewIsCompact` | Verifies only the exact `"compact"` value selects one-line rows: empty, differently-cased, and unrecognised values all read as detailed. |
|
||||
| `TestDefaultConfigUsesDetailedJobList` | Verifies `DefaultConfig` selects the detailed job list. |
|
||||
|
||||
---
|
||||
|
||||
@@ -137,6 +162,7 @@ Tests all mutating operations on the Service, scheduler integration, and setting
|
||||
| `TestDeleteJobNotFound` | Verifies that `DeleteJob` returns an error for an unknown job ID. |
|
||||
| `TestSetEnabledNotFound` | Verifies that `SetEnabled` returns an error for an unknown job ID. |
|
||||
| `TestSetEnabledToggles` | Verifies that `SetEnabled` flips the enabled flag and persists the change. |
|
||||
| `TestSetEnabledClearsPendingRuns` | Verifies that disabling a job zeroes a `PendingRuns` backlog it was carrying, so re-enabling it later does not replay a stale deferred run. |
|
||||
|
||||
#### Global pause / run-now / run-due
|
||||
|
||||
@@ -166,6 +192,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`. |
|
||||
| `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. |
|
||||
| `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. |
|
||||
| `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. |
|
||||
@@ -188,11 +216,11 @@ and scheduler edge cases using injected `runJob` and `primeDue`.
|
||||
| `TestRunDueSkipDropsOverlap` | Global skip: no second concurrent run, `PendingRuns` stays 0. |
|
||||
| `TestRunDueQueueRerunsAfterFinish` | Queue: one deferred run after an in-flight finish; also covers an empty per-job policy inheriting the global default. |
|
||||
| `TestRunDueQueueDrainsMultipleOverlaps` | Queue: multiple missed ticks drain as separate runs. |
|
||||
| `TestRunDueQueueCapsPendingRuns` | Regression: `PendingRuns` stops growing at `maxPendingRuns` instead of accumulating without bound for a job that never keeps up with its schedule. |
|
||||
| `TestRunDuePerJobQueueOverridesGlobalSkip` | Per-job `queue` beats global `skip`. |
|
||||
| `TestRunDuePerJobSkipOverridesGlobalQueue` | Per-job `skip` beats global `queue`. |
|
||||
| `TestRunNowSequentialGuard` | Manual run refused while another job runs in sequential mode. |
|
||||
| `TestStartRunLockedRollbackOnSaveFailure` | Regression: run does not start when `SaveJobs` fails. |
|
||||
| `TestRunDueQueueDrainSkippedWhenPaused` | Queued overlaps are not drained while the scheduler is paused. |
|
||||
| `TestRunDueQueueDrainSkippedWhenPaused` | Queued overlaps are not drained while the scheduler is paused, and pausing clears the backlog rather than leaving it to fire a stale deferred run on resume. |
|
||||
| `TestEffectiveTimeout` | Verifies the three-state resolution: `nil` inherits the global default, a positive value overrides it, and an explicit `0` means no timeout without inheriting. |
|
||||
|
||||
---
|
||||
@@ -221,8 +249,8 @@ Tests display-formatting helpers used by the UI.
|
||||
| `TestStatusText` | Verifies that job status codes map to the correct display strings. |
|
||||
| `TestEventText` | Verifies trigger-type labels for scheduled, manual, and UI triggers. |
|
||||
| `TestEventLine` | Verifies the one-line activity rendering of a `RunRecord`, including the log basename and the `Unknown` fallback for a blank trigger. |
|
||||
| `TestDisplayFolder` | Verifies that an empty folder string shows "No folder". |
|
||||
| `TestDisplayArguments` | Verifies that an empty arguments string shows "None". |
|
||||
| `TestDisplayFolder` | Verifies that an empty folder string shows "(No folder)". |
|
||||
| `TestDisplayArguments` | Verifies that an empty arguments string shows "(none)". |
|
||||
| `TestDisplayRunMode` | Verifies run-mode labels for normal and start-only modes. |
|
||||
| `TestDisplayInvocation` | Verifies that the full invocation display string combines command and arguments with spacing. |
|
||||
| `TestDisplayIndex` | Verifies the list position of a job index in a filtered index slice. |
|
||||
@@ -243,15 +271,19 @@ Tests JSON round-tripping, default generation, and backward compatibility.
|
||||
| `TestJobsRoundTrip` | Verifies that jobs saved to JSON are reloaded with identical field values. |
|
||||
| `TestConfigRoundTrip` | Verifies that settings saved to JSON are reloaded with identical field values. |
|
||||
| `TestNormalizeJobsFillsDefaults` | Verifies that `normalizeJobs` assigns sequential IDs and sets default name, schedule, and command for jobs missing those fields. |
|
||||
| `TestNormalizeJobsReassignsDuplicateIDs` | Verifies that a hand-edited `jobs.json` with two entries sharing one ID gets the later duplicates reassigned instead of colliding on one runtime. |
|
||||
| `TestResolveConfiguredPathCleansAbsolutePaths` | Verifies (Windows only) that forward-slash and backslash spellings of the same absolute path resolve to the same string. |
|
||||
| `TestLoadOrCreateConfigCreatesDefaultsOnFirstRun` | Verifies that a missing config file is created with sane defaults. |
|
||||
| `TestLoadOrCreateJobsSeedsSampleJobsOnFirstRun` | Verifies that a missing jobs file is created with the sample jobs from `defaultJobs`. |
|
||||
| `TestLoadOrCreateConfigKeepsZeroTimeoutOnReload` | Verifies that `default_timeout_seconds: 0` survives a reload rather than being normalized away — 0 is a value, not a missing field. |
|
||||
| `TestLoadOrCreateConfigPreservesZeroRetentionLimits` | Verifies that `max_log_files` / `max_log_age_days` of 0 read back as 0 ("keep everything") instead of being backfilled to the 100 / 30 defaults — a field the file sets is not the missing-field case. |
|
||||
| `TestLoadOrCreateConfigMigratesJobsDir` | Verifies that a pre-0.15 `jobs_dir` becomes `jobs_file` pointing at the same `jobs.json`, and that the retired key is not written back. |
|
||||
| `TestLoadOrCreateConfigMigratesLegacyThemeDefault` | Verifies that a config storing the retired `"default"` theme value is normalized to `system` on load. |
|
||||
| `TestLoadJobsFileReportsMissingWithoutCreating` | Verifies that `LoadJobsFile` reports a missing file as not-found without creating or seeding it, and normalizes the jobs it does load. |
|
||||
| `TestApplyConfigPathsDerivesJobsDir` | Verifies that the configured jobs file resolves against the program folder and that `Paths.JobsDir` is derived from it. |
|
||||
| `TestJobTimeoutRoundTripsThreeStates` | Verifies the on-disk encoding that keeps "inherit" and "no timeout" distinguishable: `nil` is omitted entirely, an explicit `0` is written and read back as set. |
|
||||
| `TestJobsJSONDoesNotPersistRuntimeNoise` | Verifies that `jobs.json` does not persist runtime state (LastRun, NextRun, etc.). Only durable job fields are stored. |
|
||||
| `TestWriteJSONReplacesFileAtomically` | Pins the durability fix: `writeJSON` replaces the destination through a temp file and a rename rather than truncating it in place, and leaves no temp file behind. |
|
||||
|
||||
---
|
||||
|
||||
@@ -319,6 +351,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. |
|
||||
| `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. |
|
||||
|
||||
---
|
||||
|
||||
@@ -335,6 +368,7 @@ Tests the Windows shell invocation and hidden-window flags.
|
||||
| `TestShellCommandHidesWindow` | Verifies that shell commands request hidden-window startup to prevent console flash. |
|
||||
| `TestShellCommandUsesWindowsSafeQuoting` | Verifies `cmd.exe /S /C` quoting for paths with spaces and special characters. |
|
||||
| `TestWindowsShellCommandLineQuotesUnquotedProgramPath` | Verifies that unquoted program paths in shell commands are quoted while preserving already-quoted arguments. |
|
||||
| `TestQuoteLeadingWindowsProgramPathPicksEarliestBoundedExtension` | Regression: the program path ends at the *earliest* extension match sitting at a token boundary — not the first extension in `.exe`/`.cmd`/`.bat`/`.com` list order, and not a substring inside another word — so a `.bat` wrapper followed by an `.exe` argument still quotes only the wrapper. |
|
||||
|
||||
---
|
||||
|
||||
@@ -373,6 +407,19 @@ Tests log-file cleanup by age and by count.
|
||||
|
||||
---
|
||||
|
||||
### src/runner/logfile_test.go
|
||||
|
||||
**Package:** `runner`
|
||||
|
||||
Tests the disambiguating suffix `writeRunLog` applies when two runs land on
|
||||
the same second.
|
||||
|
||||
| Test | Purpose |
|
||||
|------|---------|
|
||||
| `TestUniqueLogPathAvoidsCollision` | Verifies repeated calls for the same file name return distinct paths instead of silently overwriting an existing log. |
|
||||
|
||||
---
|
||||
|
||||
### src/platform/autostart/autostart_windows_test.go
|
||||
|
||||
**Location:** `src/platform/autostart/autostart_windows_test.go`
|
||||
@@ -468,11 +515,34 @@ widgets are assembled.
|
||||
| `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. |
|
||||
| `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. |
|
||||
|
||||
---
|
||||
|
||||
### 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
|
||||
|
||||
**Package:** `ui`
|
||||
@@ -482,8 +552,6 @@ column-width behaviour of the assembled table.
|
||||
|
||||
| Test | Purpose |
|
||||
|------|---------|
|
||||
| `TestCollectActivityMergesAndSorts` | Verifies per-job logs are merged and sorted by time. |
|
||||
| `TestCollectActivitySkipsMissingRuntimes` | Verifies missing runtime entries are skipped safely. |
|
||||
| `TestHistoryCellText` | Verifies table cell text for all columns; empty trigger → `Unknown`. |
|
||||
| `TestLogFileName` | Verifies log path basename extraction on Windows and Unix paths. |
|
||||
| `TestNewEventUsesConsistentTimestampShape` | Verifies UI events use the same timestamp layout as run records. |
|
||||
@@ -494,6 +562,10 @@ column-width behaviour of the assembled table.
|
||||
| `TestHistoryCellTemplateIsPlainText` | Verifies the cell template already carries the zero `TextStyle`, since the per-cell assignment that used to reset it is gone. |
|
||||
| `TestTextColumnWidthClamps` | Covers the three shapes of `textColumnWidth`: below the minimum, in range, and capped at the maximum. |
|
||||
| `TestHistoryColumnsFitTheirContent` | Verifies every column is at least as wide as its widest known or present value, at the default text size and at a scaled theme. |
|
||||
| `TestHistoryLogCapsRecords` | Regression guard for the unbounded History list: the log keeps the newest `maxHistoryRows` records, drops the oldest from the front, and trims a list handed in already over the cap. |
|
||||
| `TestHistoryLogWidthsMatchAFullScan` | Verifies the incremental column widths equal a full rescan while every measured record is still present — the cheaper path must not clip what the old one showed. |
|
||||
| `TestHistoryLogWidthsDoNotShrinkWhenRecordsAgeOut` | Verifies a column keeps its width after the record that set it is dropped by the cap, since the rows on screen were laid out against it. |
|
||||
| `TestHistoryLogRescansOnThemeChange` | Verifies a theme change falls back to a full rescan, the one case the incremental fold cannot handle because every stored width was measured at the old text size. |
|
||||
|
||||
---
|
||||
|
||||
@@ -554,6 +626,19 @@ Tests main view construction with an injected `*app.Service`.
|
||||
|
||||
---
|
||||
|
||||
### src/ui/notify_timing_test.go
|
||||
|
||||
**Package:** `ui`
|
||||
|
||||
Tests the failure-notification timing diagnostics added in 1.0.2.
|
||||
|
||||
| Test | Purpose |
|
||||
|------|---------|
|
||||
| `TestNotificationTimingFormatLine` | Verifies `notificationTiming.formatLine` renders the job name and the three millisecond deltas (`ms_after_run`, `ms_fyne_do`, `ms_send`) plus their sum (`ms_app_total`). |
|
||||
| `TestAppendNotificationTimingLogWritesHeaderAndRow` | Verifies `appendNotificationTimingLog` creates `notify-timing.tsv` with its header on first write and appends a row containing the job name. |
|
||||
|
||||
---
|
||||
|
||||
## Test Design Principles
|
||||
|
||||
1. **Isolation** — Tests use `t.TempDir()` for file operations and `t.Setenv()` for environment variables to avoid affecting system state.
|
||||
@@ -603,6 +688,12 @@ A coverage run over the non-UI packages reports these as uncovered. All are
|
||||
intentional; none is an oversight to be "fixed" with a test.
|
||||
|
||||
- The real `Clock` — a fake is injected everywhere it is used.
|
||||
- `storage.OpenStore`, `storage.ResolvePaths`, `app.Service.Start`, `app.Service.Open` — process entry points, exercised by running the app.
|
||||
- The autostart and desktop-icon wrappers — OS integration, driven only on a real desktop.
|
||||
- `storage.OpenStore`, `storage.ResolvePaths`, `storage.PeekKeepRunningInTray`, `app.Service.Start`, `app.Open` — process entry points, exercised by running the app.
|
||||
- The autostart and desktop-icon wrappers (`app.Service.InstallDesktopIcon`, `AutostartStatus`, `ApplyAutostart`) — OS integration, driven only on a real desktop.
|
||||
- `app.Service.ShouldNotifyOnFailure` — a getter under the mutex.
|
||||
- `app.Service.Config` and `app.Service.Paths` — read only from `src/ui`, which
|
||||
this run excludes, so they are covered by the suite but not by this profile.
|
||||
The same applies to `storage.Store.SaveJobs`: the engine writes through
|
||||
`PrepareSaveJobs`, and the one-shot wrapper is what `OpenStore` uses.
|
||||
- The five `isEvent` marker methods in `app/events.go` — empty bodies that exist
|
||||
only to close the `Event` interface.
|
||||
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 37 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 99 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 100 KiB |
Binary file not shown.
|
Before Width: | Height: | Size: 65 KiB |
Binary file not shown.
|
Before Width: | Height: | Size: 57 KiB |
@@ -0,0 +1,39 @@
|
||||
# Measures the latency of Fyne's Windows toast path: write a short PowerShell
|
||||
# script to %TEMP% and run it via PowerShell -ExecutionPolicy Bypass, the same
|
||||
# approach fyne.io/fyne/v2/app uses in app_windows.go SendNotification.
|
||||
param(
|
||||
[int]$Iterations = 3
|
||||
)
|
||||
|
||||
$template = @'
|
||||
$title = "GoSentry timing test"
|
||||
$content = "benchmark"
|
||||
$iconPath = "file:///"
|
||||
[Windows.UI.Notifications.ToastNotificationManager, Windows.UI.Notifications, ContentType = WindowsRuntime] > $null
|
||||
$template = [Windows.UI.Notifications.ToastNotificationManager]::GetTemplateContent([Windows.UI.Notifications.ToastTemplateType]::ToastImageAndText02)
|
||||
$toastXml = [xml] $template.GetXml()
|
||||
$toastXml.GetElementsByTagName("text")[0].AppendChild($toastXml.CreateTextNode($title)) > $null
|
||||
$toastXml.GetElementsByTagName("text")[1].AppendChild($toastXml.CreateTextNode($content)) > $null
|
||||
$toastXml.GetElementsByTagName("image")[0].SetAttribute("src", $iconPath) > $null
|
||||
$xml = New-Object Windows.Data.Xml.Dom.XmlDocument
|
||||
$xml.LoadXml($toastXml.OuterXml)
|
||||
$toast = [Windows.UI.Notifications.ToastNotification]::new($xml)
|
||||
[Windows.UI.Notifications.ToastNotificationManager]::CreateToastNotifier("ru.mixeme.gosentry.desktop").Show($toast);
|
||||
'@
|
||||
|
||||
Write-Host "Fyne-style Windows toast latency ($Iterations run(s), no icon path):"
|
||||
$totalMs = 0
|
||||
for ($i = 1; $i -le $Iterations; $i++) {
|
||||
$scriptPath = Join-Path $env:TEMP ("fyne-timing-test-$i.ps1")
|
||||
Set-Content -Path $scriptPath -Value $template -Encoding UTF8
|
||||
$sw = [System.Diagnostics.Stopwatch]::StartNew()
|
||||
$launch = "(Get-Content -Encoding UTF8 -Path `"$scriptPath`" -Raw) | Invoke-Expression"
|
||||
& PowerShell -ExecutionPolicy Bypass -Command $launch | Out-Null
|
||||
$sw.Stop()
|
||||
$ms = [int]$sw.ElapsedMilliseconds
|
||||
$totalMs += $ms
|
||||
Write-Host (" run {0}: {1} ms" -f $i, $ms)
|
||||
Remove-Item $scriptPath -ErrorAction SilentlyContinue
|
||||
}
|
||||
$avg = [math]::Round($totalMs / [double]$Iterations)
|
||||
Write-Host (" average: {0} ms" -f $avg)
|
||||
@@ -6,6 +6,13 @@ REM Runs go vet and go test with race detection
|
||||
REM Move to repository root
|
||||
cd /d "%~dp0\.."
|
||||
|
||||
REM This file is UTF-8 (the ✓/✗ below). cmd.exe reads batch files in the
|
||||
REM console's active code page, which defaults to the system locale (e.g.
|
||||
REM CP866 on Russian Windows) rather than UTF-8, so without this the two
|
||||
REM symbols render as mojibake. Switching the console to UTF-8 first fixes
|
||||
REM that; >nul silences chcp's own "Active code page" confirmation line.
|
||||
chcp 65001 >nul
|
||||
|
||||
REM Fyne uses native libraries through CGO. MSYS2 UCRT64 provides the GCC toolchain
|
||||
REM expected by the Windows build; prepending it keeps the script self-contained
|
||||
REM without permanently changing the user's system PATH.
|
||||
|
||||
+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
|
||||
// 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
|
||||
// listener can exhaustively type-switch over them and the compiler will flag a
|
||||
// new event type that a switch forgot to handle.
|
||||
// only implementations (enforced by the unexported isEvent marker), so an
|
||||
// Event handed to an Observer is always one of the types declared here — a
|
||||
// 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
|
||||
// reaching into the GUI, the Service emits typed events and the UI subscribes —
|
||||
|
||||
+18
-3
@@ -82,14 +82,29 @@ func DisplayInvocation(job domain.Job) string {
|
||||
return job.Command + " " + strings.ReplaceAll(strings.TrimSpace(job.Arguments), "\n", " ")
|
||||
}
|
||||
|
||||
// DisplayStats returns a one-line execution-time summary for a job runtime.
|
||||
// Returns "No runs recorded" when no runs have been counted yet.
|
||||
// DisplayStats returns a one-line execution-time summary for a job runtime,
|
||||
// with the queued-run depth appended whenever the "queue" overlap policy has
|
||||
// deferred runs waiting (see maxPendingRuns). Returns "No runs recorded" when
|
||||
// no runs have been counted yet, still showing the queue depth if one exists.
|
||||
func DisplayStats(rt *domain.JobRuntime) string {
|
||||
if rt == nil || rt.RunCount == 0 {
|
||||
if rt != nil && rt.PendingRuns > 0 {
|
||||
return "No runs recorded" + pendingRunsSuffix(rt.PendingRuns)
|
||||
}
|
||||
return "No runs recorded"
|
||||
}
|
||||
return fmt.Sprintf("%d runs, %d failed, last %d ms, avg %d ms, max %d ms",
|
||||
rt.RunCount, rt.FailCount, rt.LastDurationMS, rt.AvgDurationMS, rt.MaxDurationMS)
|
||||
rt.RunCount, rt.FailCount, rt.LastDurationMS, rt.AvgDurationMS, rt.MaxDurationMS) +
|
||||
pendingRunsSuffix(rt.PendingRuns)
|
||||
}
|
||||
|
||||
// pendingRunsSuffix formats the queued-run depth for DisplayStats, empty when
|
||||
// nothing is queued.
|
||||
func pendingRunsSuffix(pending int) string {
|
||||
if pending <= 0 {
|
||||
return ""
|
||||
}
|
||||
return fmt.Sprintf(", %d queued", pending)
|
||||
}
|
||||
|
||||
// DisplayOverlapPolicy formats a job's effective overlap policy for the details
|
||||
|
||||
@@ -126,6 +126,17 @@ func TestDisplayStats(t *testing.T) {
|
||||
if got := DisplayStats(rtNoFail); got != wantNoFail {
|
||||
t.Errorf("DisplayStats no-fail = %q, want %q", got, wantNoFail)
|
||||
}
|
||||
|
||||
// A "queue" overlap backlog is appended to whichever form applies, so it stays
|
||||
// visible even before the first run has completed.
|
||||
if got, want := DisplayStats(&domain.JobRuntime{PendingRuns: 2}), "No runs recorded, 2 queued"; got != want {
|
||||
t.Errorf("DisplayStats pending, no runs = %q, want %q", got, want)
|
||||
}
|
||||
rtPending := &domain.JobRuntime{RunCount: 5, FailCount: 2, LastDurationMS: 450, AvgDurationMS: 380, MaxDurationMS: 520, PendingRuns: 3}
|
||||
wantPending := "5 runs, 2 failed, last 450 ms, avg 380 ms, max 520 ms, 3 queued"
|
||||
if got := DisplayStats(rtPending); got != wantPending {
|
||||
t.Errorf("DisplayStats pending = %q, want %q", got, wantPending)
|
||||
}
|
||||
}
|
||||
|
||||
func TestEventLine(t *testing.T) {
|
||||
|
||||
+78
-39
@@ -43,15 +43,23 @@ func (s *Service) CreateJob(job domain.Job) (domain.Job, error) {
|
||||
s.parseScheduleLocked(&job)
|
||||
record := uiRecord(job.ID, job.Name, "Created", "Job was added")
|
||||
prependLog(runtime, record)
|
||||
err := s.store.SaveJobs(s.jobs)
|
||||
if err != nil {
|
||||
s.jobs = s.jobs[:len(s.jobs)-1]
|
||||
save := s.deferSaveLocked(s.store.PrepareSaveJobs(s.jobs))
|
||||
s.mu.Unlock()
|
||||
|
||||
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.schedules, job.ID)
|
||||
s.mu.Unlock()
|
||||
return domain.Job{}, err
|
||||
}
|
||||
s.mu.Unlock()
|
||||
s.emit(RunRecorded{Record: record})
|
||||
s.emit(JobChanged{JobID: job.ID})
|
||||
return job, nil
|
||||
@@ -87,10 +95,10 @@ func (s *Service) UpdateJob(job domain.Job) error {
|
||||
s.refreshNextRunLocked(existing, runtime)
|
||||
record := uiRecord(job.ID, job.Name, "Updated", "Job settings changed")
|
||||
prependLog(runtime, record)
|
||||
err := s.store.SaveJobs(s.jobs)
|
||||
save := s.deferSaveLocked(s.store.PrepareSaveJobs(s.jobs))
|
||||
s.mu.Unlock()
|
||||
|
||||
if err != nil {
|
||||
if err := save(); err != nil {
|
||||
return err
|
||||
}
|
||||
s.emit(RunRecorded{Record: record})
|
||||
@@ -113,10 +121,10 @@ func (s *Service) DeleteJob(id int) error {
|
||||
delete(s.runtimes, id)
|
||||
delete(s.schedules, id)
|
||||
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()
|
||||
|
||||
if err != nil {
|
||||
if err := save(); err != nil {
|
||||
return err
|
||||
}
|
||||
s.emit(RunRecorded{Record: record})
|
||||
@@ -148,13 +156,17 @@ func (s *Service) SetEnabled(id int, enabled bool) error {
|
||||
runtime.LastState = "Paused"
|
||||
runtime.NextRun = "Paused"
|
||||
runtime.NextDue = time.Time{}
|
||||
// A disabled job's own occurrences stop firing, so a "queue" backlog it was
|
||||
// carrying no longer corresponds to anything: clear it rather than replaying
|
||||
// stale deferred runs if the job is re-enabled later.
|
||||
runtime.PendingRuns = 0
|
||||
record = uiRecord(id, job.Name, "Paused", "Job was disabled")
|
||||
}
|
||||
prependLog(runtime, record)
|
||||
err := s.store.SaveJobs(s.jobs)
|
||||
save := s.deferSaveLocked(s.store.PrepareSaveJobs(s.jobs))
|
||||
s.mu.Unlock()
|
||||
|
||||
if err != nil {
|
||||
if err := save(); err != nil {
|
||||
return err
|
||||
}
|
||||
s.emit(RunRecorded{Record: record})
|
||||
@@ -175,15 +187,19 @@ func (s *Service) SetGlobalPause(paused bool) error {
|
||||
for index := range s.jobs {
|
||||
job := &s.jobs[index]
|
||||
runtime := s.runtimeForLocked(job)
|
||||
if paused {
|
||||
// A "queue" backlog counts occurrences missed *while paused is off*; once
|
||||
// paused, none of those correspond to anything the user would expect
|
||||
// replayed on resume, so drop it rather than letting a stale counter fire
|
||||
// a deferred run for an occurrence from before the pause.
|
||||
runtime.PendingRuns = 0
|
||||
}
|
||||
s.refreshNextRunFromLocked(job, runtime, now)
|
||||
}
|
||||
err := s.store.SaveConfig()
|
||||
if err == nil {
|
||||
err = s.store.SaveJobs(s.jobs)
|
||||
}
|
||||
save := s.deferSaveLocked(s.store.PrepareSaveConfig())
|
||||
s.mu.Unlock()
|
||||
|
||||
if err != nil {
|
||||
if err := save(); err != nil {
|
||||
return err
|
||||
}
|
||||
state, detail := "Resumed", "All job execution resumed"
|
||||
@@ -211,9 +227,9 @@ func (s *Service) SetJobListView(view domain.JobListView) error {
|
||||
return nil
|
||||
}
|
||||
s.store.Config.JobListView = view
|
||||
err := s.store.SaveConfig()
|
||||
save := s.deferSaveLocked(s.store.PrepareSaveConfig())
|
||||
s.mu.Unlock()
|
||||
return err
|
||||
return save()
|
||||
}
|
||||
|
||||
// ShouldNotifyOnFailure reports whether the user has enabled desktop
|
||||
@@ -243,54 +259,75 @@ func (s *Service) UpdateSettings(config domain.Config) error {
|
||||
config.JobsFile = strings.TrimSpace(config.JobsFile)
|
||||
|
||||
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
|
||||
if switching && s.anyRunningLocked() {
|
||||
s.mu.Unlock()
|
||||
running := s.anyRunningLocked()
|
||||
s.mu.Unlock()
|
||||
|
||||
if switching && 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
|
||||
// parsed leaves both the config and the current jobs untouched.
|
||||
// Read the new file, and reconstruct its jobs' statistics from the logs the
|
||||
// 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 seeds map[int]runner.SeededStats
|
||||
if switching {
|
||||
jobs, found, err := storage.LoadJobsFile(jobsPath)
|
||||
if err != nil {
|
||||
s.mu.Unlock()
|
||||
return fmt.Errorf("read jobs file %s: %w", jobsPath, err)
|
||||
}
|
||||
if found {
|
||||
adopted = jobs
|
||||
seeds = runner.SeedStats(storage.ResolveConfiguredPath(appDir, config.LogsDir), jobs, config.MaxLogFiles)
|
||||
}
|
||||
}
|
||||
|
||||
s.store.Config = config
|
||||
if err := s.store.SaveConfig(); err != nil {
|
||||
s.mu.Lock()
|
||||
// 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()
|
||||
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 {
|
||||
s.adoptJobsLocked(adopted)
|
||||
s.applySeededStatsLocked(seeds)
|
||||
}
|
||||
// SaveConfig re-resolved the paths from the new config, so SaveJobs writes to
|
||||
// the (possibly new) jobs file and cleanup targets the new logs dir. Adopted
|
||||
// jobs are written back too, which persists the IDs and defaults that
|
||||
// normalization filled in, exactly as loading them at startup would.
|
||||
if err := s.store.SaveJobs(s.jobs); err != nil {
|
||||
s.mu.Unlock()
|
||||
return err
|
||||
}
|
||||
// PrepareSaveConfig re-resolved the paths from the new config, so the jobs
|
||||
// write targets the (possibly new) jobs file and cleanup targets the new logs
|
||||
// dir. Adopted jobs are written back too, which persists the IDs and defaults
|
||||
// that normalization filled in, exactly as loading them at startup would. The
|
||||
// jobs write is skipped when the config write fails, because both writes run
|
||||
// in the order prepared and stop at the first error.
|
||||
save := s.deferSaveLocked(saveConfig, s.store.PrepareSaveJobs(s.jobs))
|
||||
loaded := len(s.jobs)
|
||||
logsDir := s.store.Paths.LogsDir
|
||||
maxFiles := s.store.Config.MaxLogFiles
|
||||
maxAge := s.store.Config.MaxLogAgeDays
|
||||
s.mu.Unlock()
|
||||
|
||||
saveErr := save()
|
||||
if adopted != nil {
|
||||
// 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(JobChanged{})
|
||||
}
|
||||
if saveErr != nil {
|
||||
return saveErr
|
||||
}
|
||||
return runner.CleanupLogs(logsDir, maxFiles, maxAge)
|
||||
}
|
||||
|
||||
@@ -466,11 +503,13 @@ func validateConfig(config domain.Config) error {
|
||||
if strings.TrimSpace(config.LogsDir) == "" {
|
||||
return errors.New("logs directory is required")
|
||||
}
|
||||
if config.MaxLogFiles <= 0 {
|
||||
return errors.New("max log files must be a positive number")
|
||||
// 0 means "keep everything" (see runner.CleanupLogs); only a negative count
|
||||
// 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 {
|
||||
return errors.New("max log age days must be a positive number")
|
||||
if config.MaxLogAgeDays < 0 {
|
||||
return errors.New("max log age days must be zero (unlimited) or a positive number")
|
||||
}
|
||||
if config.ExecutionMode != domain.ExecutionModeParallel && config.ExecutionMode != domain.ExecutionModeSequential {
|
||||
return errors.New("execution mode must be 'parallel' or 'sequential'")
|
||||
|
||||
+136
-8
@@ -3,8 +3,10 @@ package app
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"sync"
|
||||
"sync/atomic"
|
||||
"testing"
|
||||
"time"
|
||||
@@ -217,6 +219,23 @@ func TestSetEnabledToggles(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
// TestSetEnabledClearsPendingRuns verifies that disabling a job drops any
|
||||
// "queue" overlap backlog it was carrying, so re-enabling it later does not
|
||||
// replay a deferred run for an occurrence that fired before the disable.
|
||||
func TestSetEnabledClearsPendingRuns(t *testing.T) {
|
||||
svc := newTempService(t, []domain.Job{{ID: 1, Name: "A", Schedule: "@every 1m", Command: "echo", Enabled: true}})
|
||||
svc.mu.Lock()
|
||||
svc.runtimes[1].PendingRuns = 2
|
||||
svc.mu.Unlock()
|
||||
|
||||
if err := svc.SetEnabled(1, false); err != nil {
|
||||
t.Fatalf("SetEnabled false: %v", err)
|
||||
}
|
||||
if rt := svc.Runtime(1); rt.PendingRuns != 0 {
|
||||
t.Errorf("PendingRuns after disable = %d, want 0", rt.PendingRuns)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSetGlobalPauseUpdatesRuntimesAndEmits(t *testing.T) {
|
||||
svc := newTempService(t, []domain.Job{
|
||||
{ID: 1, Name: "On", Schedule: "@every 1m", Command: "echo", Enabled: true},
|
||||
@@ -503,9 +522,9 @@ func TestUpdateSettingsPersistsAndValidates(t *testing.T) {
|
||||
svc := newTempService(t, nil)
|
||||
|
||||
bad := svc.store.Config
|
||||
bad.MaxLogFiles = 0
|
||||
bad.MaxLogFiles = -1
|
||||
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
|
||||
@@ -514,8 +533,21 @@ func TestUpdateSettingsPersistsAndValidates(t *testing.T) {
|
||||
if err := svc.UpdateSettings(good); err != nil {
|
||||
t.Fatalf("UpdateSettings: %v", err)
|
||||
}
|
||||
if svc.Store().Config.MaxLogAgeDays != 7 || svc.Store().Config.NotifyOnFailure {
|
||||
t.Errorf("config not applied: %+v", svc.Store().Config)
|
||||
if svc.store.Config.MaxLogAgeDays != 7 || svc.store.Config.NotifyOnFailure {
|
||||
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)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -530,8 +562,8 @@ func TestUpdateSettingsRejectsInvalidConfigs(t *testing.T) {
|
||||
{"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) }},
|
||||
{"missing logs dir", func(c *domain.Config) { c.LogsDir = "" }},
|
||||
{"non-positive max files", func(c *domain.Config) { c.MaxLogFiles = 0 }},
|
||||
{"non-positive max age", func(c *domain.Config) { c.MaxLogAgeDays = -1 }},
|
||||
{"negative max files", func(c *domain.Config) { c.MaxLogFiles = -1 }},
|
||||
{"negative max age", func(c *domain.Config) { c.MaxLogAgeDays = -1 }},
|
||||
{"negative default timeout", func(c *domain.Config) { c.DefaultTimeoutSeconds = -1 }},
|
||||
}
|
||||
for _, tc := range tests {
|
||||
@@ -693,12 +725,12 @@ func TestUpdateSettingsRefusesJobsFileSwitchWhileRunning(t *testing.T) {
|
||||
if err := svc.UpdateSettings(config); err == nil {
|
||||
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")
|
||||
}
|
||||
|
||||
// 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
|
||||
if err := svc.UpdateSettings(unrelated); err != nil {
|
||||
t.Errorf("unrelated setting should still save during a run: %v", err)
|
||||
@@ -708,6 +740,102 @@ func TestUpdateSettingsRefusesJobsFileSwitchWhileRunning(t *testing.T) {
|
||||
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) {
|
||||
runtime := &domain.JobRuntime{}
|
||||
for i := 0; i < maxJobLogs+10; i++ {
|
||||
|
||||
+12
-5
@@ -1,18 +1,25 @@
|
||||
package app
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
|
||||
"gitea.mixdep.ru/mix/gosentry/src/platform/desktop"
|
||||
)
|
||||
|
||||
// InstallDesktopIcon installs the application's .desktop file and icon on
|
||||
// Linux (no-op on other platforms). The resulting icon path is stored in
|
||||
// store.Paths.DesktopIcon so ApplyAutostart can reference it.
|
||||
// store.Paths.DesktopIcon so ApplyAutostart can reference it. A failure is
|
||||
// reported through ErrorOccurred rather than discarded, so the visible symptom
|
||||
// (a generic dock icon) has an explanation in History instead of none.
|
||||
func (s *Service) InstallDesktopIcon(appID string, iconBytes []byte) {
|
||||
if iconPath, err := desktop.InstallDesktopIntegration(appID, s.store.Paths.ExecutablePath, iconBytes); err == nil {
|
||||
s.mu.Lock()
|
||||
s.store.Paths.DesktopIcon = iconPath
|
||||
s.mu.Unlock()
|
||||
iconPath, err := desktop.InstallDesktopIntegration(appID, s.store.Paths.ExecutablePath, iconBytes)
|
||||
if err != nil {
|
||||
s.emit(ErrorOccurred{Err: fmt.Errorf("install desktop icon: %w", err)})
|
||||
return
|
||||
}
|
||||
s.mu.Lock()
|
||||
s.store.Paths.DesktopIcon = iconPath
|
||||
s.mu.Unlock()
|
||||
}
|
||||
|
||||
// AutostartStatus reports whether the platform autostart entry matches the
|
||||
|
||||
+31
-43
@@ -11,6 +11,13 @@ import (
|
||||
"gitea.mixdep.ru/mix/gosentry/src/runner"
|
||||
)
|
||||
|
||||
// maxPendingRuns bounds how many missed occurrences the "queue" overlap policy
|
||||
// will defer for one job. Without a ceiling a job whose runs take longer than
|
||||
// its interval would queue one more occurrence on every tick forever, so once
|
||||
// the cap is reached further overlaps are dropped exactly as the "skip" policy
|
||||
// would drop them, until the backlog drains below the cap again.
|
||||
const maxPendingRuns = 10
|
||||
|
||||
// RunNow starts a manual run of a job. Global pause stops only the scheduler's
|
||||
// automatic runs (see RunDue), so a manual "Run now" is allowed even while
|
||||
// paused — it is the user's explicit, one-off action. It will not start a job
|
||||
@@ -36,14 +43,12 @@ func (s *Service) RunNow(id int) error {
|
||||
s.mu.Unlock()
|
||||
return errors.New("another job is already running (sequential mode)")
|
||||
}
|
||||
err := s.startRunLocked(job, runtime, "Manual", time.Now())
|
||||
s.startRunLocked(job, runtime, "Manual", time.Now())
|
||||
s.mu.Unlock()
|
||||
|
||||
if err == nil {
|
||||
// Reflect the "Running" transition; the run's completion emits again later.
|
||||
s.emit(JobChanged{JobID: id})
|
||||
}
|
||||
return err
|
||||
// Reflect the "Running" transition; the run's completion emits again later.
|
||||
s.emit(JobChanged{JobID: id})
|
||||
return nil
|
||||
}
|
||||
|
||||
// RunDue is the scheduler's per-tick entry point: it starts whatever is due at
|
||||
@@ -63,7 +68,6 @@ func (s *Service) RunNow(id int) error {
|
||||
func (s *Service) RunDue(now time.Time) {
|
||||
s.mu.Lock()
|
||||
var started []int
|
||||
var startErr error
|
||||
if !s.paused {
|
||||
sequential := s.store.Config.ExecutionMode == domain.ExecutionModeSequential
|
||||
running := s.anyRunningLocked()
|
||||
@@ -77,7 +81,7 @@ func (s *Service) RunDue(now time.Time) {
|
||||
// The job came due again while its own run is still in flight.
|
||||
// Apply the effective overlap policy and step past this
|
||||
// occurrence.
|
||||
if s.effectiveOverlapPolicy(job) == domain.OverlapPolicyQueue {
|
||||
if s.effectiveOverlapPolicy(job) == domain.OverlapPolicyQueue && runtime.PendingRuns < maxPendingRuns {
|
||||
runtime.PendingRuns++
|
||||
}
|
||||
s.advanceNextDueLocked(job, runtime, now)
|
||||
@@ -88,19 +92,13 @@ func (s *Service) RunDue(now time.Time) {
|
||||
// tick once the in-flight run has finished.
|
||||
continue
|
||||
}
|
||||
if err := s.startRunLocked(job, runtime, "Schedule", now); err != nil {
|
||||
startErr = err
|
||||
continue
|
||||
}
|
||||
s.startRunLocked(job, runtime, "Schedule", now)
|
||||
started = append(started, job.ID)
|
||||
running = true
|
||||
}
|
||||
}
|
||||
s.mu.Unlock()
|
||||
|
||||
if startErr != nil {
|
||||
s.emit(ErrorOccurred{Err: fmt.Errorf("save jobs before scheduled run: %w", startErr)})
|
||||
}
|
||||
for _, id := range started {
|
||||
s.emit(JobChanged{JobID: id})
|
||||
}
|
||||
@@ -116,29 +114,19 @@ type runEnv struct {
|
||||
}
|
||||
|
||||
// startRunLocked transitions a job to "Running", advances its NextDue to the next
|
||||
// scheduled occurrence, persists that, and launches the run on a background
|
||||
// goroutine. Advancing (rather than zeroing) NextDue keeps the schedule marching
|
||||
// while the run is in flight, which is what lets RunDue notice a fresh occurrence
|
||||
// firing during a long run and apply the overlap policy. The caller must hold mu.
|
||||
// now is the reference time for next-due advancement and the running placeholder.
|
||||
func (s *Service) startRunLocked(job *domain.Job, runtime *domain.JobRuntime, trigger string, now time.Time) error {
|
||||
// scheduled occurrence, and launches the run on a background goroutine. Neither
|
||||
// step touches a durable field — both live on JobRuntime, which is never
|
||||
// persisted — so there is nothing to save here. Advancing (rather than zeroing)
|
||||
// NextDue keeps the schedule marching while the run is in flight, which is what
|
||||
// lets RunDue notice a fresh occurrence firing during a long run and apply the
|
||||
// overlap policy. The caller must hold mu. now is the reference time for
|
||||
// next-due advancement and the running placeholder.
|
||||
func (s *Service) startRunLocked(job *domain.Job, runtime *domain.JobRuntime, trigger string, now time.Time) {
|
||||
jobCopy := *job
|
||||
prevState := runtime.LastState
|
||||
prevNextRun := runtime.NextRun
|
||||
prevOutput := runtime.Output
|
||||
prevNextDue := runtime.NextDue
|
||||
|
||||
runtime.LastState = "Running"
|
||||
runtime.NextRun = "Running"
|
||||
runtime.Output = runningOutput(jobCopy, trigger, now)
|
||||
s.advanceNextDueLocked(job, runtime, now)
|
||||
if err := s.store.SaveJobs(s.jobs); err != nil {
|
||||
runtime.LastState = prevState
|
||||
runtime.NextRun = prevNextRun
|
||||
runtime.Output = prevOutput
|
||||
runtime.NextDue = prevNextDue
|
||||
return err
|
||||
}
|
||||
env := runEnv{
|
||||
logsDir: s.store.Paths.LogsDir,
|
||||
maxFiles: s.store.Config.MaxLogFiles,
|
||||
@@ -148,7 +136,6 @@ func (s *Service) startRunLocked(job *domain.Job, runtime *domain.JobRuntime, tr
|
||||
// Capture ctx under the lock so a concurrent Start/Stop cannot swap it out
|
||||
// from under the goroutine after we release mu.
|
||||
go s.executeRun(s.ctx, jobCopy, trigger, env)
|
||||
return nil
|
||||
}
|
||||
|
||||
// executeRun runs the job off the lock, then records the result back through the
|
||||
@@ -160,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)
|
||||
|
||||
s.mu.Lock()
|
||||
var cleanupErr, saveErr error
|
||||
var rerunStarted bool
|
||||
if current := s.findByIDLocked(jobCopy.ID); current != nil {
|
||||
runtime := s.runtimeForLocked(current)
|
||||
@@ -174,25 +160,26 @@ func (s *Service) executeRun(ctx context.Context, jobCopy domain.Job, trigger st
|
||||
runtime.PendingRuns--
|
||||
// A scheduled occurrence fired while this run was active under the
|
||||
// "queue" policy; start one deferred run now.
|
||||
saveErr = s.startRunLocked(current, runtime, "Schedule", time.Now())
|
||||
rerunStarted = saveErr == nil
|
||||
s.startRunLocked(current, runtime, "Schedule", time.Now())
|
||||
rerunStarted = true
|
||||
} else {
|
||||
s.refreshNextRunLocked(current, runtime)
|
||||
saveErr = s.store.SaveJobs(s.jobs)
|
||||
}
|
||||
cleanupErr = runner.CleanupLogs(env.logsDir, env.maxFiles, env.maxAge)
|
||||
}
|
||||
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 {
|
||||
s.emit(ErrorOccurred{Err: fmt.Errorf("write run log for %q: %w", jobCopy.Name, logErr)})
|
||||
}
|
||||
if cleanupErr != nil {
|
||||
s.emit(ErrorOccurred{Err: fmt.Errorf("log cleanup after run %q: %w", jobCopy.Name, cleanupErr)})
|
||||
}
|
||||
if saveErr != nil {
|
||||
s.emit(ErrorOccurred{Err: fmt.Errorf("save jobs after run %q: %w", jobCopy.Name, saveErr)})
|
||||
}
|
||||
s.emit(RunRecorded{Record: record})
|
||||
if !rerunStarted {
|
||||
s.emit(JobChanged{JobID: jobCopy.ID})
|
||||
@@ -266,7 +253,8 @@ func updateStats(rt *domain.JobRuntime, r domain.RunRecord) {
|
||||
rt.MaxDurationMS = r.DurationMS
|
||||
}
|
||||
rt.TimedRunCount++
|
||||
rt.AvgDurationMS = (rt.AvgDurationMS*int64(rt.TimedRunCount-1) + r.DurationMS) / int64(rt.TimedRunCount)
|
||||
rt.DurationSumMS += r.DurationMS
|
||||
rt.AvgDurationMS = rt.DurationSumMS / int64(rt.TimedRunCount)
|
||||
}
|
||||
|
||||
// runningOutput is the placeholder output shown while a job is running, before
|
||||
|
||||
+56
-33
@@ -2,7 +2,6 @@ package app
|
||||
|
||||
import (
|
||||
"context"
|
||||
"os"
|
||||
"sync/atomic"
|
||||
"testing"
|
||||
"time"
|
||||
@@ -104,6 +103,15 @@ func TestUpdateStats(t *testing.T) {
|
||||
if rt.AvgDurationMS != 233 {
|
||||
t.Errorf("after run 3: avg=%d, want 233", rt.AvgDurationMS)
|
||||
}
|
||||
// AvgDurationMS must always be exactly DurationSumMS/TimedRunCount — a stored
|
||||
// sum divided once, not an incremental mean that truncates on every step and
|
||||
// compounds error over a long-running job.
|
||||
if rt.DurationSumMS != 700 {
|
||||
t.Errorf("DurationSumMS = %d, want 700", rt.DurationSumMS)
|
||||
}
|
||||
if want := rt.DurationSumMS / int64(rt.TimedRunCount); rt.AvgDurationMS != want {
|
||||
t.Errorf("AvgDurationMS = %d, want DurationSumMS/TimedRunCount = %d", rt.AvgDurationMS, want)
|
||||
}
|
||||
}
|
||||
|
||||
func TestUpdateStatsSkipsZeroDuration(t *testing.T) {
|
||||
@@ -359,6 +367,42 @@ func TestRunDueQueueDrainsMultipleOverlaps(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
// TestRunDueQueueCapsPendingRuns verifies that a job whose runs never keep up
|
||||
// with its schedule stops accumulating PendingRuns at maxPendingRuns instead of
|
||||
// growing without bound.
|
||||
func TestRunDueQueueCapsPendingRuns(t *testing.T) {
|
||||
svc := newQueueService(t, domain.ExecutionModeParallel, domain.OverlapPolicyQueue, []domain.Job{
|
||||
{ID: 1, Name: "A", Schedule: "@every 1h", Command: "echo", Enabled: true},
|
||||
})
|
||||
|
||||
release := make(chan struct{})
|
||||
svc.runJob = func(_ context.Context, job *domain.Job, _ string, _ string, _ time.Duration) (domain.RunRecord, error) {
|
||||
<-release
|
||||
return domain.RunRecord{Time: "t", JobID: job.ID, JobName: job.Name, State: "Success"}, nil
|
||||
}
|
||||
done := completions(svc)
|
||||
t.Cleanup(func() {
|
||||
close(release)
|
||||
waitRecord(t, done)
|
||||
})
|
||||
|
||||
primeDue(t, svc, 1)
|
||||
svc.RunDue(time.Now())
|
||||
|
||||
// Far more due ticks than the cap while the first run stays in flight.
|
||||
for range maxPendingRuns + 5 {
|
||||
primeDue(t, svc, 1)
|
||||
svc.RunDue(time.Now())
|
||||
}
|
||||
|
||||
svc.mu.Lock()
|
||||
pending := svc.runtimes[1].PendingRuns
|
||||
svc.mu.Unlock()
|
||||
if pending != maxPendingRuns {
|
||||
t.Fatalf("PendingRuns = %d, want capped at %d", pending, maxPendingRuns)
|
||||
}
|
||||
}
|
||||
|
||||
// TestRunDuePerJobQueueOverridesGlobalSkip verifies that a job carrying its own
|
||||
// "queue" policy queues a re-run even though the global default is "skip": the
|
||||
// effective policy is resolved per job, so the job-level value wins.
|
||||
@@ -493,37 +537,9 @@ func TestRunNowSequentialGuard(t *testing.T) {
|
||||
waitRecord(t, done)
|
||||
}
|
||||
|
||||
// TestStartRunLockedRollbackOnSaveFailure is a regression test for CODE_REVIEW
|
||||
// finding #2: a run must not start when persisting the Running state fails.
|
||||
func TestStartRunLockedRollbackOnSaveFailure(t *testing.T) {
|
||||
svc := newTempService(t, []domain.Job{{ID: 1, Name: "A", Schedule: "@every 1h", Command: "echo", Enabled: true}})
|
||||
if err := svc.store.SaveJobs(svc.jobs); err != nil {
|
||||
t.Fatalf("seed jobs.json: %v", err)
|
||||
}
|
||||
if err := os.Chmod(svc.store.Paths.JobsPath, 0o444); err != nil {
|
||||
t.Fatalf("chmod jobs.json: %v", err)
|
||||
}
|
||||
t.Cleanup(func() { _ = os.Chmod(svc.store.Paths.JobsPath, 0o644) })
|
||||
|
||||
var started int32
|
||||
svc.runJob = func(_ context.Context, job *domain.Job, _ string, _ string, _ time.Duration) (domain.RunRecord, error) {
|
||||
atomic.AddInt32(&started, 1)
|
||||
return domain.RunRecord{Time: "t", JobID: job.ID, JobName: job.Name, State: "OK"}, nil
|
||||
}
|
||||
|
||||
if err := svc.RunNow(1); err == nil {
|
||||
t.Fatal("expected RunNow to fail when jobs.json is not writable")
|
||||
}
|
||||
if atomic.LoadInt32(&started) != 0 {
|
||||
t.Error("run goroutine must not start when SaveJobs fails")
|
||||
}
|
||||
if rt := svc.Runtime(1); rt == nil || rt.LastState == "Running" {
|
||||
t.Errorf("runtime should roll back from Running, got %+v", rt)
|
||||
}
|
||||
}
|
||||
|
||||
// TestRunDueQueueDrainSkippedWhenPaused verifies that queued overlap runs are not
|
||||
// drained while the scheduler is globally paused.
|
||||
// drained while the scheduler is globally paused, and that pausing clears the
|
||||
// backlog rather than leaving it to fire a stale deferred run on resume.
|
||||
func TestRunDueQueueDrainSkippedWhenPaused(t *testing.T) {
|
||||
svc := newQueueService(t, domain.ExecutionModeParallel, domain.OverlapPolicyQueue, []domain.Job{
|
||||
{ID: 1, Name: "A", Schedule: "@every 1h", Command: "echo", Enabled: true},
|
||||
@@ -561,6 +577,13 @@ func TestRunDueQueueDrainSkippedWhenPaused(t *testing.T) {
|
||||
t.Fatalf("SetGlobalPause: %v", err)
|
||||
}
|
||||
|
||||
svc.mu.Lock()
|
||||
pending = svc.runtimes[1].PendingRuns
|
||||
svc.mu.Unlock()
|
||||
if pending != 0 {
|
||||
t.Errorf("pausing must clear a queued backlog, PendingRuns = %d, want 0", pending)
|
||||
}
|
||||
|
||||
close(release)
|
||||
waitRecord(t, done)
|
||||
expectNoEntry(t, entered)
|
||||
@@ -568,8 +591,8 @@ func TestRunDueQueueDrainSkippedWhenPaused(t *testing.T) {
|
||||
svc.mu.Lock()
|
||||
pending = svc.runtimes[1].PendingRuns
|
||||
svc.mu.Unlock()
|
||||
if pending != 1 {
|
||||
t.Errorf("paused scheduler must not drain queue, PendingRuns = %d, want 1", pending)
|
||||
if pending != 0 {
|
||||
t.Errorf("paused scheduler must not drain queue, PendingRuns = %d, want 0", pending)
|
||||
}
|
||||
if got := atomic.LoadInt32(&calls); got != 1 {
|
||||
t.Errorf("runner called %d time(s), want 1", got)
|
||||
|
||||
+70
-13
@@ -26,7 +26,10 @@ import (
|
||||
// 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) 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 {
|
||||
mu sync.Mutex
|
||||
store *storage.Store
|
||||
@@ -56,6 +59,13 @@ type Service struct {
|
||||
// do not exercise autostart; Open() wires it via autostart.New().
|
||||
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
|
||||
// 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.
|
||||
@@ -63,6 +73,26 @@ type Service struct {
|
||||
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
|
||||
// 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
|
||||
@@ -77,18 +107,19 @@ func NewService(store *storage.Store, jobs []domain.Job) *Service {
|
||||
// No lock is needed here: construction is single-threaded, before Start
|
||||
// launches the timing loop.
|
||||
s.adoptJobsLocked(jobs)
|
||||
s.applySeededStatsLocked(runner.SeedStats(store.Paths.LogsDir, s.jobs, store.Config.MaxLogFiles))
|
||||
return s
|
||||
}
|
||||
|
||||
// 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
|
||||
// next-run — so the Service is ready to schedule the moment it exists, mirroring
|
||||
// the old scheduler's reset-on-construction — and the statistics seeded from
|
||||
// existing log files, so the details panel shows accumulated run history
|
||||
// immediately rather than only runs since this process started.
|
||||
// derived from it: the runtime map, the parsed-schedule cache, and each job's
|
||||
// first next-run — so the Service is ready to schedule the moment it exists,
|
||||
// mirroring the old scheduler's reset-on-construction.
|
||||
//
|
||||
// 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) {
|
||||
s.jobs = jobs
|
||||
s.runtimes = domain.NewRuntimes(jobs)
|
||||
@@ -100,7 +131,16 @@ func (s *Service) adoptJobsLocked(jobs []domain.Job) {
|
||||
s.parseScheduleLocked(job)
|
||||
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]
|
||||
if runtime == nil {
|
||||
continue
|
||||
@@ -111,6 +151,7 @@ func (s *Service) adoptJobsLocked(jobs []domain.Job) {
|
||||
runtime.AvgDurationMS = seed.AvgDurationMS
|
||||
runtime.MaxDurationMS = seed.MaxDurationMS
|
||||
runtime.TimedRunCount = seed.TimedRunCount
|
||||
runtime.DurationSumMS = seed.DurationSumMS
|
||||
}
|
||||
}
|
||||
|
||||
@@ -164,11 +205,27 @@ func Open() (*Service, error) {
|
||||
return svc, nil
|
||||
}
|
||||
|
||||
// Store returns the underlying store. It is exposed so callers that still need
|
||||
// resolved paths and config (the GUI, during the transition) can reach them;
|
||||
// later phases narrow this surface.
|
||||
func (s *Service) Store() *storage.Store {
|
||||
return s.store
|
||||
// Config returns a copy of the current application configuration, safe to
|
||||
// call from any goroutine. UpdateSettings, SetGlobalPause, and SetJobListView
|
||||
// are the only writers and all mutate store.Config under mu; copying under the
|
||||
// same lock is what keeps a UI read from racing them, instead of holding onto
|
||||
// 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
|
||||
|
||||
+1
-1
@@ -3,4 +3,4 @@ package app
|
||||
// Version is the application version shown in the GUI and used by build
|
||||
// scripts in artifact names. It is a var rather than a const so release builds
|
||||
// can override it with Go ldflags when CI tags a build.
|
||||
var Version = "1.0.1"
|
||||
var Version = "1.0.3"
|
||||
|
||||
@@ -26,3 +26,24 @@ func TestResolveStartHidden(t *testing.T) {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// TestJobListViewIsCompact pins the normalization rule: only the exact
|
||||
// "compact" value selects the one-line rows, so empty and unrecognised values
|
||||
// (including configs written before the field existed) keep the detailed look.
|
||||
func TestJobListViewIsCompact(t *testing.T) {
|
||||
cases := []struct {
|
||||
view JobListView
|
||||
want bool
|
||||
}{
|
||||
{JobListViewCompact, true},
|
||||
{JobListViewDetailed, false},
|
||||
{"", false},
|
||||
{"Compact", false},
|
||||
{"tiny", false},
|
||||
}
|
||||
for _, tc := range cases {
|
||||
if got := tc.view.IsCompact(); got != tc.want {
|
||||
t.Errorf("JobListView(%q).IsCompact() = %v, want %v", tc.view, got, tc.want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -4,13 +4,13 @@ package domain
|
||||
// output is also written to a log file; the in-memory Output copy exists so the
|
||||
// latest run can be displayed without reopening the log on every repaint.
|
||||
type RunRecord struct {
|
||||
Time string `yaml:"time"`
|
||||
JobID int `yaml:"job_id"`
|
||||
JobName string `yaml:"job_name"`
|
||||
Trigger string `yaml:"trigger,omitempty"`
|
||||
State string `yaml:"state"`
|
||||
Detail string `yaml:"detail"`
|
||||
LogFile string `yaml:"log_file,omitempty"`
|
||||
Output string `yaml:"output,omitempty"`
|
||||
DurationMS int64 `yaml:"duration_ms,omitempty"`
|
||||
Time string
|
||||
JobID int
|
||||
JobName string
|
||||
Trigger string
|
||||
State string
|
||||
Detail string
|
||||
LogFile string
|
||||
Output string
|
||||
DurationMS int64
|
||||
}
|
||||
|
||||
@@ -36,6 +36,14 @@ type JobRuntime struct {
|
||||
// launches that round to 0) increment RunCount but not this. StartOnly runs
|
||||
// otherwise contribute their launch latency.
|
||||
TimedRunCount int
|
||||
// DurationSumMS is the running total of every timed run's duration.
|
||||
// AvgDurationMS is always DurationSumMS/TimedRunCount, computed fresh on each
|
||||
// update rather than folded incrementally — an incremental integer mean
|
||||
// truncates on every step, and the error compounds over the life of a job
|
||||
// that keeps running. A stored sum divided once per update matches the exact
|
||||
// sum/count average runner.aggregateLogStats computes when seeding from logs,
|
||||
// so the two no longer disagree about the same run history.
|
||||
DurationSumMS int64
|
||||
}
|
||||
|
||||
// NewRuntime builds the initial runtime state for a freshly loaded or created
|
||||
|
||||
@@ -18,16 +18,16 @@ type linuxManager struct{}
|
||||
func New() Manager { return linuxManager{} }
|
||||
|
||||
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) {
|
||||
return AutostartStatus(expectedEnabled, startInTray, executablePath)
|
||||
return autostartStatus(expectedEnabled, startInTray, executablePath)
|
||||
}
|
||||
|
||||
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()
|
||||
if err != nil {
|
||||
return err
|
||||
@@ -58,7 +58,7 @@ X-GNOME-Autostart-enabled=true
|
||||
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()
|
||||
if err != nil {
|
||||
return false, "Cannot resolve XDG autostart directory"
|
||||
|
||||
@@ -14,7 +14,7 @@ func TestLinuxAutostartStartsInTray(t *testing.T) {
|
||||
t.Setenv("XDG_CONFIG_HOME", t.TempDir())
|
||||
|
||||
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)
|
||||
}
|
||||
|
||||
@@ -37,7 +37,7 @@ func TestLinuxAutostartWithoutTrayFlag(t *testing.T) {
|
||||
t.Setenv("XDG_CONFIG_HOME", t.TempDir())
|
||||
|
||||
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)
|
||||
}
|
||||
|
||||
|
||||
@@ -10,21 +10,21 @@ type otherManager struct{}
|
||||
func New() Manager { return otherManager{} }
|
||||
|
||||
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) {
|
||||
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 {
|
||||
return nil
|
||||
}
|
||||
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 {
|
||||
return true, "Autostart is off"
|
||||
}
|
||||
|
||||
@@ -17,17 +17,17 @@ type windowsManager struct{}
|
||||
func New() Manager { return windowsManager{} }
|
||||
|
||||
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) {
|
||||
return AutostartStatus(expectedEnabled, startInTray, executablePath)
|
||||
return autostartStatus(expectedEnabled, startInTray, executablePath)
|
||||
}
|
||||
|
||||
const autostartName = "GoSentry"
|
||||
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
|
||||
// brittle once paths with spaces and the "--start-in-tray" argument entered
|
||||
// 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)
|
||||
}
|
||||
|
||||
func AutostartStatus(expectedEnabled bool, startInTray bool, executablePath string) (bool, string) {
|
||||
func autostartStatus(expectedEnabled bool, startInTray bool, executablePath string) (bool, string) {
|
||||
shortcutPath, err := startupShortcutPath()
|
||||
if err != nil {
|
||||
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,
|
||||
// [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
|
||||
// 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.
|
||||
// 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)`
|
||||
|
||||
@@ -134,7 +134,7 @@ func TestAutostartStatusRequiresMatchingTrayFlag(t *testing.T) {
|
||||
t.Fatalf("create shortcut: %v", err)
|
||||
}
|
||||
|
||||
ok, message := AutostartStatus(true, false, targetPath)
|
||||
ok, message := autostartStatus(true, false, targetPath)
|
||||
if ok {
|
||||
t.Fatalf("expected problem when tray flag mismatches, got OK: %s", message)
|
||||
}
|
||||
|
||||
@@ -9,6 +9,11 @@ import (
|
||||
"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 {
|
||||
entries, err := os.ReadDir(logsDir)
|
||||
if err != nil {
|
||||
|
||||
@@ -64,5 +64,3 @@ func LogArguments(arguments string) string {
|
||||
}
|
||||
return strings.ReplaceAll(strings.TrimSpace(arguments), "\r\n", "\n")
|
||||
}
|
||||
|
||||
func logArguments(arguments string) string { return LogArguments(arguments) }
|
||||
|
||||
@@ -6,6 +6,7 @@ import (
|
||||
"strings"
|
||||
"syscall"
|
||||
"unicode"
|
||||
"unicode/utf8"
|
||||
)
|
||||
|
||||
func shellCommand(ctx context.Context, command string) *exec.Cmd {
|
||||
@@ -32,19 +33,45 @@ func quoteLeadingWindowsProgramPath(command string) string {
|
||||
}
|
||||
|
||||
lower := strings.ToLower(trimmed)
|
||||
pathEnd := -1
|
||||
for _, extension := range []string{".exe", ".cmd", ".bat", ".com"} {
|
||||
index := strings.Index(lower, extension)
|
||||
if index < 0 {
|
||||
continue
|
||||
end := earliestBoundedExtensionEnd(lower, extension)
|
||||
if end >= 0 && (pathEnd < 0 || end < pathEnd) {
|
||||
pathEnd = end
|
||||
}
|
||||
pathEnd := index + len(extension)
|
||||
programPath := trimmed[:pathEnd]
|
||||
if !strings.ContainsFunc(programPath, unicode.IsSpace) {
|
||||
return command
|
||||
}
|
||||
return leadingWhitespace + `"` + programPath + `"` + trimmed[pathEnd:]
|
||||
}
|
||||
return command
|
||||
if pathEnd < 0 {
|
||||
return command
|
||||
}
|
||||
programPath := trimmed[:pathEnd]
|
||||
if !strings.ContainsFunc(programPath, unicode.IsSpace) {
|
||||
return command
|
||||
}
|
||||
return leadingWhitespace + `"` + programPath + `"` + trimmed[pathEnd:]
|
||||
}
|
||||
|
||||
// earliestBoundedExtensionEnd returns the offset just past the first
|
||||
// occurrence of extension in s that ends at a token boundary (end of string
|
||||
// or whitespace), or -1 if none does. Scanning left to right and rejecting
|
||||
// unbounded matches keeps a trailing "...\App.exe" inside an argument, such
|
||||
// as "run.bat C:\tool.exe", from being mistaken for the program path.
|
||||
func earliestBoundedExtensionEnd(s, extension string) int {
|
||||
offset := 0
|
||||
for {
|
||||
index := strings.Index(s[offset:], extension)
|
||||
if index < 0 {
|
||||
return -1
|
||||
}
|
||||
end := offset + index + len(extension)
|
||||
if end == len(s) {
|
||||
return end
|
||||
}
|
||||
r, _ := utf8.DecodeRuneInString(s[end:])
|
||||
if unicode.IsSpace(r) {
|
||||
return end
|
||||
}
|
||||
offset += index + 1
|
||||
}
|
||||
}
|
||||
|
||||
func startsWithWindowsRootedPath(command string) bool {
|
||||
|
||||
+61
-3
@@ -23,15 +23,73 @@ func writeRunLog(logsDir string, job domain.Job, trigger string, state string, d
|
||||
// by run time. The job name is included for human scanning, but sanitized to
|
||||
// avoid characters that are invalid on Windows or awkward on shells.
|
||||
fileName := started.Format("20060102-150405") + "_" + sanitizeFileName(job.Name) + ".log"
|
||||
path := filepath.Join(logsDir, fileName)
|
||||
path := uniqueLogPath(logsDir, fileName)
|
||||
content := fmt.Sprintf("time: %s\njob_id: %d\njob_name: %s\ntrigger: %s\nstate: %s\ndetail: %s\nduration: %d\ncommand: %s\narguments: %s\nstart_only: %t\n\n%s\n",
|
||||
started.Format("2006-01-02 15:04:05"), job.ID, job.Name, trigger, state, detail, durationMS, job.Command, logArguments(job.Arguments), job.StartOnly, output)
|
||||
if err := os.WriteFile(path, []byte(content), 0o644); err != nil {
|
||||
started.Format("2006-01-02 15:04:05"), job.ID, job.Name, trigger, state, detail, durationMS, job.Command, LogArguments(job.Arguments), job.StartOnly, output)
|
||||
if err := writeFileAtomic(logsDir, path, []byte(content), 0o644); err != nil {
|
||||
return "", fmt.Errorf("write log file: %w", err)
|
||||
}
|
||||
return path, nil
|
||||
}
|
||||
|
||||
// writeFileAtomic writes data to a temp file in dir, then renames it over
|
||||
// path. Rename is atomic within a volume on both supported platforms, so a
|
||||
// crash or a killed process mid-write can never leave path holding a
|
||||
// truncated log file the way a direct os.WriteFile could.
|
||||
func writeFileAtomic(dir, path string, data []byte, perm os.FileMode) error {
|
||||
tmp, err := os.CreateTemp(dir, filepath.Base(path)+".tmp*")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
tmpPath := tmp.Name()
|
||||
success := false
|
||||
defer func() {
|
||||
if !success {
|
||||
os.Remove(tmpPath)
|
||||
}
|
||||
}()
|
||||
|
||||
if _, err := tmp.Write(data); err != nil {
|
||||
tmp.Close()
|
||||
return err
|
||||
}
|
||||
if err := tmp.Sync(); err != nil {
|
||||
tmp.Close()
|
||||
return err
|
||||
}
|
||||
if err := tmp.Close(); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := os.Chmod(tmpPath, perm); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := os.Rename(tmpPath, path); err != nil {
|
||||
return err
|
||||
}
|
||||
success = true
|
||||
return nil
|
||||
}
|
||||
|
||||
// uniqueLogPath returns a path for fileName in dir, appending a disambiguating
|
||||
// "-2", "-3", … suffix before the extension if the plain name is already
|
||||
// taken. Two runs of the same job in the same second — a fast manual re-run,
|
||||
// or a sub-second queue drain — would otherwise share one timestamp and the
|
||||
// second write would silently overwrite the first.
|
||||
func uniqueLogPath(dir, fileName string) string {
|
||||
path := filepath.Join(dir, fileName)
|
||||
if _, err := os.Stat(path); err != nil {
|
||||
return path
|
||||
}
|
||||
ext := filepath.Ext(fileName)
|
||||
base := strings.TrimSuffix(fileName, ext)
|
||||
for n := 2; ; n++ {
|
||||
candidate := filepath.Join(dir, fmt.Sprintf("%s-%d%s", base, n, ext))
|
||||
if _, err := os.Stat(candidate); err != nil {
|
||||
return candidate
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func sanitizeFileName(name string) string {
|
||||
name = strings.TrimSpace(name)
|
||||
if name == "" {
|
||||
|
||||
@@ -0,0 +1,36 @@
|
||||
package runner
|
||||
|
||||
import (
|
||||
"os"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// TestUniqueLogPathAvoidsCollision pins the fix for two runs of the same job
|
||||
// landing on the same second: without disambiguation the second write would
|
||||
// silently overwrite the first.
|
||||
func TestUniqueLogPathAvoidsCollision(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
const name = "20260101-120000_job.log"
|
||||
|
||||
first := uniqueLogPath(dir, name)
|
||||
if first != filepath.Join(dir, name) {
|
||||
t.Fatalf("first call: got %q, want the plain name", first)
|
||||
}
|
||||
if err := os.WriteFile(first, []byte("one"), 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
second := uniqueLogPath(dir, name)
|
||||
if second == first {
|
||||
t.Fatalf("second call returned the same path as an existing file: %q", second)
|
||||
}
|
||||
if err := os.WriteFile(second, []byte("two"), 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
third := uniqueLogPath(dir, name)
|
||||
if third == first || third == second {
|
||||
t.Fatalf("third call collided with an existing file: %q (existing: %q, %q)", third, first, second)
|
||||
}
|
||||
}
|
||||
+10
-2
@@ -36,7 +36,15 @@ func RunJob(ctx context.Context, job *domain.Job, trigger string, logsDir string
|
||||
var detail string
|
||||
var durationMS int64
|
||||
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
|
||||
// launch latency (time to spawn the process) rather than run time.
|
||||
state, detail, output, durationMS = startJobOnly(invocation, *job, started)
|
||||
@@ -108,7 +116,7 @@ func startOnlyOutput(job domain.Job, pid int) string {
|
||||
builder.WriteString("command:\n")
|
||||
builder.WriteString(job.Command + "\n\n")
|
||||
builder.WriteString("arguments:\n")
|
||||
builder.WriteString(logArguments(job.Arguments))
|
||||
builder.WriteString(LogArguments(job.Arguments))
|
||||
builder.WriteString("\n\nstart_only:\ntrue")
|
||||
return builder.String()
|
||||
}
|
||||
|
||||
@@ -135,8 +135,8 @@ func TestLogArguments(t *testing.T) {
|
||||
{"--flag\n--value", "--flag\n--value"},
|
||||
}
|
||||
for _, tc := range cases {
|
||||
if got := logArguments(tc.input); got != tc.want {
|
||||
t.Errorf("logArguments(%q) = %q, want %q", tc.input, got, tc.want)
|
||||
if got := LogArguments(tc.input); got != tc.want {
|
||||
t.Errorf("LogArguments(%q) = %q, want %q", tc.input, got, tc.want)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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) {
|
||||
command := "sh"
|
||||
arguments := "-c\nsleep 5"
|
||||
|
||||
@@ -51,3 +51,44 @@ func TestWindowsShellCommandLineQuotesUnquotedProgramPath(t *testing.T) {
|
||||
t.Fatalf("expected command line %q, got %q", want, got)
|
||||
}
|
||||
}
|
||||
|
||||
// TestQuoteLeadingWindowsProgramPathPicksEarliestBoundedExtension pins the
|
||||
// fix for the quoting bug found in the whole-project review: the program
|
||||
// path must end at the *earliest* extension match that sits at a token
|
||||
// boundary, not the first extension in the .exe/.cmd/.bat/.com list order,
|
||||
// and not a substring match inside another word.
|
||||
func TestQuoteLeadingWindowsProgramPathPicksEarliestBoundedExtension(t *testing.T) {
|
||||
cases := []struct {
|
||||
name string
|
||||
command string
|
||||
want string
|
||||
}{
|
||||
{
|
||||
name: "bat with unquoted argument",
|
||||
command: `C:\My Tools\run.bat D:\in.txt`,
|
||||
want: `"C:\My Tools\run.bat" D:\in.txt`,
|
||||
},
|
||||
{
|
||||
name: "bat program with exe argument",
|
||||
command: `C:\My Tools\run.bat C:\Windows\System32\notepad.exe`,
|
||||
want: `"C:\My Tools\run.bat" C:\Windows\System32\notepad.exe`,
|
||||
},
|
||||
{
|
||||
name: "cmd program with exe argument",
|
||||
command: `C:\Program Files\App\deploy.cmd D:\stage\setup.exe`,
|
||||
want: `"C:\Program Files\App\deploy.cmd" D:\stage\setup.exe`,
|
||||
},
|
||||
{
|
||||
name: "exe substring inside directory name",
|
||||
command: `C:\dir.exexample\My Tool\run.bat`,
|
||||
want: `"C:\dir.exexample\My Tool\run.bat"`,
|
||||
},
|
||||
}
|
||||
for _, tc := range cases {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
if got := quoteLeadingWindowsProgramPath(tc.command); got != tc.want {
|
||||
t.Fatalf("quoteLeadingWindowsProgramPath(%q) = %q, want %q", tc.command, got, tc.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
+56
-55
@@ -21,6 +21,10 @@ type SeededStats struct {
|
||||
AvgDurationMS int64
|
||||
MaxDurationMS int64
|
||||
TimedRunCount int
|
||||
// DurationSumMS is the running total AvgDurationMS was computed from, folded
|
||||
// into JobRuntime.DurationSumMS so app.updateStats continues the same exact
|
||||
// sum instead of restarting from a value it would have to reverse-multiply.
|
||||
DurationSumMS int64
|
||||
}
|
||||
|
||||
// SeedStats scans logsDir once and reconstructs per-job execution-time
|
||||
@@ -45,8 +49,8 @@ func SeedStats(logsDir string, jobs []domain.Job, maxFiles int) map[int]SeededSt
|
||||
return result
|
||||
}
|
||||
|
||||
byID := make(map[int][]string)
|
||||
byName := make(map[string][]string)
|
||||
byID := make(map[int][]logSummary)
|
||||
byName := make(map[string][]logSummary)
|
||||
for _, entry := range entries {
|
||||
if entry.IsDir() {
|
||||
continue
|
||||
@@ -55,9 +59,10 @@ func SeedStats(logsDir string, jobs []domain.Job, maxFiles int) map[int]SeededSt
|
||||
if !strings.HasSuffix(strings.ToLower(name), ".log") {
|
||||
continue
|
||||
}
|
||||
path := filepath.Join(logsDir, name)
|
||||
if jobID, ok := readLogJobID(path); ok {
|
||||
byID[jobID] = append(byID[jobID], name)
|
||||
summary := readLogSummary(filepath.Join(logsDir, name))
|
||||
summary.name = name
|
||||
if summary.hasJobID {
|
||||
byID[summary.jobID] = append(byID[summary.jobID], summary)
|
||||
continue
|
||||
}
|
||||
base := name[:len(name)-len(".log")]
|
||||
@@ -65,7 +70,7 @@ func SeedStats(logsDir string, jobs []domain.Job, maxFiles int) map[int]SeededSt
|
||||
if idx < 0 {
|
||||
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 {
|
||||
@@ -76,80 +81,71 @@ func SeedStats(logsDir string, jobs []domain.Job, maxFiles int) map[int]SeededSt
|
||||
if len(files) == 0 {
|
||||
continue
|
||||
}
|
||||
// The timestamp prefix sorts chronologically, so a lexical sort puts the
|
||||
// oldest first; keep the newest maxFiles to honor the retention bound.
|
||||
sort.Strings(files)
|
||||
// The timestamp prefix sorts chronologically, so a lexical sort by file
|
||||
// name puts the oldest first; keep the newest maxFiles to honor the
|
||||
// retention bound.
|
||||
sort.Slice(files, func(i, j int) bool { return files[i].name < files[j].name })
|
||||
if maxFiles > 0 && len(files) > maxFiles {
|
||||
files = files[len(files)-maxFiles:]
|
||||
}
|
||||
result[job.ID] = aggregateLogStats(logsDir, files)
|
||||
result[job.ID] = aggregateLogStats(files)
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
// aggregateLogStats folds the header of each log file (oldest first) into one
|
||||
// SeededStats. Files lacking a duration line contribute to the run/fail counts
|
||||
// but not to the duration aggregates.
|
||||
func aggregateLogStats(logsDir string, files []string) SeededStats {
|
||||
// aggregateLogStats folds the already-read header of each log file (oldest
|
||||
// first) into one SeededStats. Files lacking a duration line contribute to the
|
||||
// run/fail counts but not to the duration aggregates.
|
||||
func aggregateLogStats(files []logSummary) SeededStats {
|
||||
var stats SeededStats
|
||||
var durationSum int64
|
||||
var durationCount int
|
||||
for _, file := range files {
|
||||
state, durationMS, hasDuration := readLogHeader(filepath.Join(logsDir, file))
|
||||
stats.RunCount++
|
||||
if state == "Failed" {
|
||||
if file.state == "Failed" {
|
||||
stats.FailCount++
|
||||
}
|
||||
if hasDuration {
|
||||
if file.hasDuration {
|
||||
// Files are oldest first, so the last assignment is the newest run.
|
||||
stats.LastDurationMS = durationMS
|
||||
if durationMS > stats.MaxDurationMS {
|
||||
stats.MaxDurationMS = durationMS
|
||||
stats.LastDurationMS = file.durationMS
|
||||
if file.durationMS > stats.MaxDurationMS {
|
||||
stats.MaxDurationMS = file.durationMS
|
||||
}
|
||||
durationSum += durationMS
|
||||
durationSum += file.durationMS
|
||||
durationCount++
|
||||
}
|
||||
}
|
||||
if durationCount > 0 {
|
||||
stats.TimedRunCount = durationCount
|
||||
stats.DurationSumMS = durationSum
|
||||
stats.AvgDurationMS = durationSum / int64(durationCount)
|
||||
}
|
||||
return stats
|
||||
}
|
||||
|
||||
// readLogJobID reads the job_id field from a log file header.
|
||||
func readLogJobID(path string) (int, bool) {
|
||||
file, err := os.Open(path)
|
||||
if err != nil {
|
||||
return 0, false
|
||||
}
|
||||
defer file.Close()
|
||||
|
||||
scanner := bufio.NewScanner(file)
|
||||
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
|
||||
// logSummary is everything SeedStats needs from one run log: the file name it
|
||||
// sorts by, which job wrote it, how the run ended, and how long it took.
|
||||
type logSummary struct {
|
||||
name string
|
||||
jobID int
|
||||
hasJobID bool
|
||||
state string
|
||||
durationMS int64
|
||||
hasDuration bool
|
||||
}
|
||||
|
||||
// readLogHeader reads the "state" and "duration" fields from a log file's
|
||||
// header (the lines before the first blank line). hasDuration reports whether a
|
||||
// well-formed duration line was present, distinguishing a legacy duration-less
|
||||
// log from one that genuinely recorded a zero-millisecond run.
|
||||
func readLogHeader(path string) (state string, durationMS int64, hasDuration bool) {
|
||||
// readLogSummary reads the job_id, state, and duration fields from a log file's
|
||||
// header (the lines before the first blank line) in a single pass, so seeding
|
||||
// opens each log once rather than once to find its job and again to read its
|
||||
// result. The has* flags report whether a well-formed line was present,
|
||||
// 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)
|
||||
if err != nil {
|
||||
return "", 0, false
|
||||
return summary
|
||||
}
|
||||
defer file.Close()
|
||||
|
||||
@@ -159,14 +155,19 @@ func readLogHeader(path string) (state string, durationMS int64, hasDuration boo
|
||||
if line == "" {
|
||||
break // end of header
|
||||
}
|
||||
if rest, ok := strings.CutPrefix(line, "state: "); ok {
|
||||
state = strings.TrimSpace(rest)
|
||||
if rest, ok := strings.CutPrefix(line, "job_id: "); ok {
|
||||
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 {
|
||||
if value, err := strconv.ParseInt(strings.TrimSpace(rest), 10, 64); err == nil {
|
||||
durationMS = value
|
||||
hasDuration = true
|
||||
summary.durationMS = value
|
||||
summary.hasDuration = true
|
||||
}
|
||||
}
|
||||
}
|
||||
return state, durationMS, hasDuration
|
||||
return summary
|
||||
}
|
||||
|
||||
@@ -61,6 +61,9 @@ func TestSeedStatsBasic(t *testing.T) {
|
||||
if s.AvgDurationMS != 400 {
|
||||
t.Errorf("AvgDurationMS = %d, want 400", s.AvgDurationMS)
|
||||
}
|
||||
if s.DurationSumMS != 1200 {
|
||||
t.Errorf("DurationSumMS = %d, want 1200", s.DurationSumMS)
|
||||
}
|
||||
}
|
||||
|
||||
// TestSeedStatsDurationLessLegacyLog verifies that a log without a duration
|
||||
|
||||
+106
-23
@@ -19,6 +19,13 @@ type Store struct {
|
||||
// PeekKeepRunningInTray reads keep_running_in_tray from gosentry.json for startup
|
||||
// decisions that must run before app.Open(). On error it returns the built-in
|
||||
// default.
|
||||
//
|
||||
// Despite the name, this can write: loadOrCreateConfig creates gosentry.json
|
||||
// with defaults on first run, the same as OpenStore does moments later when
|
||||
// app.Open() parses the now-existing file again. The double parse and the
|
||||
// write-on-read are both harmless — the second read just sees the file the
|
||||
// first one created — but worth knowing before adding a third startup path
|
||||
// that also wants an early look at the config.
|
||||
func PeekKeepRunningInTray() bool {
|
||||
paths, err := ResolvePaths()
|
||||
if err != nil {
|
||||
@@ -65,19 +72,47 @@ func OpenStore() (*Store, []domain.Job, error) {
|
||||
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()
|
||||
if err := os.MkdirAll(s.Paths.AppDir, 0o755); err != nil {
|
||||
return err
|
||||
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 writeJSON(path, config)
|
||||
}
|
||||
return writeJSON(s.Paths.ConfigPath, s.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 {
|
||||
if err := os.MkdirAll(s.Paths.JobsDir, 0o755); err != nil {
|
||||
return err
|
||||
}
|
||||
return writeJSON(s.Paths.JobsPath, domain.JobsFile{Jobs: jobs})
|
||||
return s.PrepareSaveJobs(jobs)()
|
||||
}
|
||||
|
||||
func loadOrCreateConfig(paths Paths) (domain.Config, error) {
|
||||
@@ -116,12 +151,12 @@ func loadOrCreateConfig(paths Paths) (domain.Config, error) {
|
||||
if strings.TrimSpace(config.LogsDir) == "" {
|
||||
config.LogsDir = "logs"
|
||||
}
|
||||
if config.MaxLogFiles <= 0 {
|
||||
config.MaxLogFiles = 100
|
||||
}
|
||||
if config.MaxLogAgeDays <= 0 {
|
||||
config.MaxLogAgeDays = 30
|
||||
}
|
||||
// MaxLogFiles and MaxLogAgeDays are deliberately not normalized: 0 means
|
||||
// "keep everything" (see runner.CleanupLogs), not a missing value, so
|
||||
// backfilling it here would make that choice impossible to persist. A config
|
||||
// written before either field existed already carries 0 from json.Unmarshal
|
||||
// leaving the DefaultConfig() value in config untouched, so old files still
|
||||
// pick up 100 / 30 without an explicit backfill.
|
||||
if config.ExecutionMode == "" {
|
||||
config.ExecutionMode = domain.ExecutionModeParallel
|
||||
}
|
||||
@@ -179,13 +214,18 @@ func loadOrCreateJobs(path string) ([]domain.Job, error) {
|
||||
|
||||
func normalizeJobs(jobs []domain.Job) {
|
||||
next := 1
|
||||
seen := make(map[int]bool, len(jobs))
|
||||
for index := range jobs {
|
||||
job := &jobs[index]
|
||||
if job.ID <= 0 {
|
||||
// IDs are assigned only when absent. Existing IDs stay stable because
|
||||
// History and future log associations use them to identify jobs.
|
||||
if job.ID <= 0 || seen[job.ID] {
|
||||
// IDs are assigned only when absent or already claimed by an earlier job
|
||||
// in this file — a hand-edited jobs.json can carry two entries with the
|
||||
// same ID, which would otherwise share one runtime, one schedule-cache
|
||||
// entry, and one SeedStats bucket. Existing, unique IDs stay stable
|
||||
// because History and future log associations use them to identify jobs.
|
||||
job.ID = next
|
||||
}
|
||||
seen[job.ID] = true
|
||||
if job.ID >= next {
|
||||
next = job.ID + 1
|
||||
}
|
||||
@@ -213,7 +253,12 @@ func normalizeJobs(jobs []domain.Job) {
|
||||
// apply the same rule to a path the user has typed but not yet saved.
|
||||
func ResolveConfiguredPath(appDir string, path string) string {
|
||||
if filepath.IsAbs(path) {
|
||||
return path
|
||||
// Cleaned so two spellings of the same file (forward vs. backslashes, a
|
||||
// trailing separator) resolve to the same string. UpdateSettings compares
|
||||
// this against Paths.JobsPath to decide whether the jobs file is changing,
|
||||
// so an uncleaned path here could trigger a spurious adoption against the
|
||||
// file the app is already using.
|
||||
return filepath.Clean(path)
|
||||
}
|
||||
// Relative paths are resolved against the executable directory, not the
|
||||
// process working directory. This matches ResolvePaths and keeps shortcuts,
|
||||
@@ -230,7 +275,8 @@ func (s *Store) applyConfigPaths() {
|
||||
}
|
||||
|
||||
func writeJSON(path string, value any) error {
|
||||
if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil {
|
||||
dir := filepath.Dir(path)
|
||||
if err := os.MkdirAll(dir, 0o755); err != nil {
|
||||
return err
|
||||
}
|
||||
data, err := json.MarshalIndent(value, "", " ")
|
||||
@@ -240,10 +286,47 @@ func writeJSON(path string, value any) error {
|
||||
// A trailing newline keeps the file friendly to editors and diff tools that
|
||||
// expect text files to end with one.
|
||||
data = append(data, '\n')
|
||||
// WriteFile replaces the full file instead of patching it in place. For small
|
||||
// JSON files this is simpler and prevents stale keys from older versions from
|
||||
// lingering after the schema changes.
|
||||
return os.WriteFile(path, data, 0o644)
|
||||
return writeFileAtomic(dir, path, data, 0o644)
|
||||
}
|
||||
|
||||
// writeFileAtomic writes data to a temp file in dir, syncs it, then renames it
|
||||
// over path. Rename is atomic within a volume on both supported platforms, so
|
||||
// a crash, a power loss, or the process being killed mid-write can never leave
|
||||
// path holding a truncated or empty file the way a direct os.WriteFile could.
|
||||
func writeFileAtomic(dir, path string, data []byte, perm os.FileMode) error {
|
||||
tmp, err := os.CreateTemp(dir, filepath.Base(path)+".tmp*")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
tmpPath := tmp.Name()
|
||||
// Any failure past this point must remove the temp file rather than leave
|
||||
// it behind for the next write to trip over.
|
||||
success := false
|
||||
defer func() {
|
||||
if !success {
|
||||
os.Remove(tmpPath)
|
||||
}
|
||||
}()
|
||||
|
||||
if _, err := tmp.Write(data); err != nil {
|
||||
tmp.Close()
|
||||
return err
|
||||
}
|
||||
if err := tmp.Sync(); err != nil {
|
||||
tmp.Close()
|
||||
return err
|
||||
}
|
||||
if err := tmp.Close(); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := os.Chmod(tmpPath, perm); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := os.Rename(tmpPath, path); err != nil {
|
||||
return err
|
||||
}
|
||||
success = true
|
||||
return nil
|
||||
}
|
||||
|
||||
func defaultJobs() []domain.Job {
|
||||
|
||||
@@ -4,6 +4,7 @@ import (
|
||||
"encoding/json"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"runtime"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
@@ -145,6 +146,50 @@ func TestNormalizeJobsFillsDefaults(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
// TestNormalizeJobsReassignsDuplicateIDs pins the fix for a hand-edited
|
||||
// jobs.json carrying two entries with the same ID: without reassignment both
|
||||
// would share one JobRuntime, one schedule-cache entry, and one SeedStats
|
||||
// bucket, so editing or deleting either would silently affect both.
|
||||
func TestNormalizeJobsReassignsDuplicateIDs(t *testing.T) {
|
||||
jobs := []domain.Job{
|
||||
{ID: 5, Name: "First"},
|
||||
{ID: 5, Name: "Second"},
|
||||
{ID: 5, Name: "Third"},
|
||||
}
|
||||
|
||||
normalizeJobs(jobs)
|
||||
|
||||
seen := make(map[int]bool, len(jobs))
|
||||
for _, job := range jobs {
|
||||
if seen[job.ID] {
|
||||
t.Fatalf("ID %d assigned to more than one job after normalization: %+v", job.ID, jobs)
|
||||
}
|
||||
seen[job.ID] = true
|
||||
}
|
||||
if jobs[0].ID != 5 {
|
||||
t.Errorf("first occurrence should keep its ID: got %d, want 5", jobs[0].ID)
|
||||
}
|
||||
if jobs[1].ID == 5 || jobs[2].ID == 5 {
|
||||
t.Errorf("later duplicates should be reassigned away from 5: got %d, %d", jobs[1].ID, jobs[2].ID)
|
||||
}
|
||||
}
|
||||
|
||||
// TestResolveConfiguredPathCleansAbsolutePaths pins the fix for two spellings
|
||||
// of the same absolute path (forward vs. backslashes) resolving to different
|
||||
// strings: UpdateSettings compares this against Paths.JobsPath as strings to
|
||||
// decide whether the jobs file is changing, so an uncleaned path here could
|
||||
// trigger a spurious adoption against the file already in use.
|
||||
func TestResolveConfiguredPathCleansAbsolutePaths(t *testing.T) {
|
||||
if runtime.GOOS != "windows" {
|
||||
t.Skip("backslash vs. forward-slash spellings of the same path are a Windows-only ambiguity")
|
||||
}
|
||||
got := ResolveConfiguredPath(`C:\app`, "C:/data/jobs.json")
|
||||
want := ResolveConfiguredPath(`C:\app`, `C:\data\jobs.json`)
|
||||
if got != want {
|
||||
t.Errorf("forward-slash and backslash spellings resolved differently: %q vs %q", got, want)
|
||||
}
|
||||
}
|
||||
|
||||
func TestLoadOrCreateConfigCreatesDefaultsOnFirstRun(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
paths := Paths{
|
||||
@@ -183,6 +228,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
|
||||
// jobs.json is created with the sample jobs from defaultJobs, so a new user
|
||||
// sees scheduled and manual execution without inventing a command.
|
||||
@@ -430,3 +504,44 @@ func TestJobsJSONDoesNotPersistRuntimeNoise(t *testing.T) {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// TestWriteJSONReplacesFileAtomically pins the durability fix: writeJSON must
|
||||
// never truncate the destination in place. It writes through a temp file and
|
||||
// renames over the target, so a reader can never observe a partially written
|
||||
// file, and an existing file survives untouched if the marshal fails first.
|
||||
func TestWriteJSONReplacesFileAtomically(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
path := filepath.Join(dir, "gosentry.json")
|
||||
|
||||
original := domain.DefaultConfig()
|
||||
original.LogsDir = "logs-original"
|
||||
if err := writeJSON(path, original); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
updated := domain.DefaultConfig()
|
||||
updated.LogsDir = "logs-updated"
|
||||
if err := writeJSON(path, updated); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
data, err := os.ReadFile(path)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
var got domain.Config
|
||||
if err := json.Unmarshal(data, &got); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if got.LogsDir != "logs-updated" {
|
||||
t.Fatalf("LogsDir = %q, want %q", got.LogsDir, "logs-updated")
|
||||
}
|
||||
|
||||
entries, err := os.ReadDir(dir)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(entries) != 1 {
|
||||
t.Fatalf("expected only the final file in %s, got %v", dir, entries)
|
||||
}
|
||||
}
|
||||
|
||||
+111
-38
@@ -5,8 +5,6 @@ import (
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"gitea.mixdep.ru/mix/gosentry/src/domain"
|
||||
|
||||
"fyne.io/fyne/v2"
|
||||
"fyne.io/fyne/v2/container"
|
||||
"fyne.io/fyne/v2/theme"
|
||||
@@ -26,22 +24,6 @@ func newEvent(jobID int, jobName string, state string, detail string) event {
|
||||
}
|
||||
}
|
||||
|
||||
func collectActivity(jobs []job, runtimes map[int]*domain.JobRuntime) []event {
|
||||
var events []event
|
||||
for _, current := range jobs {
|
||||
// At startup this is usually empty because jobs.json does not persist
|
||||
// runtime logs. The function still centralizes the merge for future
|
||||
// history loading from log metadata.
|
||||
if rt := runtimes[current.ID]; rt != nil {
|
||||
events = append(events, rt.Logs...)
|
||||
}
|
||||
}
|
||||
sort.SliceStable(events, func(left int, right int) bool {
|
||||
return events[left].Time < events[right].Time
|
||||
})
|
||||
return events
|
||||
}
|
||||
|
||||
// textWidth measures how wide s renders at the theme's current body text size.
|
||||
func textWidth(s string) float32 {
|
||||
return fyne.MeasureText(s, theme.TextSize(), fyne.TextStyle{}).Width
|
||||
@@ -102,23 +84,113 @@ const historyTimeSample = "2026-01-02 15:04:05"
|
||||
// Detail and Log are free text, so their width tracks the values actually
|
||||
// present, bounded the same way the Log column always was.
|
||||
func historyColumnWidths(rows []event) [6]float32 {
|
||||
jobNames := make([]string, 0, len(rows))
|
||||
details := make([]string, 0, len(rows))
|
||||
logNames := make([]string, 0, len(rows))
|
||||
var content [3][]string
|
||||
for i := range content {
|
||||
content[i] = make([]string, 0, len(rows))
|
||||
}
|
||||
for _, current := range rows {
|
||||
jobNames = append(jobNames, current.JobName)
|
||||
details = append(details, current.Detail)
|
||||
logNames = append(logNames, logFileName(current.LogFile))
|
||||
for i, value := range historyContentValues(current) {
|
||||
content[i] = append(content[i], value)
|
||||
}
|
||||
}
|
||||
min, max := textColumnMinWidth(), textColumnMaxWidth()
|
||||
return [6]float32{
|
||||
textWidth(historyTimeSample) + cellPadding(),
|
||||
textColumnWidth(historyTriggerSamples, min, max),
|
||||
textColumnWidth(jobNames, min, max),
|
||||
textColumnWidth(historyStateSamples, min, max),
|
||||
textColumnWidth(details, min, max),
|
||||
textColumnWidth(logNames, min, max),
|
||||
widths := [6]float32{
|
||||
0: textWidth(historyTimeSample) + cellPadding(),
|
||||
1: textColumnWidth(historyTriggerSamples, min, max),
|
||||
3: textColumnWidth(historyStateSamples, min, max),
|
||||
}
|
||||
for i, col := range historyContentCols {
|
||||
widths[col] = textColumnWidth(content[i], min, max)
|
||||
}
|
||||
return widths
|
||||
}
|
||||
|
||||
// maxHistoryRows caps the session History list, the way app.maxJobLogs caps a
|
||||
// job's own activity list. History is never persisted and every record carries
|
||||
// the run's full captured output, so an app left running in the tray — the mode
|
||||
// GoSentry is designed for — would otherwise hold every record of every run
|
||||
// forever, and pay a full resort plus a full column-width rescan on each new
|
||||
// one. One job on @every 10s produces ~8 600 records a day.
|
||||
const maxHistoryRows = 1000
|
||||
|
||||
// historyLog is the session History: the capped record list plus the column
|
||||
// widths measured from it. It exists so the widths can be folded in one record
|
||||
// at a time instead of being recomputed from every row on every event, which
|
||||
// is what made the per-event cost grow with the number of rows.
|
||||
type historyLog struct {
|
||||
records []event
|
||||
widths [6]float32
|
||||
// textSize and padding are the theme metrics widths were last measured at.
|
||||
// A theme change invalidates every measurement, so it forces a full rescan
|
||||
// rather than folding new records into stale numbers.
|
||||
textSize float32
|
||||
padding float32
|
||||
}
|
||||
|
||||
func newHistoryLog(records []event) *historyLog {
|
||||
h := &historyLog{records: trimHistory(records)}
|
||||
h.rescan()
|
||||
return h
|
||||
}
|
||||
|
||||
// trimHistory drops the oldest records past the cap. The tail of the backing
|
||||
// array is zeroed because a dropped record holds the run's whole output, which
|
||||
// would otherwise stay reachable until the slice happens to be reallocated.
|
||||
func trimHistory(records []event) []event {
|
||||
if len(records) <= maxHistoryRows {
|
||||
return records
|
||||
}
|
||||
kept := copy(records, records[len(records)-maxHistoryRows:])
|
||||
for i := kept; i < len(records); i++ {
|
||||
records[i] = event{}
|
||||
}
|
||||
return records[:kept]
|
||||
}
|
||||
|
||||
// add appends one record and widens any content-measured column the record
|
||||
// does not fit. Widths only ever grow within a theme: a column is never
|
||||
// narrowed when a record ages out, because the rows still on screen were laid
|
||||
// out against the wider value.
|
||||
func (h *historyLog) add(record event) {
|
||||
h.records = trimHistory(append(h.records, record))
|
||||
if h.stale() {
|
||||
h.rescan()
|
||||
return
|
||||
}
|
||||
min, max := textColumnMinWidth(), textColumnMaxWidth()
|
||||
for i, value := range historyContentValues(record) {
|
||||
if width := textColumnWidth([]string{value}, min, max); width > h.widths[historyContentCols[i]] {
|
||||
h.widths[historyContentCols[i]] = width
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// columnWidths returns the widths to apply to the table, rescanning every
|
||||
// record only when the theme's text metrics have changed since the last scan.
|
||||
func (h *historyLog) columnWidths() [6]float32 {
|
||||
if h.stale() {
|
||||
h.rescan()
|
||||
}
|
||||
return h.widths
|
||||
}
|
||||
|
||||
func (h *historyLog) stale() bool {
|
||||
return theme.TextSize() != h.textSize || cellPadding() != h.padding
|
||||
}
|
||||
|
||||
func (h *historyLog) rescan() {
|
||||
h.textSize, h.padding = theme.TextSize(), cellPadding()
|
||||
h.widths = historyColumnWidths(h.records)
|
||||
}
|
||||
|
||||
// historyContentCols are the columns whose width follows the values actually
|
||||
// present, in the order historyContentValues returns them. Both the
|
||||
// incremental fold in add and the full scan in historyColumnWidths go through
|
||||
// this pair, so they cannot disagree about which columns follow content.
|
||||
var historyContentCols = [3]int{2, 4, 5}
|
||||
|
||||
func historyContentValues(record event) [3]string {
|
||||
return [3]string{record.JobName, record.Detail, logFileName(record.LogFile)}
|
||||
}
|
||||
|
||||
// historyHeader is a bold tappable label used in the History table header row.
|
||||
@@ -156,7 +228,7 @@ func (h *historyHeader) SetText(text string) {
|
||||
// Time caption is built per update because it carries the sort direction arrow.
|
||||
var historyHeaders = [...]string{"Time", "Trigger", "Job", "State", "Detail", "Log"}
|
||||
|
||||
func newHistoryView(events *[]event) (*fyne.Container, func()) {
|
||||
func newHistoryView(log *historyLog) (*fyne.Container, func()) {
|
||||
descending := false
|
||||
headerText := func(id widget.TableCellID) string {
|
||||
if id.Row < 0 && id.Col == 0 {
|
||||
@@ -179,7 +251,7 @@ func newHistoryView(events *[]event) (*fyne.Container, func()) {
|
||||
// per redraw: at build time, on a sort toggle, and from refresh().
|
||||
var rows []event
|
||||
resort := func() {
|
||||
rows = append(rows[:0], (*events)...)
|
||||
rows = append(rows[:0], log.records...)
|
||||
sort.SliceStable(rows, func(left int, right int) bool {
|
||||
if descending {
|
||||
return rows[left].Time > rows[right].Time
|
||||
@@ -224,16 +296,17 @@ func newHistoryView(events *[]event) (*fyne.Container, func()) {
|
||||
table.Unselect(id)
|
||||
}
|
||||
setColumnWidths := func() {
|
||||
for col, width := range historyColumnWidths(rows) {
|
||||
for col, width := range log.columnWidths() {
|
||||
table.SetColumnWidth(col, width)
|
||||
}
|
||||
}
|
||||
setColumnWidths()
|
||||
|
||||
// refresh re-reads the event list into the sorted snapshot and recomputes
|
||||
// every content-fit column width before redrawing, so newly recorded events
|
||||
// appear in the current sort order and longer values widen their column
|
||||
// instead of being truncated.
|
||||
// refresh re-reads the event list into the sorted snapshot and re-applies
|
||||
// the column widths before redrawing, so newly recorded events appear in
|
||||
// the current sort order and longer values widen their column instead of
|
||||
// being truncated. The widths come from historyLog, which folded each new
|
||||
// record in as it arrived — this does not rescan every row.
|
||||
refresh := func() {
|
||||
resort()
|
||||
setColumnWidths()
|
||||
|
||||
+95
-31
@@ -1,12 +1,11 @@
|
||||
package ui
|
||||
|
||||
import (
|
||||
"strconv"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"gitea.mixdep.ru/mix/gosentry/src/domain"
|
||||
|
||||
"fyne.io/fyne/v2"
|
||||
"fyne.io/fyne/v2/test"
|
||||
"fyne.io/fyne/v2/widget"
|
||||
@@ -56,31 +55,6 @@ func TestIndexOfID(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestCollectActivityMergesAndSorts(t *testing.T) {
|
||||
jobs := []job{
|
||||
{ID: 1, Name: "A"},
|
||||
{ID: 2, Name: "B"},
|
||||
}
|
||||
runtimes := map[int]*domain.JobRuntime{
|
||||
1: {Logs: []domain.RunRecord{{Time: "2026-01-02 10:00:00", JobID: 1}}},
|
||||
2: {Logs: []domain.RunRecord{{Time: "2026-01-01 09:00:00", JobID: 2}}},
|
||||
}
|
||||
got := collectActivity(jobs, runtimes)
|
||||
if len(got) != 2 {
|
||||
t.Fatalf("len = %d, want 2", len(got))
|
||||
}
|
||||
if got[0].Time != "2026-01-01 09:00:00" || got[1].Time != "2026-01-02 10:00:00" {
|
||||
t.Errorf("sort order = %v, want ascending by Time", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCollectActivitySkipsMissingRuntimes(t *testing.T) {
|
||||
jobs := []job{{ID: 1, Name: "A"}}
|
||||
if got := collectActivity(jobs, nil); len(got) != 0 {
|
||||
t.Errorf("nil runtimes: got %v, want empty", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestHistoryCellText(t *testing.T) {
|
||||
events := []event{{
|
||||
Time: "2026-06-01 12:00:00",
|
||||
@@ -144,7 +118,8 @@ func TestHistorySortToggleKeepsRowsInSync(t *testing.T) {
|
||||
{Time: "2026-06-01 11:00:00", JobName: "B"},
|
||||
{Time: "2026-06-01 12:00:00", JobName: "C"},
|
||||
}
|
||||
content, refresh := newHistoryView(&events)
|
||||
log := newHistoryLog(events)
|
||||
content, refresh := newHistoryView(log)
|
||||
table, ok := content.Objects[0].(*widget.Table)
|
||||
if !ok {
|
||||
t.Fatal("history view does not wrap a table")
|
||||
@@ -192,7 +167,7 @@ func TestHistorySortToggleKeepsRowsInSync(t *testing.T) {
|
||||
|
||||
// A new run arrives while the table is sorted newest-first: it must be
|
||||
// counted and placed in the order currently on screen, not the build-time one.
|
||||
events = append(events, event{Time: "2026-06-01 13:00:00", JobName: "D"})
|
||||
log.add(event{Time: "2026-06-01 13:00:00", JobName: "D"})
|
||||
refresh()
|
||||
assertOrder("descending after refresh", "D", "C", "B", "A")
|
||||
|
||||
@@ -207,8 +182,7 @@ func TestHistoryCellTemplateIsPlainText(t *testing.T) {
|
||||
testApp := test.NewApp()
|
||||
defer testApp.Quit()
|
||||
|
||||
var events []event
|
||||
content, _ := newHistoryView(&events)
|
||||
content, _ := newHistoryView(newHistoryLog(nil))
|
||||
table := content.Objects[0].(*widget.Table)
|
||||
label, ok := table.CreateCell().(*widget.Label)
|
||||
if !ok {
|
||||
@@ -292,6 +266,96 @@ func TestHistoryColumnsFitTheirContent(t *testing.T) {
|
||||
check("scaled theme")
|
||||
}
|
||||
|
||||
// TestHistoryLogCapsRecords is the regression guard for the unbounded History
|
||||
// list: an app left in the tray records thousands of runs a day, each carrying
|
||||
// the run's whole captured output, so the list must drop the oldest instead of
|
||||
// growing forever.
|
||||
func TestHistoryLogCapsRecords(t *testing.T) {
|
||||
testApp := test.NewApp()
|
||||
defer testApp.Quit()
|
||||
|
||||
log := newHistoryLog(nil)
|
||||
for i := 0; i < maxHistoryRows+25; i++ {
|
||||
log.add(event{Time: "t", JobName: "Job " + strconv.Itoa(i)})
|
||||
}
|
||||
if len(log.records) != maxHistoryRows {
|
||||
t.Fatalf("record count = %d, want capped at %d", len(log.records), maxHistoryRows)
|
||||
}
|
||||
if got, want := log.records[0].JobName, "Job 25"; got != want {
|
||||
t.Errorf("oldest kept record = %q, want %q — the cap must drop from the front", got, want)
|
||||
}
|
||||
last := log.records[len(log.records)-1].JobName
|
||||
if want := "Job " + strconv.Itoa(maxHistoryRows+24); last != want {
|
||||
t.Errorf("newest record = %q, want %q", last, want)
|
||||
}
|
||||
// A list handed in above the cap is trimmed too, not only one grown into it.
|
||||
oversized := make([]event, maxHistoryRows+10)
|
||||
if got := len(newHistoryLog(oversized).records); got != maxHistoryRows {
|
||||
t.Errorf("pre-filled log length = %d, want %d", got, maxHistoryRows)
|
||||
}
|
||||
}
|
||||
|
||||
// TestHistoryLogWidthsMatchAFullScan pins the incremental column widths: while
|
||||
// every measured record is still in the list, folding each one in as it
|
||||
// arrives must give exactly what rescanning every row would, or the cheaper
|
||||
// path would clip values the old one showed.
|
||||
func TestHistoryLogWidthsMatchAFullScan(t *testing.T) {
|
||||
testApp := test.NewApp()
|
||||
defer testApp.Quit()
|
||||
|
||||
log := newHistoryLog(nil)
|
||||
for _, record := range []event{
|
||||
{Time: "1", JobName: "A", Detail: "short", LogFile: `/logs/a.log`},
|
||||
{Time: "2", JobName: "A moderately long job name", Detail: "a longer detail message", LogFile: `/logs/20260601-120000_SomeJobName.log`},
|
||||
{Time: "3", JobName: "B", Detail: "s", LogFile: `/logs/b.log`},
|
||||
} {
|
||||
log.add(record)
|
||||
}
|
||||
if got, want := log.columnWidths(), historyColumnWidths(log.records); got != want {
|
||||
t.Errorf("incremental widths = %v, want the full-scan widths %v", got, want)
|
||||
}
|
||||
}
|
||||
|
||||
// TestHistoryLogWidthsDoNotShrinkWhenRecordsAgeOut covers the other half of the
|
||||
// rule: widths only grow. Dropping the record that set a column's width must
|
||||
// not narrow the column, because the rows on screen were laid out against it.
|
||||
func TestHistoryLogWidthsDoNotShrinkWhenRecordsAgeOut(t *testing.T) {
|
||||
testApp := test.NewApp()
|
||||
defer testApp.Quit()
|
||||
|
||||
log := newHistoryLog(nil)
|
||||
log.add(event{Time: "1", JobName: "A job name long enough to widen its column"})
|
||||
widest := log.columnWidths()[2]
|
||||
for i := 0; i < maxHistoryRows; i++ {
|
||||
log.add(event{Time: "t", JobName: "x"})
|
||||
}
|
||||
if got := log.columnWidths()[2]; got != widest {
|
||||
t.Errorf("Job column width = %v after the wide record aged out, want it held at %v", got, widest)
|
||||
}
|
||||
}
|
||||
|
||||
// TestHistoryLogRescansOnThemeChange guards the one case the incremental fold
|
||||
// cannot handle: every stored width was measured at the old text size, so a
|
||||
// theme change has to fall back to a full rescan.
|
||||
func TestHistoryLogRescansOnThemeChange(t *testing.T) {
|
||||
testApp := test.NewApp()
|
||||
defer testApp.Quit()
|
||||
|
||||
log := newHistoryLog([]event{
|
||||
{Time: "1", JobName: "A moderately long job name", Detail: "a longer detail message"},
|
||||
})
|
||||
before := log.columnWidths()
|
||||
|
||||
testApp.Settings().SetTheme(test.NewTheme())
|
||||
after := log.columnWidths()
|
||||
if after == before {
|
||||
t.Fatal("widths unchanged after a theme change; the fixture theme must alter text metrics")
|
||||
}
|
||||
if want := historyColumnWidths(log.records); after != want {
|
||||
t.Errorf("widths after theme change = %v, want the rescanned %v", after, want)
|
||||
}
|
||||
}
|
||||
|
||||
func TestNewEventUsesConsistentTimestampShape(t *testing.T) {
|
||||
ev := newEvent(1, "Job", "OK", "detail")
|
||||
if _, err := time.Parse("2006-01-02 15:04:05", ev.Time); err != nil {
|
||||
|
||||
+157
-312
@@ -1,8 +1,6 @@
|
||||
package ui
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
|
||||
"gitea.mixdep.ru/mix/gosentry/src/app"
|
||||
"gitea.mixdep.ru/mix/gosentry/src/domain"
|
||||
|
||||
@@ -22,340 +20,187 @@ const noFolder = "No folder"
|
||||
// view; this panel is a quick at-a-glance summary anchored below the output.
|
||||
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.
|
||||
// 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
|
||||
// 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.
|
||||
func newJobsView(w fyne.Window, svc *app.Service) (fyne.CanvasObject, func()) {
|
||||
jobs := svc.Jobs()
|
||||
runtimes := make(map[int]*domain.JobRuntime, len(jobs))
|
||||
syncFromService := func() {
|
||||
jobs = svc.Jobs()
|
||||
for id := range runtimes {
|
||||
delete(runtimes, id)
|
||||
}
|
||||
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{}
|
||||
config := svc.Config()
|
||||
v := &jobsView{
|
||||
w: w,
|
||||
svc: svc,
|
||||
state: newJobsViewState(svc),
|
||||
listView: config.JobListView,
|
||||
paused: config.Paused,
|
||||
}
|
||||
v.dp = newDetailsPanel(job{}, &domain.JobRuntime{}, config.OverlapPolicy, config.DefaultTimeoutSeconds)
|
||||
v.updateDetails()
|
||||
|
||||
selected := 0
|
||||
if len(jobs) == 0 {
|
||||
selected = -1
|
||||
}
|
||||
selectedFolder := allFolders
|
||||
schedulerPaused := svc.Store().Config.Paused
|
||||
listView := svc.Store().Config.JobListView
|
||||
filteredJobs := filteredJobIndexes(jobs, selectedFolder)
|
||||
// Build order follows what refresh() touches: the folder select fires its
|
||||
// OnChanged from SetSelected below, which refreshes, so every widget that
|
||||
// refresh() reaches has to exist by then.
|
||||
v.list = v.newList()
|
||||
v.viewButton = v.newViewToggle()
|
||||
globalControls := v.newGlobalControls()
|
||||
v.folderSelect = v.newFolderSelect()
|
||||
v.folderSelect.SetSelected(v.state.folder)
|
||||
v.syncListSelection()
|
||||
|
||||
dp := newDetailsPanel(job{}, &domain.JobRuntime{}, svc.Store().Config.OverlapPolicy, svc.Store().Config.DefaultTimeoutSeconds)
|
||||
if selected >= 0 {
|
||||
dp.update(jobs[selected], runtimeFor(selected), svc.Store().Config.OverlapPolicy, svc.Store().Config.DefaultTimeoutSeconds)
|
||||
} else {
|
||||
dp.clear()
|
||||
}
|
||||
return v.assemble(globalControls), v.refresh
|
||||
}
|
||||
|
||||
updateDetails := func(index int) {
|
||||
if index < 0 || index >= len(jobs) {
|
||||
// A folder filter can temporarily leave no selectable rows. Clearing
|
||||
// the details panel avoids showing stale information for a hidden job.
|
||||
dp.clear()
|
||||
return
|
||||
}
|
||||
selected = index
|
||||
dp.update(jobs[selected], runtimeFor(selected), svc.Store().Config.OverlapPolicy, svc.Store().Config.DefaultTimeoutSeconds)
|
||||
}
|
||||
// refresh re-reads the Service and redraws the whole view. It is the single
|
||||
// entry point for "something changed": the toolbar handlers call it after a
|
||||
// successful operation, and mainwindow's event observer calls it for everything
|
||||
// else.
|
||||
func (v *jobsView) refresh() {
|
||||
v.state.sync()
|
||||
// The pause state is Service-owned and can change from outside this view, so
|
||||
// it is re-read here rather than mirrored from the tap handler alone — that is
|
||||
// what makes this view a consumer of SchedulerStateChanged.
|
||||
v.applySchedulerState(v.svc.Config().Paused)
|
||||
// updateDetails already ends in a d.logs.Refresh() (both its update and clear
|
||||
// paths do), so refreshing the activity list again here would redraw it twice
|
||||
// per call.
|
||||
v.updateDetails()
|
||||
v.list.Refresh()
|
||||
v.syncListSelection()
|
||||
}
|
||||
|
||||
// list and folderSelect are declared early so closures below can reference
|
||||
// them before the widget.NewList / widget.NewSelect calls assign the values.
|
||||
var list *widget.List
|
||||
var folderSelect *widget.Select
|
||||
|
||||
refreshView := func() {
|
||||
syncFromService()
|
||||
filteredJobs = filteredJobIndexes(jobs, selectedFolder)
|
||||
updateDetails(selected)
|
||||
dp.logs.Refresh()
|
||||
if list != nil {
|
||||
list.Refresh()
|
||||
}
|
||||
// updateDetails repopulates the details pane from the current selection.
|
||||
func (v *jobsView) updateDetails() {
|
||||
current, ok := v.state.selected()
|
||||
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)
|
||||
}
|
||||
|
||||
// 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.
|
||||
applyRowMode := func(inlineStatus, meta, status fyne.CanvasObject) {
|
||||
if listView.IsCompact() {
|
||||
inlineStatus.Show()
|
||||
meta.Hide()
|
||||
status.Hide()
|
||||
return
|
||||
}
|
||||
inlineStatus.Hide()
|
||||
meta.Show()
|
||||
status.Show()
|
||||
// syncListSelection points the list's highlight at the selected job. It is what
|
||||
// keeps the highlight and the details pane describing the same job when the row
|
||||
// a job sits in moves — a job created or deleted above it, a folder filter
|
||||
// applied, or a different jobs file adopted. widget.List.Select returns early
|
||||
// when the row is already highlighted, so calling this on every refresh does not
|
||||
// fight the user's scrolling.
|
||||
func (v *jobsView) syncListSelection() {
|
||||
row := v.state.displayRow()
|
||||
if row < 0 {
|
||||
v.list.UnselectAll()
|
||||
return
|
||||
}
|
||||
v.list.Select(row)
|
||||
}
|
||||
|
||||
list = widget.NewList(
|
||||
func() int { return len(filteredJobs) },
|
||||
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")
|
||||
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)
|
||||
// rebuildFolders re-derives the folder filter's options from the current jobs.
|
||||
// Creating, editing, and deleting a job can all add or remove a folder.
|
||||
func (v *jobsView) rebuildFolders() {
|
||||
v.folderSelect.Options = folderOptions(v.state.jobs)
|
||||
v.folderSelect.Refresh()
|
||||
}
|
||||
|
||||
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()),
|
||||
)
|
||||
// assemble puts the sidebar (global controls, folder filter, toolbar, list) and
|
||||
// the details pane into the master/detail split the tab shows.
|
||||
func (v *jobsView) assemble(globalControls fyne.CanvasObject) fyne.CanvasObject {
|
||||
// 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
|
||||
// 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})
|
||||
filterRow := container.NewBorder(nil, nil, folderCaption, viewButton, folderSelect)
|
||||
sidebarHeader := container.NewVBox(globalControls, widget.NewSeparator(), filterRow, toolbar)
|
||||
sidebar := container.NewBorder(sidebarHeader, nil, nil, nil, list)
|
||||
filterRow := container.NewBorder(nil, nil, folderCaption, v.viewButton, v.folderSelect)
|
||||
sidebarHeader := container.NewVBox(globalControls, widget.NewSeparator(), filterRow, v.newToolbar())
|
||||
sidebar := container.NewBorder(sidebarHeader, nil, nil, nil, v.list)
|
||||
|
||||
// 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.
|
||||
// The divider lets either pane grow, and neither can be dragged below its own
|
||||
// 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))
|
||||
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())
|
||||
}
|
||||
|
||||
@@ -8,7 +8,7 @@ import (
|
||||
|
||||
// lastJobLogs returns a fresh slice of the most recent activity entries for the
|
||||
// "Selected job activity" panel. Logs are stored newest-first (see
|
||||
// app.Service.recordRun), so the leading entries are the latest; the result is
|
||||
// app.prependLog), so the leading entries are the latest; the result is
|
||||
// capped at maxJobActivityRows.
|
||||
func lastJobLogs(logs []event) []event {
|
||||
n := len(logs)
|
||||
|
||||
@@ -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
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
|
||||
"gitea.mixdep.ru/mix/gosentry/src/app"
|
||||
@@ -378,7 +381,7 @@ func TestJobsSplitOpensAtTheSidebarWidth(t *testing.T) {
|
||||
|
||||
// TestToolbarButtonRedrawsRowAndDetails is the regression guard for F12: the
|
||||
// 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
|
||||
// status and the details lose the selection — neither is a compile error.
|
||||
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
|
||||
// metadataRows list self-enforcing (F10): every caption it returns must
|
||||
// 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)
|
||||
})
|
||||
}
|
||||
+44
-35
@@ -13,30 +13,23 @@ import (
|
||||
"fyne.io/fyne/v2/theme"
|
||||
)
|
||||
|
||||
const runRecordTimeLayout = "2006-01-02 15:04:05"
|
||||
|
||||
// The UI package aliases domain types to keep widget callbacks short. The actual
|
||||
// durable model still lives in src/domain, so UI code does not define a second
|
||||
// copy of the scheduler data.
|
||||
type job = domain.Job
|
||||
type event = domain.RunRecord
|
||||
|
||||
func newMainView(w fyne.Window, svc *app.Service) (fyne.CanvasObject, func(time.Duration, bool)) {
|
||||
svc.InstallDesktopIcon(appID, assets.IconBytes())
|
||||
|
||||
// Build the initial event history from the current runtime state. Jobs and
|
||||
// runtimes are read here only for this one-time initialization; the jobs view
|
||||
// owns all subsequent state via its own syncFromService closure.
|
||||
initialJobs := svc.Jobs()
|
||||
initialRuntimes := make(map[int]*domain.JobRuntime, len(initialJobs))
|
||||
for _, j := range initialJobs {
|
||||
if rt := svc.Runtime(j.ID); rt != nil {
|
||||
initialRuntimes[j.ID] = rt
|
||||
}
|
||||
}
|
||||
events := collectActivity(initialJobs, initialRuntimes)
|
||||
func newMainView(w fyne.Window, svc *app.Service, tray *trayState) (fyne.CanvasObject, func(time.Duration, bool)) {
|
||||
// History is session-only: jobs.json never persists JobRuntime.Logs (see
|
||||
// domain.JobRuntime), so there is nothing to seed the History tab with at
|
||||
// startup. It starts empty and fills as events arrive.
|
||||
events := newHistoryLog(nil)
|
||||
|
||||
jobsPanel, refreshJobsView := newJobsView(w, svc)
|
||||
|
||||
history, refreshHistory := newHistoryView(&events)
|
||||
history, refreshHistory := newHistoryView(events)
|
||||
recordStartup := func(duration time.Duration, windowShown bool) {
|
||||
// Startup is recorded as an in-memory History event instead of being
|
||||
// persisted into jobs.json. It is session diagnostics, not durable job
|
||||
@@ -46,7 +39,7 @@ func newMainView(w fyne.Window, svc *app.Service) (fyne.CanvasObject, func(time.
|
||||
if !windowShown {
|
||||
detail = "Started in tray in " + duration.Round(time.Millisecond).String()
|
||||
}
|
||||
events = append(events, newEvent(0, "Application", "Started", detail))
|
||||
events.add(newEvent(0, "Application", "Started", detail))
|
||||
refreshHistory()
|
||||
}
|
||||
|
||||
@@ -63,40 +56,56 @@ 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 UI thread. This is the sole place events touch widgets. (Resolves #4.)
|
||||
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() {
|
||||
if isRecorded {
|
||||
events = append(events, recorded.Record)
|
||||
r := recorded.Record
|
||||
if r.State == "Failed" &&
|
||||
(r.Trigger == "Manual" || r.Trigger == "Schedule") &&
|
||||
// A type switch does not get compiler-enforced exhaustiveness (see
|
||||
// app.Event's doc comment) — JobChanged and SchedulerStateChanged
|
||||
// intentionally fall through to the unconditional refresh() below
|
||||
// without their own case, since a broad state re-read is all they need.
|
||||
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() {
|
||||
fyne.CurrentApp().SendNotification(&fyne.Notification{
|
||||
Title: "GoSentry: Job Failed",
|
||||
Content: r.JobName + ": " + r.Detail,
|
||||
timing := notificationTiming{
|
||||
JobName: e.Record.JobName,
|
||||
EmittedAt: time.Now(),
|
||||
}
|
||||
if finished, err := time.ParseInLocation(runRecordTimeLayout, e.Record.Time, time.Local); err == nil {
|
||||
timing.RunFinished = finished
|
||||
}
|
||||
fyne.Do(func() {
|
||||
timing.UIQueuedAt = time.Now()
|
||||
fyne.CurrentApp().SendNotification(&fyne.Notification{
|
||||
Title: "GoSentry: Job Failed",
|
||||
Content: e.Record.JobName + ": " + e.Record.Detail,
|
||||
})
|
||||
timing.AfterSendAt = time.Now()
|
||||
if err := appendNotificationTimingLog(svc.Paths().LogsDir, timing); err != nil {
|
||||
fyne.LogError("Failed to write notification timing log", err)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
if isError {
|
||||
events = append(events, newEvent(0, "Service", "Error", errOccurred.Err.Error()))
|
||||
}
|
||||
if isJobsLoaded {
|
||||
case app.ErrorOccurred:
|
||||
events.add(newEvent(0, "Service", "Error", e.Err.Error()))
|
||||
case app.JobsLoaded:
|
||||
// Selecting an existing jobs file replaces the job list without a
|
||||
// prompt, so History carries the receipt: how many jobs, from where.
|
||||
detail := strconv.Itoa(jobsLoaded.Count) + " jobs from " + jobsLoaded.Path
|
||||
events = append(events, newEvent(0, "Service", "Jobs loaded", detail))
|
||||
detail := strconv.Itoa(e.Count) + " jobs from " + e.Path
|
||||
events.add(newEvent(0, "Service", "Jobs loaded", detail))
|
||||
}
|
||||
refresh()
|
||||
})
|
||||
}))
|
||||
// Installed after Subscribe so a failure reaches History through
|
||||
// ErrorOccurred instead of being emitted to no listener.
|
||||
svc.InstallDesktopIcon(appID, assets.IconBytes())
|
||||
svc.Start()
|
||||
|
||||
tabs := container.NewAppTabs(
|
||||
container.NewTabItemWithIcon("Jobs", theme.ListIcon(), jobsPanel),
|
||||
container.NewTabItemWithIcon("History", theme.HistoryIcon(), history),
|
||||
container.NewTabItemWithIcon("Settings", theme.SettingsIcon(), settingsView(w, svc)),
|
||||
container.NewTabItemWithIcon("Settings", theme.SettingsIcon(), settingsView(w, svc, tray)),
|
||||
)
|
||||
tabs.SetTabLocation(container.TabLocationTop)
|
||||
|
||||
|
||||
@@ -66,7 +66,7 @@ func TestMainViewFitsTheDefaultWindowSize(t *testing.T) {
|
||||
svc := app.NewService(store, nil)
|
||||
defer svc.Stop()
|
||||
|
||||
content, _ := newMainView(w, svc)
|
||||
content, _ := newMainView(w, svc, &trayState{})
|
||||
min := content.MinSize()
|
||||
if min.Width > defaultWindowWidth || min.Height > defaultWindowHeight {
|
||||
t.Errorf("content.MinSize() = %v, want within %vx%v", min, defaultWindowWidth, defaultWindowHeight)
|
||||
@@ -111,7 +111,7 @@ func TestMainViewRecordStartupAddsHistoryRow(t *testing.T) {
|
||||
svc := newTestService(t)
|
||||
defer svc.Stop()
|
||||
|
||||
content, recordStartup := newMainView(w, svc)
|
||||
content, recordStartup := newMainView(w, svc, &trayState{})
|
||||
w.SetContent(content)
|
||||
|
||||
table := historyTable(t, content)
|
||||
|
||||
@@ -0,0 +1,72 @@
|
||||
package ui
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"time"
|
||||
)
|
||||
|
||||
// notificationTimingLogName deliberately does not end in .log: runner.CleanupLogs
|
||||
// only manages .log files in the logs directory, and this diagnostic file
|
||||
// should not be subject to (or counted against) that retention policy.
|
||||
const notificationTimingLogName = "notify-timing.tsv"
|
||||
|
||||
// notificationTiming captures wall-clock points from a failed run through
|
||||
// SendNotification. It does not include OS toast display latency — Fyne on
|
||||
// Windows shows toasts via a separate PowerShell process after SendNotification
|
||||
// returns.
|
||||
type notificationTiming struct {
|
||||
JobName string
|
||||
RunFinished time.Time
|
||||
EmittedAt time.Time
|
||||
UIQueuedAt time.Time
|
||||
AfterSendAt time.Time
|
||||
}
|
||||
|
||||
func (t notificationTiming) formatLine() string {
|
||||
return fmt.Sprintf(
|
||||
"%s\tjob=%s\tms_after_run=%s\tms_fyne_do=%s\tms_send=%s\tms_app_total=%s\n",
|
||||
t.AfterSendAt.Format(time.RFC3339Nano),
|
||||
t.JobName,
|
||||
msBetween(t.RunFinished, t.EmittedAt),
|
||||
msBetween(t.EmittedAt, t.UIQueuedAt),
|
||||
msBetween(t.UIQueuedAt, t.AfterSendAt),
|
||||
msBetween(t.EmittedAt, t.AfterSendAt),
|
||||
)
|
||||
}
|
||||
|
||||
func msBetween(from, to time.Time) string {
|
||||
if from.IsZero() || to.IsZero() || to.Before(from) {
|
||||
return "-"
|
||||
}
|
||||
return fmt.Sprintf("%d", to.Sub(from).Milliseconds())
|
||||
}
|
||||
|
||||
func appendNotificationTimingLog(logsDir string, timing notificationTiming) error {
|
||||
if logsDir == "" {
|
||||
return nil
|
||||
}
|
||||
if err := os.MkdirAll(logsDir, 0o755); err != nil {
|
||||
return err
|
||||
}
|
||||
path := filepath.Join(logsDir, notificationTimingLogName)
|
||||
file, err := os.OpenFile(path, os.O_APPEND|os.O_CREATE|os.O_WRONLY, 0o644)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer file.Close()
|
||||
|
||||
info, err := file.Stat()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if info.Size() == 0 {
|
||||
if _, err := file.WriteString("# GoSentry failure-notification timing (app side only; OS toast delay is not included)\n" +
|
||||
"# columns: timestamp job ms_after_run ms_fyne_do ms_send ms_app_total\n"); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
_, err = file.WriteString(timing.formatLine())
|
||||
return err
|
||||
}
|
||||
@@ -0,0 +1,59 @@
|
||||
package ui
|
||||
|
||||
import (
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
func TestNotificationTimingFormatLine(t *testing.T) {
|
||||
runFinished := time.Date(2026, 8, 5, 23, 0, 0, 0, time.Local)
|
||||
emitted := runFinished.Add(15 * time.Millisecond)
|
||||
uiQueued := emitted.Add(4 * time.Millisecond)
|
||||
afterSend := uiQueued.Add(2 * time.Millisecond)
|
||||
|
||||
line := notificationTiming{
|
||||
JobName: "Failure notification test",
|
||||
RunFinished: runFinished,
|
||||
EmittedAt: emitted,
|
||||
UIQueuedAt: uiQueued,
|
||||
AfterSendAt: afterSend,
|
||||
}.formatLine()
|
||||
|
||||
if !strings.Contains(line, "job=Failure notification test") {
|
||||
t.Fatalf("line = %q, want job name", line)
|
||||
}
|
||||
for _, want := range []string{"ms_after_run=15", "ms_fyne_do=4", "ms_send=2", "ms_app_total=6"} {
|
||||
if !strings.Contains(line, want) {
|
||||
t.Fatalf("line = %q, want substring %q", line, want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestAppendNotificationTimingLogWritesHeaderAndRow(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
timing := notificationTiming{
|
||||
JobName: "demo",
|
||||
RunFinished: time.Now().Add(-10 * time.Millisecond),
|
||||
EmittedAt: time.Now().Add(-5 * time.Millisecond),
|
||||
UIQueuedAt: time.Now().Add(-2 * time.Millisecond),
|
||||
AfterSendAt: time.Now(),
|
||||
}
|
||||
if err := appendNotificationTimingLog(dir, timing); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
data, err := os.ReadFile(filepath.Join(dir, notificationTimingLogName))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
text := string(data)
|
||||
if !strings.HasPrefix(text, "# GoSentry failure-notification timing") {
|
||||
t.Fatalf("log = %q, want header", text)
|
||||
}
|
||||
if !strings.Contains(text, "job=demo") {
|
||||
t.Fatalf("log = %q, want timing row", text)
|
||||
}
|
||||
}
|
||||
+36
-12
@@ -17,9 +17,10 @@ import (
|
||||
const appID = "ru.mixeme.gosentry.desktop"
|
||||
|
||||
// defaultWindowWidth and defaultWindowHeight are the size the window opens at
|
||||
// on first launch (later launches restore the last size from preferences).
|
||||
// Fyne enforces the assembled content's MinSize as a hard floor over these, so
|
||||
// they only take effect if the content actually fits within them.
|
||||
// on every launch. Window size persistence is frozen (see ROADMAP.md), so
|
||||
// there is no saved size to restore. Fyne enforces the assembled content's
|
||||
// MinSize as a hard floor over these, so they only take effect if the content
|
||||
// actually fits within them.
|
||||
const defaultWindowWidth = 1024
|
||||
const defaultWindowHeight = 660
|
||||
|
||||
@@ -59,31 +60,32 @@ func Run(startInTray bool) {
|
||||
}
|
||||
|
||||
w := a.NewWindow("GoSentry " + app.Version)
|
||||
prefs := a.Preferences()
|
||||
winW := float32(prefs.FloatWithFallback("window.width", defaultWindowWidth))
|
||||
winH := float32(prefs.FloatWithFallback("window.height", defaultWindowHeight))
|
||||
w.Resize(fyne.NewSize(winW, winH))
|
||||
setWindowsNotificationIcon()
|
||||
w.Resize(fyne.NewSize(defaultWindowWidth, defaultWindowHeight))
|
||||
svc, err := app.Open()
|
||||
if err != nil {
|
||||
w.SetContent(container.NewPadded(widget.NewLabel("Failed to load GoSentry configuration: " + err.Error())))
|
||||
a.Run()
|
||||
return
|
||||
}
|
||||
keepInTray = svc.Store().Config.KeepRunningInTray
|
||||
config := svc.Config()
|
||||
keepInTray = config.KeepRunningInTray
|
||||
startHidden = resolveStartHidden(startInTray, keepInTray)
|
||||
applyTrayBehavior(a, w, keepInTray, false)
|
||||
tray := &trayState{}
|
||||
tray.apply(a, w, keepInTray, false)
|
||||
// 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.
|
||||
applyTheme(a, svc.Store().Config.Theme)
|
||||
content, recordStartup := newMainView(w, svc)
|
||||
applyTheme(a, config.Theme)
|
||||
content, recordStartup := newMainView(w, svc, tray)
|
||||
w.SetContent(content)
|
||||
serveSingleInstance(instanceListener, w)
|
||||
serveSingleInstance(instanceListener, w, tray)
|
||||
if startHidden {
|
||||
// Autostart launches intentionally stay hidden, so "window shown" would be
|
||||
// a misleading metric. Record a separate startup event for the tray path
|
||||
// instead of forcing one timing definition onto two different UX flows.
|
||||
recordStartup(time.Since(started), false)
|
||||
a.Run()
|
||||
svc.Stop()
|
||||
return
|
||||
}
|
||||
// Show the window before recording startup time. Measuring earlier, during
|
||||
@@ -93,4 +95,26 @@ func Run(startInTray bool) {
|
||||
w.Show()
|
||||
recordStartup(time.Since(started), true)
|
||||
a.Run()
|
||||
// a.Run() blocks until the tray's Quit item or a window close calls a.Quit().
|
||||
// Stopping here — rather than not at all — cancels the run context so an
|
||||
// in-flight run's os/exec call sees ctx.Done() instead of being orphaned, and
|
||||
// stops the scheduler goroutine before the process exits.
|
||||
svc.Stop()
|
||||
}
|
||||
|
||||
// setWindowsNotificationIcon supplies App.Icon for Fyne desktop notifications
|
||||
// without touching the window or taskbar icon. On Windows those come from the PE
|
||||
// gosentry.ico resource, so run.go must not call SetIcon. Fyne's NewWindow ends
|
||||
// with SetIcon(nil), which adopts App.Icon when it is already set — metadata
|
||||
// must therefore be registered only after the window is created. The tray icon
|
||||
// is set separately in tray.go via SetSystemTrayIcon.
|
||||
func setWindowsNotificationIcon() {
|
||||
if runtime.GOOS != "windows" {
|
||||
return
|
||||
}
|
||||
fyneapp.SetMetadata(fyne.AppMetadata{
|
||||
ID: appID,
|
||||
Name: "GoSentry",
|
||||
Icon: assets.Icon(),
|
||||
})
|
||||
}
|
||||
|
||||
+73
-51
@@ -26,8 +26,15 @@ var settingsCaptions = []string{
|
||||
"GoSentry", "Go", "Fyne", "Repository",
|
||||
}
|
||||
|
||||
func settingsView(w fyne.Window, svc *app.Service) fyne.CanvasObject {
|
||||
store := svc.Store()
|
||||
func settingsView(w fyne.Window, svc *app.Service, tray *trayState) fyne.CanvasObject {
|
||||
// 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
|
||||
// when something differs. It is defined below (once Save and every field
|
||||
// exist) but declared here so the field change handlers can reference it.
|
||||
@@ -36,23 +43,42 @@ func settingsView(w fyne.Window, svc *app.Service) fyne.CanvasObject {
|
||||
// both the initial load and the Cancel/Defaults buttons below.
|
||||
var loadFields func(domain.Config)
|
||||
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.SetChecked(store.Config.KeepRunningInTray)
|
||||
minimizeToTray.SetChecked(saved.KeepRunningInTray)
|
||||
autostartStatus := widget.NewLabel("")
|
||||
trayRestartHint := widget.NewLabel("")
|
||||
trayRestartHint.Truncation = fyne.TextTruncateClip
|
||||
// autostartCheckGen guards against an in-flight check's result landing after
|
||||
// a newer one started (e.g. the user toggles a checkbox again before the
|
||||
// first check's PowerShell call returns). Both the increment and the compare
|
||||
// happen on the main/Fyne thread, so this needs no lock of its own.
|
||||
var autostartCheckGen int
|
||||
refreshAutostartStatus := func() {
|
||||
if settingsPendingAutostart(startOnLogin, minimizeToTray, store.Config) {
|
||||
if settingsPendingAutostart(startOnLogin, minimizeToTray, saved) {
|
||||
autostartStatus.SetText("Pending: save settings to apply")
|
||||
return
|
||||
}
|
||||
ok, message := svc.AutostartStatus()
|
||||
if ok {
|
||||
autostartStatus.SetText("OK: " + message)
|
||||
return
|
||||
}
|
||||
autostartStatus.SetText("Problem: " + message)
|
||||
// svc.AutostartStatus() reaches readShortcut on Windows, which spawns
|
||||
// powershell.exe and blocks on CombinedOutput() — hundreds of milliseconds
|
||||
// of cold start. Running it off the main thread keeps that from freezing
|
||||
// the window on construction and on every checkbox toggle.
|
||||
autostartStatus.SetText("Checking...")
|
||||
autostartCheckGen++
|
||||
gen := autostartCheckGen
|
||||
go func() {
|
||||
ok, message := svc.AutostartStatus()
|
||||
fyne.Do(func() {
|
||||
if gen != autostartCheckGen {
|
||||
return
|
||||
}
|
||||
if ok {
|
||||
autostartStatus.SetText("OK: " + message)
|
||||
return
|
||||
}
|
||||
autostartStatus.SetText("Problem: " + message)
|
||||
})
|
||||
}()
|
||||
}
|
||||
refreshTrayRestartHint := func(pending bool) {
|
||||
if pending {
|
||||
@@ -67,15 +93,15 @@ func settingsView(w fyne.Window, svc *app.Service) fyne.CanvasObject {
|
||||
}
|
||||
minimizeToTray.OnChanged = func(bool) {
|
||||
refreshAutostartStatus()
|
||||
refreshTrayRestartHint(minimizeToTray.Checked != store.Config.KeepRunningInTray)
|
||||
refreshTrayRestartHint(minimizeToTray.Checked != saved.KeepRunningInTray)
|
||||
updateSaveState()
|
||||
}
|
||||
refreshAutostartStatus()
|
||||
notifications := widget.NewCheck("Show desktop notifications for failed jobs", nil)
|
||||
notifications.SetChecked(store.Config.NotifyOnFailure)
|
||||
notifications.SetChecked(saved.NotifyOnFailure)
|
||||
notifications.OnChanged = func(bool) { updateSaveState() }
|
||||
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
|
||||
// saving; Save persists it. Reverting the selection reverts the preview, and
|
||||
// closing without saving falls back to the stored theme on next launch.
|
||||
@@ -87,20 +113,20 @@ func settingsView(w fyne.Window, svc *app.Service) fyne.CanvasObject {
|
||||
[]string{string(domain.ExecutionModeParallel), string(domain.ExecutionModeSequential)},
|
||||
nil,
|
||||
)
|
||||
executionModeSelect.SetSelected(string(store.Config.ExecutionMode))
|
||||
executionModeSelect.SetSelected(string(saved.ExecutionMode))
|
||||
executionModeSelect.OnChanged = func(string) { updateSaveState() }
|
||||
overlapPolicySelect := widget.NewSelect(
|
||||
[]string{string(domain.OverlapPolicySkip), string(domain.OverlapPolicyQueue)},
|
||||
nil,
|
||||
)
|
||||
overlapPolicySelect.SetSelected(string(store.Config.OverlapPolicy))
|
||||
overlapPolicySelect.SetSelected(string(saved.OverlapPolicy))
|
||||
overlapPolicySelect.OnChanged = func(string) { updateSaveState() }
|
||||
defaultTimeout := widget.NewEntry()
|
||||
defaultTimeout.SetPlaceHolder("0 = no timeout")
|
||||
defaultTimeout.SetText(strconv.Itoa(store.Config.DefaultTimeoutSeconds))
|
||||
defaultTimeout.SetText(strconv.Itoa(saved.DefaultTimeoutSeconds))
|
||||
defaultTimeout.OnChanged = func(string) { updateSaveState() }
|
||||
jobsFile := widget.NewEntry()
|
||||
jobsFile.SetText(store.Config.JobsFile)
|
||||
jobsFile.SetText(saved.JobsFile)
|
||||
jobsFile.OnChanged = func(string) { updateSaveState() }
|
||||
// The picker only offers existing files; a jobs file that does not exist yet
|
||||
// is entered by typing its path, which Save then creates.
|
||||
@@ -108,7 +134,7 @@ func settingsView(w fyne.Window, svc *app.Service) fyne.CanvasObject {
|
||||
chooseJSONFile(w, jobsFile)
|
||||
})
|
||||
logsDir := widget.NewEntry()
|
||||
logsDir.SetText(store.Config.LogsDir)
|
||||
logsDir.SetText(saved.LogsDir)
|
||||
logsDir.OnChanged = func(string) { updateSaveState() }
|
||||
logsDirBrowse := widget.NewButtonWithIcon("Browse", theme.FolderOpenIcon(), func() {
|
||||
chooseFolder(w, logsDir)
|
||||
@@ -118,13 +144,15 @@ func settingsView(w fyne.Window, svc *app.Service) fyne.CanvasObject {
|
||||
// manager. It reveals whatever the field currently holds, so an edit can be
|
||||
// checked before Save.
|
||||
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.SetText(strconv.Itoa(store.Config.MaxLogFiles))
|
||||
maxLogFiles.SetPlaceHolder("0 = unlimited")
|
||||
maxLogFiles.SetText(strconv.Itoa(saved.MaxLogFiles))
|
||||
maxLogFiles.OnChanged = func(string) { updateSaveState() }
|
||||
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() }
|
||||
// Autostart status sits on its own row beneath the checkbox (rather than
|
||||
// beside it) so the Application section fits within a half-width column.
|
||||
@@ -133,33 +161,22 @@ func settingsView(w fyne.Window, svc *app.Service) fyne.CanvasObject {
|
||||
settingsStatus := widget.NewLabel("")
|
||||
|
||||
saveSettings := widget.NewButtonWithIcon("Save settings", theme.DocumentSaveIcon(), func() {
|
||||
files, err := strconv.Atoi(strings.TrimSpace(maxLogFiles.Text))
|
||||
if err != nil || files <= 0 {
|
||||
settingsStatus.SetText("Max log files must be a positive number")
|
||||
return
|
||||
}
|
||||
days, err := strconv.Atoi(strings.TrimSpace(maxLogAgeDays.Text))
|
||||
if err != nil || days <= 0 {
|
||||
settingsStatus.SetText("Max log age days must be a positive number")
|
||||
return
|
||||
}
|
||||
if strings.TrimSpace(jobsFile.Text) == "" {
|
||||
settingsStatus.SetText("Jobs file is required")
|
||||
return
|
||||
}
|
||||
if strings.TrimSpace(logsDir.Text) == "" {
|
||||
settingsStatus.SetText("Logs directory is required")
|
||||
return
|
||||
}
|
||||
timeout, err := strconv.Atoi(strings.TrimSpace(defaultTimeout.Text))
|
||||
if err != nil || timeout < 0 {
|
||||
settingsStatus.SetText("Default timeout must not be negative (0 = no timeout)")
|
||||
// Only the parse itself happens here: a numeric field has to become an int
|
||||
// before it can go into a domain.Config at all. Everything else — required
|
||||
// fields, negative numbers, valid enum values — is Service.UpdateSettings'
|
||||
// job (see app.validateConfig), so its error is what the user sees rather
|
||||
// than a second copy of the same rules with different wording.
|
||||
files, filesErr := strconv.Atoi(strings.TrimSpace(maxLogFiles.Text))
|
||||
days, daysErr := strconv.Atoi(strings.TrimSpace(maxLogAgeDays.Text))
|
||||
timeout, timeoutErr := strconv.Atoi(strings.TrimSpace(defaultTimeout.Text))
|
||||
if filesErr != nil || daysErr != nil || timeoutErr != nil {
|
||||
settingsStatus.SetText("Max log files, max log age days, and default timeout must be numbers")
|
||||
return
|
||||
}
|
||||
// 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,
|
||||
// and runs log cleanup so tightened retention limits take effect at once.
|
||||
config := store.Config
|
||||
config := saved
|
||||
config.JobsFile = strings.TrimSpace(jobsFile.Text)
|
||||
config.LogsDir = strings.TrimSpace(logsDir.Text)
|
||||
config.MaxLogFiles = files
|
||||
@@ -171,18 +188,23 @@ func settingsView(w fyne.Window, svc *app.Service) fyne.CanvasObject {
|
||||
config.OverlapPolicy = domain.OverlapPolicy(overlapPolicySelect.Selected)
|
||||
config.DefaultTimeoutSeconds = timeout
|
||||
config.Theme = themeFromLabel(themeSelect.Selected)
|
||||
previousKeepInTray := store.Config.KeepRunningInTray
|
||||
previousKeepInTray := saved.KeepRunningInTray
|
||||
if err := svc.UpdateSettings(config); err != nil {
|
||||
settingsStatus.SetText("Save failed: " + err.Error())
|
||||
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 {
|
||||
refreshAutostartStatus()
|
||||
settingsStatus.SetText("Saved, autostart failed: " + err.Error())
|
||||
return
|
||||
}
|
||||
refreshAutostartStatus()
|
||||
applyTrayBehavior(fyne.CurrentApp(), w, config.KeepRunningInTray, true)
|
||||
tray.apply(fyne.CurrentApp(), w, config.KeepRunningInTray, true)
|
||||
if previousKeepInTray != config.KeepRunningInTray {
|
||||
trayRestartHint.SetText(trayRestartHintText)
|
||||
} else {
|
||||
@@ -198,7 +220,7 @@ func settingsView(w fyne.Window, svc *app.Service) fyne.CanvasObject {
|
||||
// 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.
|
||||
updateSaveState = func() {
|
||||
c := store.Config
|
||||
c := saved
|
||||
changed := startOnLogin.Checked != c.StartOnLogin ||
|
||||
minimizeToTray.Checked != c.KeepRunningInTray ||
|
||||
notifications.Checked != c.NotifyOnFailure ||
|
||||
@@ -235,17 +257,17 @@ func settingsView(w fyne.Window, svc *app.Service) fyne.CanvasObject {
|
||||
logsDir.SetText(c.LogsDir)
|
||||
maxLogFiles.SetText(strconv.Itoa(c.MaxLogFiles))
|
||||
maxLogAgeDays.SetText(strconv.Itoa(c.MaxLogAgeDays))
|
||||
if settingsPendingAutostart(startOnLogin, minimizeToTray, store.Config) {
|
||||
if settingsPendingAutostart(startOnLogin, minimizeToTray, saved) {
|
||||
autostartStatus.SetText("Pending: save settings to apply")
|
||||
} else {
|
||||
refreshAutostartStatus()
|
||||
}
|
||||
refreshTrayRestartHint(minimizeToTray.Checked != store.Config.KeepRunningInTray)
|
||||
refreshTrayRestartHint(minimizeToTray.Checked != saved.KeepRunningInTray)
|
||||
settingsStatus.SetText("")
|
||||
updateSaveState()
|
||||
}
|
||||
cancelSettings := widget.NewButtonWithIcon("Cancel", theme.CancelIcon(), func() {
|
||||
loadFields(store.Config)
|
||||
loadFields(saved)
|
||||
})
|
||||
restoreDefaults := widget.NewButtonWithIcon("Defaults", theme.MediaReplayIcon(), func() {
|
||||
loadFields(domain.DefaultConfig())
|
||||
@@ -261,7 +283,7 @@ func settingsView(w fyne.Window, svc *app.Service) fyne.CanvasObject {
|
||||
executionModeSelect: executionModeSelect,
|
||||
overlapPolicySelect: overlapPolicySelect,
|
||||
defaultTimeout: defaultTimeout,
|
||||
configPath: store.Paths.ConfigPath,
|
||||
configPath: paths.ConfigPath,
|
||||
jobsFile: jobsFile,
|
||||
jobsFileBrowse: jobsFileBrowse,
|
||||
logsDir: logsDir,
|
||||
|
||||
@@ -34,11 +34,14 @@ func acquireSingleInstance(showExisting bool) (net.Listener, bool) {
|
||||
// If the port is unavailable but does not answer as GoSentry, continue
|
||||
// startup instead of making the application impossible to open because of an
|
||||
// unrelated local listener. In the normal duplicate-start case the dial above
|
||||
// succeeds and this process exits after waking the first instance.
|
||||
// succeeds and this process exits after waking the first instance. The
|
||||
// consequence of this fallback — two schedulers able to run against the same
|
||||
// jobs.json and logs directory — is recorded in STANDARDS.md alongside the
|
||||
// unauthenticated nature of this same port.
|
||||
return nil, true
|
||||
}
|
||||
|
||||
func serveSingleInstance(listener net.Listener, w fyne.Window) {
|
||||
func serveSingleInstance(listener net.Listener, w fyne.Window, tray *trayState) {
|
||||
if listener == nil {
|
||||
return
|
||||
}
|
||||
@@ -56,7 +59,7 @@ func serveSingleInstance(listener net.Listener, w fyne.Window) {
|
||||
// Accept runs on its own goroutine, so focusing the window must be
|
||||
// marshaled onto the main thread like every other widget update.
|
||||
fyne.Do(func() {
|
||||
mainWindowHidden = false
|
||||
tray.hidden = false
|
||||
w.Show()
|
||||
w.RequestFocus()
|
||||
})
|
||||
|
||||
+28
-25
@@ -10,16 +10,19 @@ import (
|
||||
fynedesktop "fyne.io/fyne/v2/driver/desktop"
|
||||
)
|
||||
|
||||
// systemTrayRegistered tracks whether this process registered a tray icon at
|
||||
// launch. Fyne cannot add or remove the icon mid-session, so toggling
|
||||
// KeepRunningInTray in Settings updates close behavior immediately and shows a
|
||||
// restart hint for the icon itself.
|
||||
var systemTrayRegistered bool
|
||||
|
||||
// mainWindowHidden tracks whether the primary window was hidden via the tray
|
||||
// close intercept. Fyne exposes no Window.Visible API, so the flag drives the
|
||||
// reveal-on-tray-disable path in applyTrayBehavior.
|
||||
var mainWindowHidden bool
|
||||
// trayState tracks the two pieces of tray-related process state that Fyne
|
||||
// itself does not expose: whether this process has registered the tray icon
|
||||
// (Fyne cannot add or remove it mid-session, so toggling KeepRunningInTray in
|
||||
// Settings updates close behavior immediately but shows a restart hint for the
|
||||
// icon itself) and whether the primary window is currently hidden via the tray
|
||||
// close intercept (Fyne exposes no Window.Visible API). Run owns one instance
|
||||
// and passes it to every call site of apply — settingsView's Save handler is
|
||||
// the other one — so the coupling between them is explicit instead of hidden
|
||||
// behind package-level globals that no test can reset.
|
||||
type trayState struct {
|
||||
registered bool
|
||||
hidden bool
|
||||
}
|
||||
|
||||
const trayRestartHintText = "Restart GoSentry for the tray icon change to take effect."
|
||||
|
||||
@@ -27,23 +30,23 @@ func resolveStartHidden(cliStartInTray, keepInTray bool) bool {
|
||||
return domain.ResolveStartHidden(cliStartInTray, keepInTray)
|
||||
}
|
||||
|
||||
// applyTrayBehavior configures window close handling for KeepRunningInTray.
|
||||
// When revealIfHidden is true and the tray is off, a hidden window is shown so
|
||||
// the user can still reach the app after disabling the tray mid-session.
|
||||
func applyTrayBehavior(a fyne.App, w fyne.Window, keepInTray bool, revealIfHidden bool) {
|
||||
if keepInTray && !systemTrayRegistered {
|
||||
registerSystemTray(a, w)
|
||||
systemTrayRegistered = true
|
||||
// apply configures window close handling for KeepRunningInTray. When
|
||||
// revealIfHidden is true and the tray is off, a hidden window is shown so the
|
||||
// user can still reach the app after disabling the tray mid-session.
|
||||
func (t *trayState) apply(a fyne.App, w fyne.Window, keepInTray bool, revealIfHidden bool) {
|
||||
if keepInTray && !t.registered {
|
||||
t.registerSystemTray(a, w)
|
||||
t.registered = true
|
||||
}
|
||||
setWindowCloseBehavior(w, keepInTray)
|
||||
if !keepInTray && revealIfHidden && mainWindowHidden {
|
||||
mainWindowHidden = false
|
||||
t.setWindowCloseBehavior(w, keepInTray)
|
||||
if !keepInTray && revealIfHidden && t.hidden {
|
||||
t.hidden = false
|
||||
w.Show()
|
||||
w.RequestFocus()
|
||||
}
|
||||
}
|
||||
|
||||
func registerSystemTray(a fyne.App, w fyne.Window) {
|
||||
func (t *trayState) registerSystemTray(a fyne.App, w fyne.Window) {
|
||||
desk, ok := a.(fynedesktop.App)
|
||||
if !ok {
|
||||
// Not every Fyne driver exposes desktop tray features. Returning silently
|
||||
@@ -74,7 +77,7 @@ func registerSystemTray(a fyne.App, w fyne.Window) {
|
||||
quit.IsQuit = true
|
||||
menu := fyne.NewMenu("GoSentry",
|
||||
fyne.NewMenuItem("Show", func() {
|
||||
mainWindowHidden = false
|
||||
t.hidden = false
|
||||
w.Show()
|
||||
w.RequestFocus()
|
||||
}),
|
||||
@@ -85,17 +88,17 @@ func registerSystemTray(a fyne.App, w fyne.Window) {
|
||||
desk.SetSystemTrayWindow(w)
|
||||
}
|
||||
|
||||
func setWindowCloseBehavior(w fyne.Window, keepInTray bool) {
|
||||
func (t *trayState) setWindowCloseBehavior(w fyne.Window, keepInTray bool) {
|
||||
if keepInTray {
|
||||
w.SetCloseIntercept(func() {
|
||||
// Closing hides the window instead of quitting because scheduler tools are
|
||||
// expected to keep working in the background. The explicit Quit tray item
|
||||
// remains the way to stop the process.
|
||||
mainWindowHidden = true
|
||||
t.hidden = true
|
||||
w.Hide()
|
||||
})
|
||||
return
|
||||
}
|
||||
mainWindowHidden = false
|
||||
t.hidden = false
|
||||
w.SetCloseIntercept(nil)
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user