Compare commits
20 Commits
a735bfd116
..
v1.0.3
| Author | SHA1 | Date | |
|---|---|---|---|
| 6a03ea4a20 | |||
| 4fb9bf6ff3 | |||
| 9b7a0565ba | |||
| da8fe10365 | |||
| 18da021526 | |||
| bd7ebde68e | |||
| ca2a8c8aa7 | |||
| 0c8442a8d1 | |||
| 98c820e3bd | |||
| 263717874c | |||
| 1242b22e4f | |||
| 89be009040 | |||
| eaa37a1192 | |||
| c8a4d31441 | |||
| 1e3d14bef2 | |||
| 0aab9d8db6 | |||
| 0a50f3c66b | |||
| 1240297cca | |||
| 5b0e6fe51b | |||
| 5170cc5f99 |
@@ -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.
|
|
||||||
@@ -8,16 +8,11 @@ application service, scheduler, storage, and command runner in one binary.
|
|||||||
- [docs/STANDARDS.md](docs/STANDARDS.md) — **required.** Code-quality rules and
|
- [docs/STANDARDS.md](docs/STANDARDS.md) — **required.** Code-quality rules and
|
||||||
the list of intentional behavior. Do not "fix" anything listed there as
|
the list of intentional behavior. Do not "fix" anything listed there as
|
||||||
intentional; if a change contradicts it, update the document in the same commit.
|
intentional; if a change contradicts it, update the document in the same commit.
|
||||||
- [docs/ARCHITECTURE.md](docs/ARCHITECTURE.md) — package contracts and event flow.
|
- [docs/ARCHITECTURE.md](docs/ARCHITECTURE.md) — package contracts, event flow, and
|
||||||
|
§Platform layer (why and where OS-specific code lives).
|
||||||
- [docs/TESTS.md](docs/TESTS.md) — test layout and conventions.
|
- [docs/TESTS.md](docs/TESTS.md) — test layout and conventions.
|
||||||
- [docs/ROADMAP.md](docs/ROADMAP.md) — deliberately out of scope.
|
- [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)
|
## Key rules (full list in STANDARDS.md)
|
||||||
|
|
||||||
- `src/app.Service` is the sole owner of job and runtime state; the UI reads it
|
- `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>
|
<table>
|
||||||
<tr>
|
<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="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="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_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>
|
</tr>
|
||||||
</table>
|
</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
|
- [Roadmap](docs/ROADMAP.md) — planned work larger than a single bug fix
|
||||||
- [Architecture](docs/ARCHITECTURE.md) — component interaction model
|
- [Architecture](docs/ARCHITECTURE.md) — component interaction model
|
||||||
- [Standards](docs/STANDARDS.md) — quality rules and intentional behavior
|
- [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
|
- [Development](docs/DEVELOPMENT.md) — build instructions, project layout, dependencies
|
||||||
- [Tests](docs/TESTS.md) — test suite layout and how to run it
|
- [Tests](docs/TESTS.md) — test suite layout and how to run it
|
||||||
- [Performance](docs/PERFORMANCE.md) — measured performance findings
|
- [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**.
|
3. Set **Schedule**, **Command**, optional **Arguments**, **Folder**, and **Enabled**.
|
||||||
4. Use **Run now** for a one-off manual run without waiting for the schedule.
|
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.
|
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.
|
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.
|
8. Open **Settings** to change the storage paths, log cleanup limits, queue behavior, and notifications.
|
||||||
|
|
||||||
@@ -201,8 +203,12 @@ loading a new list discards the run state of the old one.
|
|||||||
|
|
||||||
The **Start on login** checkbox shows an `OK` or `Problem` status. Saving with
|
The **Start on login** checkbox shows an `OK` or `Problem` status. Saving with
|
||||||
it enabled writes an autostart entry using the current executable path.
|
it enabled writes an autostart entry using the current executable path.
|
||||||
Autostart entries include `--start-in-tray` so scheduled jobs run after sign-in
|
When **Keep running in the system tray** is also enabled, the entry includes
|
||||||
without opening the main window.
|
`--start-in-tray` so scheduled jobs run after sign-in without opening the main
|
||||||
|
window. With the tray option off, autostart still works but opens the main
|
||||||
|
window normally. Changing the tray setting updates close behaviour and the
|
||||||
|
autostart entry immediately; the tray icon itself updates only after you restart
|
||||||
|
GoSentry (a Fyne limitation — see [docs/ROADMAP.md](docs/ROADMAP.md)).
|
||||||
|
|
||||||
## Queue Settings
|
## Queue Settings
|
||||||
|
|
||||||
@@ -238,8 +244,9 @@ sets one.
|
|||||||
## Notifications
|
## Notifications
|
||||||
|
|
||||||
When **Notify on failure** is enabled in Settings, GoSentry sends a desktop
|
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.
|
notification whenever a scheduled or manual run ends in the `Failed` state —
|
||||||
The notification shows the job name and the exit code.
|
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
|
## Autostart
|
||||||
|
|
||||||
|
|||||||
+3
-1
@@ -17,7 +17,7 @@ import (
|
|||||||
// The hard constraint: Fyne's a.SetIcon and SetSystemTrayIcon each take ONE
|
// 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),
|
// image, which the OS then scales to every size it needs — titlebar (~16px),
|
||||||
// taskbar/dock (~32-48px), and tray. Neither source survives that scaling:
|
// 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
|
// 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
|
// size-appropriate source — which differs per platform because each platform
|
||||||
// exposes different icon channels.
|
// exposes different icon channels.
|
||||||
@@ -44,6 +44,8 @@ import (
|
|||||||
// - Tray: SetSystemTrayIcon(IconSmallICO()). The notification area is ICO-native
|
// - Tray: SetSystemTrayIcon(IconSmallICO()). The notification area is ICO-native
|
||||||
// and renders at 16-24px; a single-frame 16x16 .ico pins the hand-tuned glyph
|
// 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).
|
// (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):
|
// Linux / other non-Windows (no PE icon resource exists):
|
||||||
// - Window titlebar: a.SetIcon(IconSmall()) in run.go feeds the resource to
|
// - Window titlebar: a.SetIcon(IconSmall()) in run.go feeds the resource to
|
||||||
|
|||||||
+103
-21
@@ -41,7 +41,7 @@ flowchart LR
|
|||||||
|
|
||||||
user -->|"edits jobs, settings, runs commands"| ui
|
user -->|"edits jobs, settings, runs commands"| ui
|
||||||
ui -->|"CreateJob, UpdateJob, DeleteJob, RunNow, UpdateSettings, AutostartStatus, …"| svc
|
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"| config
|
||||||
store -->|"read/write"| jobs
|
store -->|"read/write"| jobs
|
||||||
|
|
||||||
@@ -57,13 +57,75 @@ flowchart LR
|
|||||||
svc -->|"Set / Status via Manager"| autostart
|
svc -->|"Set / Status via Manager"| autostart
|
||||||
```
|
```
|
||||||
|
|
||||||
|
## Platform layer
|
||||||
|
|
||||||
|
GoSentry ships one binary per target OS. Platform-specific code is not a
|
||||||
|
workaround for missing cross-platform support — it **is** the cross-platform
|
||||||
|
strategy: shared interfaces and call sites, with OS-specific implementations
|
||||||
|
selected at **compile time** (`*_windows.go`, `//go:build linux`, and similar).
|
||||||
|
Runtime `runtime.GOOS` checks appear only for small UI details (see below), not
|
||||||
|
for autostart, file-manager integration, or command invocation.
|
||||||
|
|
||||||
|
Callers (`app.Service`, `ui`, `runner`) depend on the shared API; they do not
|
||||||
|
branch on the operating system.
|
||||||
|
|
||||||
|
| Package / file | Windows | Linux | Other (`!windows && !linux`) |
|
||||||
|
| --- | --- | --- | --- |
|
||||||
|
| `platform/autostart` | Startup-folder `.lnk` shortcut | XDG `~/.config/autostart/gosentry.desktop` | Stub — `Set` returns an error when enabled |
|
||||||
|
| `platform/desktop` | no-op | Installs `.desktop` + icon under XDG data home | no-op |
|
||||||
|
| `platform/filemanager` | `explorer` | `xdg-open` | Unsupported — `Open` returns an error |
|
||||||
|
| `platform/winproc` | `CREATE_NO_WINDOW` / `HideWindow` on child processes | no-op | no-op |
|
||||||
|
| `runner/invocation_*` | `cmd.exe /S /C` with Windows-safe quoting | `sh -c` | `sh -c` (same as Linux) |
|
||||||
|
|
||||||
|
**Why separate implementations are required**
|
||||||
|
|
||||||
|
- **Autostart** — each OS defines its own login startup mechanism (shortcut,
|
||||||
|
XDG Autostart, LaunchAgents on macOS). There is no portable API in Go, Fyne, or
|
||||||
|
the standard library; a third-party helper would still wrap the same per-OS
|
||||||
|
code behind an interface.
|
||||||
|
- **Opening a folder** — the desktop shell exposes no shared “reveal in file
|
||||||
|
manager” call; each platform invokes its registered handler (`explorer`,
|
||||||
|
`xdg-open`, `open` on macOS).
|
||||||
|
- **Command shell** — users expect OS-native semantics (`cmd.exe` batch files,
|
||||||
|
`%VAR%`, and path rules on Windows; POSIX `sh` on Linux). A single shell for
|
||||||
|
all platforms would break commands on one side or the other.
|
||||||
|
- **Hidden console window** — launching a child process from a GUI app can flash
|
||||||
|
a console on Windows only; Linux and macOS do not need equivalent flags.
|
||||||
|
|
||||||
|
**Deliberate platform choices (not OS API limits)**
|
||||||
|
|
||||||
|
- **Window and tray icons** — Fyne accepts icons on every platform, but Windows
|
||||||
|
renders the notification area and titlebar from multi-size `.ico` resources
|
||||||
|
(embedded via `packaging/windows/gosentry.rc`), while Linux StatusNotifier
|
||||||
|
trays scale better from a larger PNG. `ui/run.go` and `ui/tray.go` branch on
|
||||||
|
`runtime.GOOS` for asset selection only.
|
||||||
|
- **Sample job commands** in `storage/store.go` — demo `echo` lines differ only
|
||||||
|
because shell quoting rules differ; real jobs are user-authored per platform.
|
||||||
|
|
||||||
|
**Adding new platform code**
|
||||||
|
|
||||||
|
- Put OS integration in `src/platform/<name>/` with a small shared API, or use
|
||||||
|
`*_GOOS.go` files in the owning package when the surface is a single function
|
||||||
|
(as in `runner/invocation_*`).
|
||||||
|
- Do not scatter `runtime.GOOS` through `app.Service` or UI business logic.
|
||||||
|
- Unsupported platforms get an explicit stub (return an error or no-op) rather
|
||||||
|
than silently doing nothing — see `autostart_other.go` and
|
||||||
|
`filemanager_other.go`.
|
||||||
|
|
||||||
|
macOS autostart and file-manager handlers are not implemented yet; see
|
||||||
|
[ROADMAP.md](ROADMAP.md) for blocked or deferred cross-platform work (for
|
||||||
|
example window-maximized detection, which would need per-OS native calls).
|
||||||
|
|
||||||
## Main Flows
|
## Main Flows
|
||||||
|
|
||||||
1. Startup:
|
1. Startup:
|
||||||
`cmd/gosentry` calls `ui.Run`, which creates an `app.Service`, opens the
|
`cmd/gosentry` calls `ui.Run`, which owns the process lifecycle: it calls
|
||||||
store, loads `gosentry.json` and `jobs.json`, subscribes the UI to service
|
`app.Open()` to open the store, load `gosentry.json` and `jobs.json`, and
|
||||||
events, builds the main window, and calls `Service.Start` to begin the
|
build the `app.Service`, then hands that Service to `newMainView`
|
||||||
scheduler loop. On every launch the service seeds per-job run-time statistics
|
(`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
|
from existing log files so the details panel reflects accumulated history
|
||||||
immediately (see §Statistics below).
|
immediately (see §Statistics below).
|
||||||
|
|
||||||
@@ -77,10 +139,13 @@ flowchart LR
|
|||||||
`UpdateSettings` has one extra step: when the configured jobs file changes
|
`UpdateSettings` has one extra step: when the configured jobs file changes
|
||||||
and a file already exists at the new path, that file is authoritative. The
|
and a file already exists at the new path, that file is authoritative. The
|
||||||
Service loads it, calls `adoptJobsLocked` to rebuild the jobs slice, runtime
|
Service loads it, calls `adoptJobsLocked` to rebuild the jobs slice, runtime
|
||||||
map, schedule cache, next-run times, and log-seeded statistics around it, and
|
map, schedule cache, and next-run times around it, applies the statistics
|
||||||
emits `JobsLoaded` plus a broad `JobChanged`. A path with no file behind it
|
seeded from the new logs directory, and emits `JobsLoaded` plus a broad
|
||||||
receives the current jobs instead. Adoption drops all runtime state, so it is
|
`JobChanged`. A path with no file behind it receives the current jobs instead.
|
||||||
refused while a job is running.
|
Adoption drops all runtime state, so it is refused while a job is running.
|
||||||
|
Reading the new file and seeding its statistics both happen before `mu` is
|
||||||
|
taken (the no-I/O-under-`mu` rule in [STANDARDS.md](STANDARDS.md)), so the
|
||||||
|
running-job check is re-evaluated under the lock before anything is replaced.
|
||||||
|
|
||||||
3. Scheduled run:
|
3. Scheduled run:
|
||||||
`scheduler.Scheduler` fires a tick every second. On each tick it calls
|
`scheduler.Scheduler` fires a tick every second. On each tick it calls
|
||||||
@@ -104,8 +169,9 @@ flowchart LR
|
|||||||
|
|
||||||
6. History update:
|
6. History update:
|
||||||
When a run goroutine completes, `Service` updates the job's runtime
|
When a run goroutine completes, `Service` updates the job's runtime
|
||||||
(including the statistics aggregate), saves JSON, triggers log cleanup, and
|
(including the statistics aggregate) under `mu`, then — after releasing it —
|
||||||
emits `RunRecorded`. The UI observer appends the record to the History tab.
|
runs log cleanup and emits `RunRecorded`. Nothing is saved: a run changes only
|
||||||
|
`JobRuntime`, which is never persisted. The UI observer appends the record to the History tab.
|
||||||
History rows exist only for the current process session; restarting the app
|
History rows exist only for the current process session; restarting the app
|
||||||
clears the table (aggregate stats in the details panel are still seeded from
|
clears the table (aggregate stats in the details panel are still seeded from
|
||||||
log files).
|
log files).
|
||||||
@@ -113,8 +179,10 @@ flowchart LR
|
|||||||
7. Autostart:
|
7. Autostart:
|
||||||
`UpdateSettings` in the Service calls `autostart.Manager.Set`. The Manager
|
`UpdateSettings` in the Service calls `autostart.Manager.Set`. The Manager
|
||||||
interface has two implementations: Windows writes a `.lnk` shortcut to the
|
interface has two implementations: Windows writes a `.lnk` shortcut to the
|
||||||
user Startup folder; Linux writes an XDG Autostart `.desktop` file. Both
|
user Startup folder; Linux writes an XDG Autostart `.desktop` file. When
|
||||||
entries pass `--start-in-tray`.
|
`KeepRunningInTray` is enabled the entry passes `--start-in-tray`; when it is
|
||||||
|
off the entry launches the executable without that flag so the main window
|
||||||
|
opens after sign-in.
|
||||||
|
|
||||||
8. Error surfacing:
|
8. Error surfacing:
|
||||||
Background errors (failed JSON saves, cleanup errors) are emitted as
|
Background errors (failed JSON saves, cleanup errors) are emitted as
|
||||||
@@ -159,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
|
resolved duration as an argument, so the runner stays ignorant of the global
|
||||||
config: a positive duration applies the timeout via `context.WithTimeout` and
|
config: a positive duration applies the timeout via `context.WithTimeout` and
|
||||||
reports `Timed out after <timeout>` on expiry; a non-positive duration runs
|
reports `Timed out after <timeout>` on expiry; a non-positive duration runs
|
||||||
without a deadline, bounded only by `ctx` (app shutdown). `StartOnly` jobs run on
|
without a deadline, bounded only by `ctx` (app shutdown). `StartOnly` jobs are
|
||||||
the untimed context and so measure launch latency only, unaffected by the run
|
built on `context.Background()` instead — neither the timeout nor app shutdown
|
||||||
timeout.
|
applies to them — and so measure launch latency only.
|
||||||
|
|
||||||
### Run-time statistics
|
### Run-time statistics
|
||||||
|
|
||||||
@@ -172,8 +240,10 @@ timeout.
|
|||||||
| `RunCount` | total runs recorded |
|
| `RunCount` | total runs recorded |
|
||||||
| `FailCount` | runs that exited non-zero |
|
| `FailCount` | runs that exited non-zero |
|
||||||
| `LastDurationMS` | wall-clock time of the most recent run (launch latency for `StartOnly`) |
|
| `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 |
|
| `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
|
`runner.RunJob` measures the wall-clock start→finish and sets `DurationMS` on
|
||||||
the returned `RunRecord`. `runner/logfile.go` writes a `duration` line into the
|
the returned `RunRecord`. `runner/logfile.go` writes a `duration` line into the
|
||||||
@@ -205,16 +275,28 @@ the moment the window opens.
|
|||||||
### `jobs_view.go` file structure
|
### `jobs_view.go` file structure
|
||||||
|
|
||||||
The size guideline for a file in this project is ~250 lines.
|
The size guideline for a file in this project is ~250 lines.
|
||||||
`src/ui/jobs_view.go` is split across three files along these seams; the view
|
`src/ui/jobs_view.go` is split across six files along these seams:
|
||||||
file itself has grown back over the guideline since — see the split item in
|
|
||||||
[ROADMAP.md](ROADMAP.md), which tracks every file currently over it:
|
|
||||||
|
|
||||||
| File | Contents |
|
| File | Contents |
|
||||||
|------|----------|
|
|------|----------|
|
||||||
| `jobs_view.go` | `newJobsView` — list, toolbar, button wiring, and layout |
|
| `jobs_view.go` | `jobsView` struct — construction, `refresh`, `updateDetails`, the pause control, and layout assembly |
|
||||||
|
| `jobs_view_state.go` | `jobsViewState` — the jobs/runtime snapshot, the folder filter, and the selection |
|
||||||
|
| `jobs_view_list.go` | The sidebar list: row template, row rendering, row mode, and the compact/detailed toggle |
|
||||||
|
| `jobs_view_toolbar.go` | The per-job button row — new, edit, run, pause, delete |
|
||||||
| `jobs_view_details.go` | `detailsPanel` struct — widget creation, `update`, `clear`, `container` |
|
| `jobs_view_details.go` | `detailsPanel` struct — widget creation, `update`, `clear`, `container` |
|
||||||
| `jobs_view_helpers.go` | Pure helpers — `filteredJobIndexes`, `folderOptions`, `filterValue`, `indexOfID`, `lastJobLogs`, `nextJobListView`, `viewToggleText` |
|
| `jobs_view_helpers.go` | Pure helpers — `filteredJobIndexes`, `folderOptions`, `filterValue`, `indexOfID`, `lastJobLogs`, `nextJobListView`, `viewToggleText` |
|
||||||
|
|
||||||
|
The widgets hold no job state of their own: they read `jobsViewState`, which is
|
||||||
|
the only thing that reads the Service. The **selection is a job ID, not a row
|
||||||
|
index.** Every path that changes the job list replaces the state's snapshot —
|
||||||
|
create, delete, and edit from this view's own handlers, adopting a different
|
||||||
|
jobs file from the Service, which the view only learns about through the refresh
|
||||||
|
`JobsLoaded` triggers. An index that outlives its snapshot points at whichever
|
||||||
|
job now sits there, so the details pane would describe one job while the list
|
||||||
|
highlighted another. Rows are derived from the ID at render time
|
||||||
|
(`selectedIndex`, `displayRow`), and `jobsView.refresh` ends by pointing the
|
||||||
|
list's highlight at the selected job.
|
||||||
|
|
||||||
### `settings_view.go` file structure
|
### `settings_view.go` file structure
|
||||||
|
|
||||||
`src/ui/settings_view.go` is split across three files the same way, once its
|
`src/ui/settings_view.go` is split across three files the same way, once its
|
||||||
|
|||||||
+245
-33
@@ -2,6 +2,251 @@
|
|||||||
|
|
||||||
All notable GoSentry changes are recorded in this file.
|
All notable GoSentry changes are recorded in this file.
|
||||||
|
|
||||||
|
## 1.0.3 - 2026-08-07
|
||||||
|
|
||||||
|
**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.**
|
||||||
|
|
||||||
|
**Application:**
|
||||||
|
|
||||||
|
- 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.
|
||||||
|
|
||||||
|
**Jobs:**
|
||||||
|
|
||||||
|
- **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:**
|
||||||
|
|
||||||
|
- **Keep running in the system tray** now controls behaviour: with the tray on
|
||||||
|
(default), closing the window hides it and autostart uses `--start-in-tray`;
|
||||||
|
with the tray off, closing quits the app and autostart opens the main window.
|
||||||
|
- Saving a tray change updates close behaviour and the autostart entry
|
||||||
|
immediately. The notification-area icon follows the saved value after a
|
||||||
|
restart; Settings shows a hint when a restart is needed (Fyne cannot add or
|
||||||
|
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.**
|
||||||
|
|
||||||
|
**Settings:**
|
||||||
|
|
||||||
|
- **The branded GoSentry theme is now the default.** Fresh installs, the
|
||||||
|
**Defaults** button, and configs that omit `theme` all open in the teal/amber
|
||||||
|
look; users who prefer Fyne's built-in theme can still pick **System** in
|
||||||
|
Settings.
|
||||||
|
- The Fyne built-in theme option is labelled **System** (stored as `"system"`);
|
||||||
|
configs that still say `"default"` are read as System and rewritten on save.
|
||||||
|
- The **About** repository link points at GitHub (`mixeme/gosentry`) instead of
|
||||||
|
the private Gitea mirror.
|
||||||
|
|
||||||
|
**Jobs:**
|
||||||
|
|
||||||
|
- The **Disable auto** row gained a top inset matching the gap below it, so it
|
||||||
|
no longer sits flush against the tab bar.
|
||||||
|
|
||||||
|
**Documentation:**
|
||||||
|
|
||||||
|
- The **README Schedules** section now documents `@every` in full: supported Go
|
||||||
|
duration units (`ns` through `h`), combined values such as `1h30m`, the link to
|
||||||
|
`time.ParseDuration`, the fact that days/months/years belong in cron rather
|
||||||
|
than `@every`, the one-second scheduler tick floor, cron examples for monthly
|
||||||
|
and yearly runs, and the `@hourly`/`@daily`/… descriptors.
|
||||||
|
|
||||||
|
**Tests:**
|
||||||
|
|
||||||
|
- Three tests with byte-identical coverage to an existing test and no unique
|
||||||
|
assertion are gone: `TestCleanupLogsKeepsFilesWithinAgeLimit`,
|
||||||
|
`TestRunDueEmptyOverlapInheritsGlobal` (its one unique setup guard moved into
|
||||||
|
`TestRunDueQueueRerunsAfterFinish`), and `TestSameWindowsPathHandlesSpaces`
|
||||||
|
(its spaces case folded into `TestSameWindowsPathIgnoresCaseAndQuotes`'s
|
||||||
|
fixture). So are two that carried no assertion at all:
|
||||||
|
`TestEmitWithNoObserversIsNoop` and `TestStoreReturnsWiredStore`.
|
||||||
|
- The four `TestFilteredJobIndexes*` tests are one table-driven
|
||||||
|
`TestFilteredJobIndexes`, matching `TestFilterValue` above it.
|
||||||
|
- `TestMainViewBuilds` is replaced by `TestMainViewRecordStartupAddsHistoryRow`,
|
||||||
|
which exercises the `recordStartup` closure for both wordings `run.go` selects
|
||||||
|
between and asserts the rows reach the History table through its cell callbacks,
|
||||||
|
including the `!windowShown` branch.
|
||||||
|
- `storage.defaultJobs` — the one accidental 0%-coverage gap the review
|
||||||
|
found — is now exercised by
|
||||||
|
`TestLoadOrCreateJobsSeedsSampleJobsOnFirstRun`, which also corrects
|
||||||
|
`docs/TESTS.md`: `TestLoadOrCreateConfigCreatesDefaultsOnFirstRun` never
|
||||||
|
touched jobs, so the "and a sample job" half of its old description was
|
||||||
|
wrong.
|
||||||
|
- `src/runner/seed_test.go`'s hand-rolled `itoa` — 18 lines of digit-by-digit
|
||||||
|
conversion in a file that already imports `strconv` — is replaced with
|
||||||
|
`strconv.FormatInt`.
|
||||||
|
- **`docs/TESTS.md`** records the `-coverpkg` command and the 84.4% baseline
|
||||||
|
(per-package figures understate the suite), design principle 9 (redundancy is
|
||||||
|
judged by comparing coverage profiles, and identical coverage alone is not
|
||||||
|
grounds for deletion), a table of the look-alike tests that are kept with the
|
||||||
|
reason each survives, and the list of functions deliberately at 0%.
|
||||||
|
- **`docs/STANDARDS.md`**'s "Intentional behavior" section points at both lists,
|
||||||
|
so the mechanism `docs/REVIEW.md` describes still reaches them. The spent test
|
||||||
|
review plan is retired.
|
||||||
|
|
||||||
## 1.0.0 - 2026-07-27
|
## 1.0.0 - 2026-07-27
|
||||||
|
|
||||||
**The window opens at the size it asks for, and the Jobs divider can be
|
**The window opens at the size it asks for, and the Jobs divider can be
|
||||||
@@ -38,12 +283,6 @@ dragged.**
|
|||||||
|
|
||||||
**Settings:**
|
**Settings:**
|
||||||
|
|
||||||
- **The branded GoSentry theme is now the default.** Fresh installs, the
|
|
||||||
**Defaults** button, and configs that omit `theme` all open in the teal/amber
|
|
||||||
look; users who prefer Fyne's built-in theme can still pick **System** in
|
|
||||||
Settings.
|
|
||||||
- The Fyne built-in theme option is labelled **System** (stored as `"system"`);
|
|
||||||
configs that still say `"default"` are read as System and rewritten on save.
|
|
||||||
- The **Save / Cancel / Restore defaults** row sits 4 px from the left edge, as
|
- The **Save / Cancel / Restore defaults** row sits 4 px from the left edge, as
|
||||||
its layout always intended, rather than 8.
|
its layout always intended, rather than 8.
|
||||||
- The caption column is as wide as the widest caption instead of a fixed width,
|
- The caption column is as wide as the widest caption instead of a fixed width,
|
||||||
@@ -57,16 +296,9 @@ dragged.**
|
|||||||
the rows above have to give but a dropdown — which paints its box out to the
|
the rows above have to give but a dropdown — which paints its box out to the
|
||||||
row's edge — does not, so the gap collapsed to about a pixel. The Theme row
|
row's edge — does not, so the gap collapsed to about a pixel. The Theme row
|
||||||
now keeps the same gap the checkbox rows have.
|
now keeps the same gap the checkbox rows have.
|
||||||
- The **About** repository link points at GitHub (`mixeme/gosentry`) instead of
|
|
||||||
the private Gitea mirror.
|
|
||||||
|
|
||||||
**Documentation:**
|
**Documentation:**
|
||||||
|
|
||||||
- The **README Schedules** section now documents `@every` in full: supported Go
|
|
||||||
duration units (`ns` through `h`), combined values such as `1h30m`, the link to
|
|
||||||
`time.ParseDuration`, the fact that days/months/years belong in cron rather
|
|
||||||
than `@every`, the one-second scheduler tick floor, cron examples for monthly
|
|
||||||
and yearly runs, and the `@hourly`/`@daily`/… descriptors.
|
|
||||||
- The **README** describes the application that exists. Its `gosentry.json`
|
- The **README** describes the application that exists. Its `gosentry.json`
|
||||||
sample was three keys short of what the app writes on first run, which made
|
sample was three keys short of what the app writes on first run, which made
|
||||||
the one file the user is invited to hand-edit the least accurate thing in the
|
the one file the user is invited to hand-edit the least accurate thing in the
|
||||||
@@ -104,24 +336,6 @@ dragged.**
|
|||||||
split in one pass during the next whole-project review, since six separate
|
split in one pass during the next whole-project review, since six separate
|
||||||
passes would settle the same seam question six ways.
|
passes would settle the same seam question six ways.
|
||||||
|
|
||||||
**Tests:**
|
|
||||||
|
|
||||||
- Three tests with byte-identical coverage to an existing test and no unique
|
|
||||||
assertion are gone: `TestCleanupLogsKeepsFilesWithinAgeLimit`,
|
|
||||||
`TestRunDueEmptyOverlapInheritsGlobal` (its one unique setup guard moved into
|
|
||||||
`TestRunDueQueueRerunsAfterFinish`), and `TestSameWindowsPathHandlesSpaces`
|
|
||||||
(its spaces case folded into `TestSameWindowsPathIgnoresCaseAndQuotes`'s
|
|
||||||
fixture).
|
|
||||||
- `storage.defaultJobs` — the one accidental 0%-coverage gap the review
|
|
||||||
found — is now exercised by
|
|
||||||
`TestLoadOrCreateJobsSeedsSampleJobsOnFirstRun`, which also corrects
|
|
||||||
`docs/TESTS.md`: `TestLoadOrCreateConfigCreatesDefaultsOnFirstRun` never
|
|
||||||
touched jobs, so the "and a sample job" half of its old description was
|
|
||||||
wrong.
|
|
||||||
- `src/runner/seed_test.go`'s hand-rolled `itoa` — 18 lines of digit-by-digit
|
|
||||||
conversion in a file that already imports `strconv` — is replaced with
|
|
||||||
`strconv.FormatInt`.
|
|
||||||
|
|
||||||
## 0.15.0 - 2026-07-26
|
## 0.15.0 - 2026-07-26
|
||||||
|
|
||||||
**Settings points at the jobs file itself, not the folder holding it.**
|
**Settings points at the jobs file itself, not the folder holding it.**
|
||||||
@@ -184,8 +398,6 @@ button for the logs folder.**
|
|||||||
|
|
||||||
**Jobs sidebar:**
|
**Jobs sidebar:**
|
||||||
|
|
||||||
- The **Disable auto** row gained a top inset matching the gap below it, so it
|
|
||||||
no longer sits flush against the tab bar.
|
|
||||||
- The **Folder** caption moved onto the filter row itself, beside the select and
|
- The **Folder** caption moved onto the filter row itself, beside the select and
|
||||||
the view toggle, instead of occupying its own line above it — the job list now
|
the view toggle, instead of occupying its own line above it — the job list now
|
||||||
starts a full label higher.
|
starts a full label higher.
|
||||||
|
|||||||
+3
-2
@@ -280,8 +280,9 @@ Before tagging:
|
|||||||
|
|
||||||
1. Bump `src/app/version.go`. The tag must match it exactly.
|
1. Bump `src/app/version.go`. The tag must match it exactly.
|
||||||
2. Add the version's [CHANGELOG.md](CHANGELOG.md) section.
|
2. Add the version's [CHANGELOG.md](CHANGELOG.md) section.
|
||||||
3. Retake the README screenshots (`images/screenshot_jobs.PNG`,
|
3. Retake the README screenshots (`docs/screenshots/screenshot_jobs.PNG`,
|
||||||
`images/screenshot_settings.PNG`) if the GUI changed its appearance. This is
|
`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
|
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,
|
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
|
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).
|
|
||||||
+82
-19
@@ -5,6 +5,64 @@ Completed work is recorded in [CHANGELOG.md](CHANGELOG.md), not here.
|
|||||||
|
|
||||||
## Open Items
|
## 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.
|
||||||
|
There is no supported way to register or remove the notification-area icon
|
||||||
|
after the process is running.
|
||||||
|
|
||||||
|
GoSentry now honours `KeepRunningInTray` from config: close behaviour and the
|
||||||
|
autostart entry update immediately when the user saves Settings; the tray icon
|
||||||
|
follows the saved value on the next launch. Settings shows a restart hint when
|
||||||
|
the tray checkbox changes.
|
||||||
|
|
||||||
|
Revisit when Fyne adds a documented API for mid-session tray registration, or
|
||||||
|
when a stable cross-platform approach exists without reaching into driver
|
||||||
|
internals. Until then, removing the restart hint and applying the icon on save
|
||||||
|
is blocked.
|
||||||
|
|
||||||
### Update check from GitHub releases
|
### Update check from GitHub releases
|
||||||
|
|
||||||
Releases are published as GitHub Releases (tags like `v0.12.0`, built by
|
Releases are published as GitHub Releases (tags like `v0.12.0`, built by
|
||||||
@@ -85,24 +143,25 @@ Design notes / open questions:
|
|||||||
|
|
||||||
[ARCHITECTURE.md](ARCHITECTURE.md) sets a ~250-line guideline per source file
|
[ARCHITECTURE.md](ARCHITECTURE.md) sets a ~250-line guideline per source file
|
||||||
and records the `jobs_view.go` and `settings_view.go` splits as the worked
|
and records the `jobs_view.go` and `settings_view.go` splits as the worked
|
||||||
examples. Six non-test files are over it at 1.0.0, including both files that
|
examples. `jobs_view.go` was split again in 1.0.3 — into view, state, list, and
|
||||||
were already split once:
|
toolbar — because the selection defect it carried was a symptom of the size
|
||||||
|
(one 330-line constructor over seven shared locals). Six non-test files are
|
||||||
|
over the guideline:
|
||||||
|
|
||||||
| File | Lines |
|
| File | Lines |
|
||||||
|------|-------|
|
|------|-------|
|
||||||
| `src/app/operations.go` | 490 |
|
| `src/app/operations.go` | 529 |
|
||||||
| `src/ui/jobs_view.go` | 355 |
|
| `src/storage/store.go` | 382 |
|
||||||
| `src/app/run.go` | 287 |
|
| `src/ui/history_view.go` | 355 |
|
||||||
| `src/ui/history_view.go` | 282 |
|
| `src/ui/settings_view.go` | 326 |
|
||||||
| `src/ui/settings_view.go` | 277 |
|
| `src/app/run.go` | 275 |
|
||||||
| `src/storage/store.go` | 265 |
|
| `src/app/service.go` | 252 |
|
||||||
|
|
||||||
This is deliberately deferred to the next whole-project review rather than done
|
The remaining six are deliberately deferred rather than done piecemeal: a
|
||||||
piecemeal: [REVIEW.md](REVIEW.md) already asks item 2 to look for exactly this,
|
split touches every reader of the file, and doing them in one pass keeps the
|
||||||
a split touches every reader of the file, and doing all six in one pass keeps
|
seams consistent instead of settling each one its own way. Splitting is
|
||||||
the seams consistent instead of settling them six different ways. Splitting is
|
|
||||||
also the kind of change that reads as pure movement while quietly dropping a
|
also the kind of change that reads as pure movement while quietly dropping a
|
||||||
function, so it wants one careful pass, not six hurried ones.
|
function, so it wants one careful pass, not a hurried one per file.
|
||||||
|
|
||||||
Seams visible today, as a starting point rather than a decision:
|
Seams visible today, as a starting point rather than a decision:
|
||||||
|
|
||||||
@@ -114,13 +173,17 @@ Seams visible today, as a starting point rather than a decision:
|
|||||||
- **`history_view.go`** — the column-measuring helpers (`textWidth` through
|
- **`history_view.go`** — the column-measuring helpers (`textWidth` through
|
||||||
`historyColumnWidths`) are pure, already unit-tested, and independent of the
|
`historyColumnWidths`) are pure, already unit-tested, and independent of the
|
||||||
table they size.
|
table they size.
|
||||||
- **`jobs_view.go`** — nearly all of it is one `newJobsView` constructor, so the
|
- **`store.go`** — path resolution, the config load/normalize path, and the jobs
|
||||||
split has to break that function up (list template, toolbar handlers,
|
load/normalize path are three separate concerns in one file.
|
||||||
assembly) rather than move whole functions. Larger judgement call than the
|
- **`run.go`**, **`settings_view.go`**, **`service.go`** — barely over. Worth
|
||||||
others.
|
|
||||||
- **`run.go`**, **`settings_view.go`**, **`store.go`** — barely over. Worth
|
|
||||||
re-measuring at the time; if a pass elsewhere has shrunk them, leave them
|
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
|
Scope note: the guideline is about source files. Test files are much larger and
|
||||||
that is fine — a table-driven test file grows with the cases it covers.
|
that is fine — a table-driven test file grows with the cases it covers.
|
||||||
|
|||||||
+69
-2
@@ -1,8 +1,7 @@
|
|||||||
# GoSentry — Standards
|
# GoSentry — Standards
|
||||||
|
|
||||||
Quality rules and intentional behavior for contributors. Package contracts live
|
Quality rules and intentional behavior for contributors. Package contracts live
|
||||||
in [ARCHITECTURE.md](ARCHITECTURE.md); test conventions in [TESTS.md](TESTS.md);
|
in [ARCHITECTURE.md](ARCHITECTURE.md); test conventions in [TESTS.md](TESTS.md).
|
||||||
what a whole-project review looks at, in [REVIEW.md](REVIEW.md).
|
|
||||||
|
|
||||||
## Code quality
|
## Code quality
|
||||||
|
|
||||||
@@ -12,6 +11,15 @@ what a whole-project review looks at, in [REVIEW.md](REVIEW.md).
|
|||||||
- Fixes with severity ≥ medium → regression test.
|
- Fixes with severity ≥ medium → regression test.
|
||||||
- Documented intentional behavior → section below, not a backlog bug.
|
- Documented intentional behavior → section below, not a backlog bug.
|
||||||
- UI view constructors accept `*app.Service`; call `app.Open()` only from `run.go`.
|
- UI view constructors accept `*app.Service`; call `app.Open()` only from `run.go`.
|
||||||
|
- **No blocking file I/O under `Service.mu`.** It is the lock the Fyne main
|
||||||
|
thread takes on every `Jobs()` and `Runtime()` call, so a JSON write, a
|
||||||
|
log-directory scan, or a pass over every log header inside it makes a UI
|
||||||
|
refresh wait on the disk. Mutate state under the lock, snapshot what the I/O
|
||||||
|
needs, and run the I/O after `mu.Unlock()` — the way `emit()` already is.
|
||||||
|
Store writes go through `Service.deferSaveLocked` and `Store.PrepareSaveJobs` /
|
||||||
|
`Store.PrepareSaveConfig`, which take `saveMu` while `mu` is still held so
|
||||||
|
writes still reach the file in the order their snapshots were taken; log
|
||||||
|
cleanup and `runner.SeedStats` run from plain snapshots.
|
||||||
- A size that must follow the theme is **measured at build time, not written as
|
- A size that must follow the theme is **measured at build time, not written as
|
||||||
a pixel constant.** `theme.Padding()` and text metrics depend on the running
|
a pixel constant.** `theme.Padding()` and text metrics depend on the running
|
||||||
app's theme, text size, and DPI, so a hand-tuned number is only correct for
|
app's theme, text size, and DPI, so a hand-tuned number is only correct for
|
||||||
@@ -63,13 +71,72 @@ change to their shape has to stay compatible on its own.
|
|||||||
= 0) and is overridable per job (`Job.TimeoutSeconds *int`: unset = inherit the
|
= 0) and is overridable per job (`Job.TimeoutSeconds *int`: unset = inherit the
|
||||||
global default, 0 = no timeout, positive = seconds). Neither zero may be
|
global default, 0 = no timeout, positive = seconds). Neither zero may be
|
||||||
normalized away on load — 0 is a value, not a missing field.
|
normalized away on load — 0 is a value, not a missing field.
|
||||||
|
- **`Config.MaxLogFiles` and `Config.MaxLogAgeDays` of 0 mean "keep everything",
|
||||||
|
not "unset".** `runner.CleanupLogs` already treated `<= 0` as "policy
|
||||||
|
disabled"; `app.validateConfig` and the Settings form now accept 0 (only a
|
||||||
|
negative count is rejected), and `storage.loadOrCreateConfig` no longer
|
||||||
|
backfills 0 to 100 / 30 — a config written before either field existed still
|
||||||
|
picks up the default because `json.Unmarshal` leaves an absent key holding
|
||||||
|
whatever `DefaultConfig()` set, the same mechanism `DefaultTimeoutSeconds`
|
||||||
|
relies on.
|
||||||
|
- **A `StartOnly` process is expected to outlive GoSentry.** The option exists to
|
||||||
|
launch something and let go of it, so the runner builds that invocation on
|
||||||
|
`context.Background()`, not on the application's lifecycle context: quitting
|
||||||
|
GoSentry (or cancelling a run) does not stop a process it started this way, and
|
||||||
|
`Service.Stop()` reaches only jobs the runner is still waiting on. The
|
||||||
|
uncancelable context is also what keeps `os/exec` from leaving a watcher
|
||||||
|
goroutine per run — it only starts one when the context can be done, and
|
||||||
|
`StartOnly` never calls `Wait` to end it.
|
||||||
- **History tab is session-only.** `JobRuntime.Logs` exists only in memory for the
|
- **History tab is session-only.** `JobRuntime.Logs` exists only in memory for the
|
||||||
current process. Log files on disk feed aggregate statistics via `SeedStats`
|
current process. Log files on disk feed aggregate statistics via `SeedStats`
|
||||||
only. See [ARCHITECTURE.md](ARCHITECTURE.md).
|
only. See [ARCHITECTURE.md](ARCHITECTURE.md).
|
||||||
|
- **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
|
- Several tests share a coverage profile with another test on purpose, and a few
|
||||||
functions sit at 0% on purpose. Both lists live in
|
functions sit at 0% on purpose. Both lists live in
|
||||||
[TESTS.md](TESTS.md) — check them before reporting a test as redundant or a
|
[TESTS.md](TESTS.md) — check them before reporting a test as redundant or a
|
||||||
coverage gap as an oversight.
|
coverage gap as an oversight.
|
||||||
|
- **`KeepRunningInTray` controls tray and close behavior.** When enabled (the
|
||||||
|
default), the app registers a system tray icon at launch, closing the window
|
||||||
|
hides it, and autostart passes `--start-in-tray`. When disabled, no tray icon
|
||||||
|
is registered at launch, closing the window quits the app, and autostart opens
|
||||||
|
the main window. Toggling the setting in Settings updates close behavior and
|
||||||
|
rewrites the autostart entry immediately; the tray icon itself follows the
|
||||||
|
saved value only after a restart because Fyne has no API to add or remove it
|
||||||
|
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
|
## Out of scope
|
||||||
|
|
||||||
|
|||||||
+122
-15
@@ -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
|
that means the MSYS2 UCRT64 toolchain described in
|
||||||
[DEVELOPMENT.md](DEVELOPMENT.md).
|
[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
|
### Manual test commands
|
||||||
|
|
||||||
Run all tests:
|
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:
|
covered by the `app` tests. Measure the engine packages together instead:
|
||||||
|
|
||||||
```bash
|
```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
|
In the PowerShell environment DEVELOPMENT.md prescribes on Windows, PowerShell
|
||||||
against before concluding that coverage has slipped.
|
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.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@@ -93,13 +119,13 @@ Tests schedule parsing and validation.
|
|||||||
|
|
||||||
**Package:** `domain`
|
**Package:** `domain`
|
||||||
|
|
||||||
Tests the normalization rule shared by every consumer of the jobs-list density
|
Tests autostart argument helpers and the jobs-list density normalization rule.
|
||||||
setting.
|
|
||||||
|
|
||||||
| Test | Purpose |
|
| Test | Purpose |
|
||||||
|------|---------|
|
|------|---------|
|
||||||
|
| `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. |
|
| `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. |
|
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@@ -136,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. |
|
| `TestDeleteJobNotFound` | Verifies that `DeleteJob` returns an error for an unknown job ID. |
|
||||||
| `TestSetEnabledNotFound` | Verifies that `SetEnabled` 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. |
|
| `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
|
#### Global pause / run-now / run-due
|
||||||
|
|
||||||
@@ -165,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`. |
|
| `TestUpdateSettingsAdoptsExistingJobsFile` | Verifies that selecting a jobs file that already exists replaces the job list with its contents, rebuilds runtimes, and emits `JobsLoaded`. |
|
||||||
| `TestUpdateSettingsKeepsJobsWhenTheNewFileIsMissing` | Verifies that a path with no file behind it receives the current jobs instead (the rename/relocate case). |
|
| `TestUpdateSettingsKeepsJobsWhenTheNewFileIsMissing` | Verifies that a path with no file behind it receives the current jobs instead (the rename/relocate case). |
|
||||||
| `TestUpdateSettingsRefusesJobsFileSwitchWhileRunning` | Verifies that switching the jobs file is refused (and not persisted) while a job runs, while unrelated settings still save. |
|
| `TestUpdateSettingsRefusesJobsFileSwitchWhileRunning` | Verifies that switching the jobs file is refused (and not persisted) while a job runs, while unrelated settings still save. |
|
||||||
|
| `TestUpdateSettingsSeedsAdoptedJobsFromLogs` | Verifies that statistics reconstructed from the new logs directory still reach the runtime map, now that the log scan happens before `UpdateSettings` takes `mu`. |
|
||||||
|
| `TestConcurrentJobOperationsLeaveTheFileMatchingMemory` | Verifies that saves prepared under `mu` and run after it is released still land in mutation order, so `jobs.json` matches the in-memory list after concurrent create/disable operations. |
|
||||||
| `TestSetJobListViewPersistsToConfigFile` | Verifies the Jobs-list density preference reaches `gosentry.json`, so the chosen view reopens after a restart. |
|
| `TestSetJobListViewPersistsToConfigFile` | Verifies the Jobs-list density preference reaches `gosentry.json`, so the chosen view reopens after a restart. |
|
||||||
| `TestSetJobListViewNormalizesUnknownValue` | Verifies anything but `"compact"` is stored as `"detailed"`, so the config never gains a value no reader understands. |
|
| `TestSetJobListViewNormalizesUnknownValue` | Verifies anything but `"compact"` is stored as `"detailed"`, so the config never gains a value no reader understands. |
|
||||||
| `TestPrependLogCapsActivityList` | Verifies that the activity log never grows beyond its maximum cap. |
|
| `TestPrependLogCapsActivityList` | Verifies that the activity log never grows beyond its maximum cap. |
|
||||||
@@ -187,11 +216,11 @@ and scheduler edge cases using injected `runJob` and `primeDue`.
|
|||||||
| `TestRunDueSkipDropsOverlap` | Global skip: no second concurrent run, `PendingRuns` stays 0. |
|
| `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. |
|
| `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. |
|
| `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`. |
|
| `TestRunDuePerJobQueueOverridesGlobalSkip` | Per-job `queue` beats global `skip`. |
|
||||||
| `TestRunDuePerJobSkipOverridesGlobalQueue` | Per-job `skip` beats global `queue`. |
|
| `TestRunDuePerJobSkipOverridesGlobalQueue` | Per-job `skip` beats global `queue`. |
|
||||||
| `TestRunNowSequentialGuard` | Manual run refused while another job runs in sequential mode. |
|
| `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, and pausing clears the backlog rather than leaving it to fire a stale deferred run on resume. |
|
||||||
| `TestRunDueQueueDrainSkippedWhenPaused` | Queued overlaps are not drained while the scheduler is paused. |
|
|
||||||
| `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. |
|
| `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. |
|
||||||
|
|
||||||
---
|
---
|
||||||
@@ -220,8 +249,8 @@ Tests display-formatting helpers used by the UI.
|
|||||||
| `TestStatusText` | Verifies that job status codes map to the correct display strings. |
|
| `TestStatusText` | Verifies that job status codes map to the correct display strings. |
|
||||||
| `TestEventText` | Verifies trigger-type labels for scheduled, manual, and UI triggers. |
|
| `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. |
|
| `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". |
|
| `TestDisplayFolder` | Verifies that an empty folder string shows "(No folder)". |
|
||||||
| `TestDisplayArguments` | Verifies that an empty arguments string shows "None". |
|
| `TestDisplayArguments` | Verifies that an empty arguments string shows "(none)". |
|
||||||
| `TestDisplayRunMode` | Verifies run-mode labels for normal and start-only modes. |
|
| `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. |
|
| `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. |
|
| `TestDisplayIndex` | Verifies the list position of a job index in a filtered index slice. |
|
||||||
@@ -242,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. |
|
| `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. |
|
| `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. |
|
| `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. |
|
| `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`. |
|
| `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. |
|
| `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. |
|
| `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. |
|
| `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. |
|
| `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. |
|
| `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. |
|
| `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. |
|
| `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. |
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@@ -318,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. |
|
| `TestRunJobStartOnlyDoesNotWaitForExitCode` | Verifies that `StartOnly: true` jobs launch and return "OK" immediately without waiting for the process to exit. |
|
||||||
| `TestRunJobStartOnlyReportsStartFailure` | Verifies that `StartOnly: true` jobs still report "Failed" if the process cannot be started. |
|
| `TestRunJobStartOnlyReportsStartFailure` | Verifies that `StartOnly: true` jobs still report "Failed" if the process cannot be started. |
|
||||||
|
| `TestRunJobStartOnlyLeavesNoContextWatcher` | Verifies that a start-only run leaves no `os/exec` context-watcher goroutine behind, since it never calls `Wait` and the started process is meant to outlive the app. |
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@@ -334,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. |
|
| `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. |
|
| `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. |
|
| `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. |
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@@ -372,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
|
### src/platform/autostart/autostart_windows_test.go
|
||||||
|
|
||||||
**Location:** `src/platform/autostart/autostart_windows_test.go`
|
**Location:** `src/platform/autostart/autostart_windows_test.go`
|
||||||
@@ -387,6 +435,8 @@ Tests Windows autostart via shortcuts in the Startup folder.
|
|||||||
| `TestStartupShortcutPathUsesUserStartupFolder` | Verifies that the shortcut path resolves into `%APPDATA%\Microsoft\Windows\Start Menu\Programs\Startup`. |
|
| `TestStartupShortcutPathUsesUserStartupFolder` | Verifies that the shortcut path resolves into `%APPDATA%\Microsoft\Windows\Start Menu\Programs\Startup`. |
|
||||||
| `TestCreateStartupShortcutHandlesCyrillicPath` | Verifies that `.lnk` files are created correctly when the executable path contains Cyrillic characters. |
|
| `TestCreateStartupShortcutHandlesCyrillicPath` | Verifies that `.lnk` files are created correctly when the executable path contains Cyrillic characters. |
|
||||||
| `TestCreateStartupShortcutHandlesSpaces` | Verifies that `.lnk` files are created with correct `TargetPath` and `--start-in-tray` arguments when the path contains spaces. |
|
| `TestCreateStartupShortcutHandlesSpaces` | Verifies that `.lnk` files are created with correct `TargetPath` and `--start-in-tray` arguments when the path contains spaces. |
|
||||||
|
| `TestCreateStartupShortcutWithoutTrayFlag` | Verifies that autostart shortcuts omit `--start-in-tray` when the tray setting is off. |
|
||||||
|
| `TestAutostartStatusRequiresMatchingTrayFlag` | Verifies `AutostartStatus` reports a problem when the shortcut arguments do not match `KeepRunningInTray`. |
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@@ -400,6 +450,19 @@ Tests Linux autostart via XDG Desktop Entry files.
|
|||||||
| Test | Purpose |
|
| Test | Purpose |
|
||||||
|------|---------|
|
|------|---------|
|
||||||
| `TestLinuxAutostartStartsInTray` | Verifies that the XDG Desktop Entry is created with `--start-in-tray` in the `Exec=` field. |
|
| `TestLinuxAutostartStartsInTray` | Verifies that the XDG Desktop Entry is created with `--start-in-tray` in the `Exec=` field. |
|
||||||
|
| `TestLinuxAutostartWithoutTrayFlag` | Verifies that the desktop entry omits `--start-in-tray` when the tray setting is off. |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### src/ui/tray_test.go
|
||||||
|
|
||||||
|
**Package:** `ui`
|
||||||
|
|
||||||
|
Tests startup helpers for tray and autostart interaction.
|
||||||
|
|
||||||
|
| Test | Purpose |
|
||||||
|
|------|---------|
|
||||||
|
| `TestResolveStartHiddenUsesDomainHelper` | Verifies the UI startup helper stays aligned with `domain.ResolveStartHidden`. |
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@@ -452,11 +515,34 @@ widgets are assembled.
|
|||||||
| `TestJobListViewCompactConfigOpensCompact` | Verifies the persisted density is honoured at build time, not only after a tap. |
|
| `TestJobListViewCompactConfigOpensCompact` | Verifies the persisted density is honoured at build time, not only after a tap. |
|
||||||
| `TestJobsSidebarWidthIsItsContent` | Regression guard: nothing but the sidebar's own toolbar row imposes a width floor on it. |
|
| `TestJobsSidebarWidthIsItsContent` | Regression guard: nothing but the sidebar's own toolbar row imposes a width floor on it. |
|
||||||
| `TestJobsSplitOpensAtTheSidebarWidth` | Verifies the derived split offset opens the divider at the sidebar's own width at the default window size — enough that the toolbar is never born clipped, and no more. |
|
| `TestJobsSplitOpensAtTheSidebarWidth` | Verifies the derived split offset opens the divider at the sidebar's own width at the default window size — enough that the toolbar is never born clipped, and no more. |
|
||||||
| `TestToolbarButtonRedrawsRowAndDetails` | Regression guard: with the duplicate refreshes removed from the handlers, `refreshView` alone must re-snapshot the jobs and repopulate the details pane. |
|
| `TestToolbarButtonRedrawsRowAndDetails` | Regression guard: with the duplicate refreshes removed from the handlers, `jobsView.refresh` alone must re-snapshot the jobs and repopulate the details pane. |
|
||||||
|
| `TestJobsViewSelectionSurvivesAJobsFileSwitch` | Regression guard: adopting a different jobs file replaces the whole list from the Service, and the refresh that follows must leave the details pane and the list highlight describing the same job — not redraw the pane from a row index that belonged to the previous list. |
|
||||||
| `TestDetailCaptionWidthCoversEveryCaption` | Verifies every caption `metadataRows` returns fits the measured caption column, which is what makes the single row list self-enforcing. |
|
| `TestDetailCaptionWidthCoversEveryCaption` | Verifies every caption `metadataRows` returns fits the measured caption column, which is what makes the single row list self-enforcing. |
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
### src/ui/jobs_view_state_test.go
|
||||||
|
|
||||||
|
**Package:** `ui`
|
||||||
|
|
||||||
|
Tests `jobsViewState`, the Jobs tab's model: the job/runtime snapshot, the
|
||||||
|
folder filter, and the ID-based selection. No Fyne app is built — the state
|
||||||
|
touches no widgets, so these run in milliseconds.
|
||||||
|
|
||||||
|
| Test | Purpose |
|
||||||
|
|------|---------|
|
||||||
|
| `TestJobsViewStateSelectsTheFirstJob` | Verifies the opening state selects the first row, so the details pane is never blank when there is something to show. |
|
||||||
|
| `TestJobsViewStateEmptyListSelectsNothing` | Verifies an empty job list leaves nothing selected and no row to highlight (`displayRow` = -1). |
|
||||||
|
| `TestJobsViewStateSelectionFollowsTheJobNotTheRow` | Regression guard: a job removed above the selected one (through the Service, the way an external change reaches the view) must not slide the selection onto its neighbour — the selection is a job ID, and only its row moves. |
|
||||||
|
| `TestJobsViewStateDropsSelectionWhenItsJobIsGone` | Verifies a selection whose job no longer exists falls back to the first visible row instead of describing whichever job inherited its position. |
|
||||||
|
| `TestJobsViewStateApplyFilter` | Verifies the folder filter keeps a selection it still shows, moves it to the folder's first row when it does not, and that "No folder" matches the job without one. |
|
||||||
|
| `TestJobsViewStateEmptyFilterSelectsNothing` | Verifies a filter matching no job is a filter choice, not an error state: nothing selected, nothing highlighted, and the selection returns when the filter is cleared. |
|
||||||
|
| `TestJobsViewStateHiddenSelectionIsNotHighlighted` | Verifies a selected job the filter hides reports no display row rather than falling back to row 0, which would highlight an unrelated job. |
|
||||||
|
| `TestJobsViewStateRuntimeIsNeverNil` | Verifies `runtime` returns an empty `JobRuntime` for a job the Service has none for, so callers need no nil check. |
|
||||||
|
| `TestJobsViewStateJobAtRejectsRowsOutsideTheFilter` | Verifies row lookups are bounded by the filtered rows, which is what the list widget draws from. |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
### src/ui/history_view_test.go
|
### src/ui/history_view_test.go
|
||||||
|
|
||||||
**Package:** `ui`
|
**Package:** `ui`
|
||||||
@@ -466,8 +552,6 @@ column-width behaviour of the assembled table.
|
|||||||
|
|
||||||
| Test | Purpose |
|
| 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`. |
|
| `TestHistoryCellText` | Verifies table cell text for all columns; empty trigger → `Unknown`. |
|
||||||
| `TestLogFileName` | Verifies log path basename extraction on Windows and Unix paths. |
|
| `TestLogFileName` | Verifies log path basename extraction on Windows and Unix paths. |
|
||||||
| `TestNewEventUsesConsistentTimestampShape` | Verifies UI events use the same timestamp layout as run records. |
|
| `TestNewEventUsesConsistentTimestampShape` | Verifies UI events use the same timestamp layout as run records. |
|
||||||
@@ -478,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. |
|
| `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. |
|
| `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. |
|
| `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. |
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@@ -538,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
|
## Test Design Principles
|
||||||
|
|
||||||
1. **Isolation** — Tests use `t.TempDir()` for file operations and `t.Setenv()` for environment variables to avoid affecting system state.
|
1. **Isolation** — Tests use `t.TempDir()` for file operations and `t.Setenv()` for environment variables to avoid affecting system state.
|
||||||
@@ -587,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.
|
intentional; none is an oversight to be "fixed" with a test.
|
||||||
|
|
||||||
- The real `Clock` — a fake is injected everywhere it is used.
|
- 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.
|
- `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 — OS integration, driven only on a real desktop.
|
- 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.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
|
REM Move to repository root
|
||||||
cd /d "%~dp0\.."
|
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 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 expected by the Windows build; prepending it keeps the script self-contained
|
||||||
REM without permanently changing the user's system PATH.
|
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
|
// Event is something the Service did to its state that observers may want to
|
||||||
// react to. It is a sealed interface: the concrete types in this file are the
|
// react to. It is a sealed interface: the concrete types in this file are the
|
||||||
// only implementations (enforced by the unexported isEvent marker), so a UI
|
// only implementations (enforced by the unexported isEvent marker), so an
|
||||||
// listener can exhaustively type-switch over them and the compiler will flag a
|
// Event handed to an Observer is always one of the types declared here — a
|
||||||
// new event type that a switch forgot to handle.
|
// caller outside this package cannot manufacture a new one. Go's type switch
|
||||||
|
// has no exhaustiveness check, so sealing buys that guarantee, not a
|
||||||
|
// compile-time warning when a new event type is added and a listener forgets
|
||||||
|
// to handle it; the listener still has to be updated by hand.
|
||||||
//
|
//
|
||||||
// Events replace the old single onChange callback. Instead of the scheduler
|
// Events replace the old single onChange callback. Instead of the scheduler
|
||||||
// reaching into the GUI, the Service emits typed events and the UI subscribes —
|
// reaching into the GUI, the Service emits typed events and the UI subscribes —
|
||||||
|
|||||||
+18
-3
@@ -82,14 +82,29 @@ func DisplayInvocation(job domain.Job) string {
|
|||||||
return job.Command + " " + strings.ReplaceAll(strings.TrimSpace(job.Arguments), "\n", " ")
|
return job.Command + " " + strings.ReplaceAll(strings.TrimSpace(job.Arguments), "\n", " ")
|
||||||
}
|
}
|
||||||
|
|
||||||
// DisplayStats returns a one-line execution-time summary for a job runtime.
|
// DisplayStats returns a one-line execution-time summary for a job runtime,
|
||||||
// Returns "No runs recorded" when no runs have been counted yet.
|
// 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 {
|
func DisplayStats(rt *domain.JobRuntime) string {
|
||||||
if rt == nil || rt.RunCount == 0 {
|
if rt == nil || rt.RunCount == 0 {
|
||||||
|
if rt != nil && rt.PendingRuns > 0 {
|
||||||
|
return "No runs recorded" + pendingRunsSuffix(rt.PendingRuns)
|
||||||
|
}
|
||||||
return "No runs recorded"
|
return "No runs recorded"
|
||||||
}
|
}
|
||||||
return fmt.Sprintf("%d runs, %d failed, last %d ms, avg %d ms, max %d ms",
|
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
|
// 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 {
|
if got := DisplayStats(rtNoFail); got != wantNoFail {
|
||||||
t.Errorf("DisplayStats no-fail = %q, want %q", 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) {
|
func TestEventLine(t *testing.T) {
|
||||||
|
|||||||
+77
-38
@@ -43,15 +43,23 @@ func (s *Service) CreateJob(job domain.Job) (domain.Job, error) {
|
|||||||
s.parseScheduleLocked(&job)
|
s.parseScheduleLocked(&job)
|
||||||
record := uiRecord(job.ID, job.Name, "Created", "Job was added")
|
record := uiRecord(job.ID, job.Name, "Created", "Job was added")
|
||||||
prependLog(runtime, record)
|
prependLog(runtime, record)
|
||||||
err := s.store.SaveJobs(s.jobs)
|
save := s.deferSaveLocked(s.store.PrepareSaveJobs(s.jobs))
|
||||||
if err != nil {
|
s.mu.Unlock()
|
||||||
s.jobs = s.jobs[:len(s.jobs)-1]
|
|
||||||
|
if err := save(); err != nil {
|
||||||
|
// The write is atomic, so a failure left the file holding the previous
|
||||||
|
// list: take the job back out so memory matches what is on disk. Another
|
||||||
|
// operation may have run in between, so it is removed by ID rather than by
|
||||||
|
// truncating the slice.
|
||||||
|
s.mu.Lock()
|
||||||
|
if index := s.indexByIDLocked(job.ID); index >= 0 {
|
||||||
|
s.jobs = append(s.jobs[:index], s.jobs[index+1:]...)
|
||||||
|
}
|
||||||
delete(s.runtimes, job.ID)
|
delete(s.runtimes, job.ID)
|
||||||
delete(s.schedules, job.ID)
|
delete(s.schedules, job.ID)
|
||||||
s.mu.Unlock()
|
s.mu.Unlock()
|
||||||
return domain.Job{}, err
|
return domain.Job{}, err
|
||||||
}
|
}
|
||||||
s.mu.Unlock()
|
|
||||||
s.emit(RunRecorded{Record: record})
|
s.emit(RunRecorded{Record: record})
|
||||||
s.emit(JobChanged{JobID: job.ID})
|
s.emit(JobChanged{JobID: job.ID})
|
||||||
return job, nil
|
return job, nil
|
||||||
@@ -87,10 +95,10 @@ func (s *Service) UpdateJob(job domain.Job) error {
|
|||||||
s.refreshNextRunLocked(existing, runtime)
|
s.refreshNextRunLocked(existing, runtime)
|
||||||
record := uiRecord(job.ID, job.Name, "Updated", "Job settings changed")
|
record := uiRecord(job.ID, job.Name, "Updated", "Job settings changed")
|
||||||
prependLog(runtime, record)
|
prependLog(runtime, record)
|
||||||
err := s.store.SaveJobs(s.jobs)
|
save := s.deferSaveLocked(s.store.PrepareSaveJobs(s.jobs))
|
||||||
s.mu.Unlock()
|
s.mu.Unlock()
|
||||||
|
|
||||||
if err != nil {
|
if err := save(); err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
s.emit(RunRecorded{Record: record})
|
s.emit(RunRecorded{Record: record})
|
||||||
@@ -113,10 +121,10 @@ func (s *Service) DeleteJob(id int) error {
|
|||||||
delete(s.runtimes, id)
|
delete(s.runtimes, id)
|
||||||
delete(s.schedules, id)
|
delete(s.schedules, id)
|
||||||
record := uiRecord(id, deleted.Name, "Deleted", "Job was removed")
|
record := uiRecord(id, deleted.Name, "Deleted", "Job was removed")
|
||||||
err := s.store.SaveJobs(s.jobs)
|
save := s.deferSaveLocked(s.store.PrepareSaveJobs(s.jobs))
|
||||||
s.mu.Unlock()
|
s.mu.Unlock()
|
||||||
|
|
||||||
if err != nil {
|
if err := save(); err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
s.emit(RunRecorded{Record: record})
|
s.emit(RunRecorded{Record: record})
|
||||||
@@ -148,13 +156,17 @@ func (s *Service) SetEnabled(id int, enabled bool) error {
|
|||||||
runtime.LastState = "Paused"
|
runtime.LastState = "Paused"
|
||||||
runtime.NextRun = "Paused"
|
runtime.NextRun = "Paused"
|
||||||
runtime.NextDue = time.Time{}
|
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")
|
record = uiRecord(id, job.Name, "Paused", "Job was disabled")
|
||||||
}
|
}
|
||||||
prependLog(runtime, record)
|
prependLog(runtime, record)
|
||||||
err := s.store.SaveJobs(s.jobs)
|
save := s.deferSaveLocked(s.store.PrepareSaveJobs(s.jobs))
|
||||||
s.mu.Unlock()
|
s.mu.Unlock()
|
||||||
|
|
||||||
if err != nil {
|
if err := save(); err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
s.emit(RunRecorded{Record: record})
|
s.emit(RunRecorded{Record: record})
|
||||||
@@ -175,15 +187,19 @@ func (s *Service) SetGlobalPause(paused bool) error {
|
|||||||
for index := range s.jobs {
|
for index := range s.jobs {
|
||||||
job := &s.jobs[index]
|
job := &s.jobs[index]
|
||||||
runtime := s.runtimeForLocked(job)
|
runtime := s.runtimeForLocked(job)
|
||||||
|
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)
|
s.refreshNextRunFromLocked(job, runtime, now)
|
||||||
}
|
}
|
||||||
err := s.store.SaveConfig()
|
save := s.deferSaveLocked(s.store.PrepareSaveConfig())
|
||||||
if err == nil {
|
|
||||||
err = s.store.SaveJobs(s.jobs)
|
|
||||||
}
|
|
||||||
s.mu.Unlock()
|
s.mu.Unlock()
|
||||||
|
|
||||||
if err != nil {
|
if err := save(); err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
state, detail := "Resumed", "All job execution resumed"
|
state, detail := "Resumed", "All job execution resumed"
|
||||||
@@ -211,9 +227,9 @@ func (s *Service) SetJobListView(view domain.JobListView) error {
|
|||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
s.store.Config.JobListView = view
|
s.store.Config.JobListView = view
|
||||||
err := s.store.SaveConfig()
|
save := s.deferSaveLocked(s.store.PrepareSaveConfig())
|
||||||
s.mu.Unlock()
|
s.mu.Unlock()
|
||||||
return err
|
return save()
|
||||||
}
|
}
|
||||||
|
|
||||||
// ShouldNotifyOnFailure reports whether the user has enabled desktop
|
// ShouldNotifyOnFailure reports whether the user has enabled desktop
|
||||||
@@ -243,54 +259,75 @@ func (s *Service) UpdateSettings(config domain.Config) error {
|
|||||||
config.JobsFile = strings.TrimSpace(config.JobsFile)
|
config.JobsFile = strings.TrimSpace(config.JobsFile)
|
||||||
|
|
||||||
s.mu.Lock()
|
s.mu.Lock()
|
||||||
jobsPath := storage.ResolveConfiguredPath(s.store.Paths.AppDir, config.JobsFile)
|
// AppDir is fixed for the process and only UpdateSettings itself — a UI
|
||||||
|
// action — can move JobsPath, so this snapshot stays valid across the reads
|
||||||
|
// below.
|
||||||
|
appDir := s.store.Paths.AppDir
|
||||||
|
jobsPath := storage.ResolveConfiguredPath(appDir, config.JobsFile)
|
||||||
switching := jobsPath != s.store.Paths.JobsPath
|
switching := jobsPath != s.store.Paths.JobsPath
|
||||||
if switching && s.anyRunningLocked() {
|
running := s.anyRunningLocked()
|
||||||
s.mu.Unlock()
|
s.mu.Unlock()
|
||||||
|
|
||||||
|
if switching && running {
|
||||||
return errors.New("cannot change the jobs file while a job is running")
|
return errors.New("cannot change the jobs file while a job is running")
|
||||||
}
|
}
|
||||||
// Read the new file before anything is written, so a file that cannot be
|
// Read the new file, and reconstruct its jobs' statistics from the logs the
|
||||||
// parsed leaves both the config and the current jobs untouched.
|
// new config points at, before anything is written and while no lock is held:
|
||||||
|
// both are file I/O, and SeedStats opens every log in the directory. A file
|
||||||
|
// that cannot be parsed leaves both the config and the current jobs untouched.
|
||||||
var adopted []domain.Job
|
var adopted []domain.Job
|
||||||
|
var seeds map[int]runner.SeededStats
|
||||||
if switching {
|
if switching {
|
||||||
jobs, found, err := storage.LoadJobsFile(jobsPath)
|
jobs, found, err := storage.LoadJobsFile(jobsPath)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
s.mu.Unlock()
|
|
||||||
return fmt.Errorf("read jobs file %s: %w", jobsPath, err)
|
return fmt.Errorf("read jobs file %s: %w", jobsPath, err)
|
||||||
}
|
}
|
||||||
if found {
|
if found {
|
||||||
adopted = jobs
|
adopted = jobs
|
||||||
|
seeds = runner.SeedStats(storage.ResolveConfiguredPath(appDir, config.LogsDir), jobs, config.MaxLogFiles)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
s.store.Config = config
|
s.mu.Lock()
|
||||||
if err := s.store.SaveConfig(); err != nil {
|
// The guard above was evaluated before the reads, off the lock, so re-check
|
||||||
|
// it: a scheduled run may have started in the meantime, and adoption drops
|
||||||
|
// every runtime.
|
||||||
|
if switching && s.anyRunningLocked() {
|
||||||
s.mu.Unlock()
|
s.mu.Unlock()
|
||||||
return err
|
return errors.New("cannot change the jobs file while a job is running")
|
||||||
}
|
}
|
||||||
|
s.store.Config = config
|
||||||
|
saveConfig := s.store.PrepareSaveConfig()
|
||||||
if adopted != nil {
|
if adopted != nil {
|
||||||
s.adoptJobsLocked(adopted)
|
s.adoptJobsLocked(adopted)
|
||||||
|
s.applySeededStatsLocked(seeds)
|
||||||
}
|
}
|
||||||
// SaveConfig re-resolved the paths from the new config, so SaveJobs writes to
|
// PrepareSaveConfig re-resolved the paths from the new config, so the jobs
|
||||||
// the (possibly new) jobs file and cleanup targets the new logs dir. Adopted
|
// write targets the (possibly new) jobs file and cleanup targets the new logs
|
||||||
// jobs are written back too, which persists the IDs and defaults that
|
// dir. Adopted jobs are written back too, which persists the IDs and defaults
|
||||||
// normalization filled in, exactly as loading them at startup would.
|
// that normalization filled in, exactly as loading them at startup would. The
|
||||||
if err := s.store.SaveJobs(s.jobs); err != nil {
|
// jobs write is skipped when the config write fails, because both writes run
|
||||||
s.mu.Unlock()
|
// in the order prepared and stop at the first error.
|
||||||
return err
|
save := s.deferSaveLocked(saveConfig, s.store.PrepareSaveJobs(s.jobs))
|
||||||
}
|
|
||||||
loaded := len(s.jobs)
|
loaded := len(s.jobs)
|
||||||
logsDir := s.store.Paths.LogsDir
|
logsDir := s.store.Paths.LogsDir
|
||||||
maxFiles := s.store.Config.MaxLogFiles
|
maxFiles := s.store.Config.MaxLogFiles
|
||||||
maxAge := s.store.Config.MaxLogAgeDays
|
maxAge := s.store.Config.MaxLogAgeDays
|
||||||
s.mu.Unlock()
|
s.mu.Unlock()
|
||||||
|
|
||||||
|
saveErr := save()
|
||||||
if adopted != nil {
|
if adopted != nil {
|
||||||
// A broad JobChanged redraws the job list; JobsLoaded tells the user in
|
// A broad JobChanged redraws the job list; JobsLoaded tells the user in
|
||||||
// History which file those jobs came from, since nothing was asked.
|
// History which file those jobs came from, since nothing was asked. Both
|
||||||
|
// are emitted even when the write failed: the adopted jobs are already the
|
||||||
|
// in-memory list, and a job list the user cannot see would be worse than
|
||||||
|
// the error they are about to be shown.
|
||||||
s.emit(JobsLoaded{Path: jobsPath, Count: loaded})
|
s.emit(JobsLoaded{Path: jobsPath, Count: loaded})
|
||||||
s.emit(JobChanged{})
|
s.emit(JobChanged{})
|
||||||
}
|
}
|
||||||
|
if saveErr != nil {
|
||||||
|
return saveErr
|
||||||
|
}
|
||||||
return runner.CleanupLogs(logsDir, maxFiles, maxAge)
|
return runner.CleanupLogs(logsDir, maxFiles, maxAge)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -466,11 +503,13 @@ func validateConfig(config domain.Config) error {
|
|||||||
if strings.TrimSpace(config.LogsDir) == "" {
|
if strings.TrimSpace(config.LogsDir) == "" {
|
||||||
return errors.New("logs directory is required")
|
return errors.New("logs directory is required")
|
||||||
}
|
}
|
||||||
if config.MaxLogFiles <= 0 {
|
// 0 means "keep everything" (see runner.CleanupLogs); only a negative count
|
||||||
return errors.New("max log files must be a positive number")
|
// is rejected, the same three-state shape as DefaultTimeoutSeconds below.
|
||||||
|
if config.MaxLogFiles < 0 {
|
||||||
|
return errors.New("max log files must be zero (unlimited) or a positive number")
|
||||||
}
|
}
|
||||||
if config.MaxLogAgeDays <= 0 {
|
if config.MaxLogAgeDays < 0 {
|
||||||
return errors.New("max log age days must be a positive number")
|
return errors.New("max log age days must be zero (unlimited) or a positive number")
|
||||||
}
|
}
|
||||||
if config.ExecutionMode != domain.ExecutionModeParallel && config.ExecutionMode != domain.ExecutionModeSequential {
|
if config.ExecutionMode != domain.ExecutionModeParallel && config.ExecutionMode != domain.ExecutionModeSequential {
|
||||||
return errors.New("execution mode must be 'parallel' or 'sequential'")
|
return errors.New("execution mode must be 'parallel' or 'sequential'")
|
||||||
|
|||||||
+136
-8
@@ -3,8 +3,10 @@ package app
|
|||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
|
"fmt"
|
||||||
"os"
|
"os"
|
||||||
"path/filepath"
|
"path/filepath"
|
||||||
|
"sync"
|
||||||
"sync/atomic"
|
"sync/atomic"
|
||||||
"testing"
|
"testing"
|
||||||
"time"
|
"time"
|
||||||
@@ -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) {
|
func TestSetGlobalPauseUpdatesRuntimesAndEmits(t *testing.T) {
|
||||||
svc := newTempService(t, []domain.Job{
|
svc := newTempService(t, []domain.Job{
|
||||||
{ID: 1, Name: "On", Schedule: "@every 1m", Command: "echo", Enabled: true},
|
{ID: 1, Name: "On", Schedule: "@every 1m", Command: "echo", Enabled: true},
|
||||||
@@ -503,9 +522,9 @@ func TestUpdateSettingsPersistsAndValidates(t *testing.T) {
|
|||||||
svc := newTempService(t, nil)
|
svc := newTempService(t, nil)
|
||||||
|
|
||||||
bad := svc.store.Config
|
bad := svc.store.Config
|
||||||
bad.MaxLogFiles = 0
|
bad.MaxLogFiles = -1
|
||||||
if err := svc.UpdateSettings(bad); err == nil {
|
if err := svc.UpdateSettings(bad); err == nil {
|
||||||
t.Error("expected validation error for non-positive max log files")
|
t.Error("expected validation error for negative max log files")
|
||||||
}
|
}
|
||||||
|
|
||||||
good := svc.store.Config
|
good := svc.store.Config
|
||||||
@@ -514,8 +533,21 @@ func TestUpdateSettingsPersistsAndValidates(t *testing.T) {
|
|||||||
if err := svc.UpdateSettings(good); err != nil {
|
if err := svc.UpdateSettings(good); err != nil {
|
||||||
t.Fatalf("UpdateSettings: %v", err)
|
t.Fatalf("UpdateSettings: %v", err)
|
||||||
}
|
}
|
||||||
if svc.Store().Config.MaxLogAgeDays != 7 || svc.Store().Config.NotifyOnFailure {
|
if svc.store.Config.MaxLogAgeDays != 7 || svc.store.Config.NotifyOnFailure {
|
||||||
t.Errorf("config not applied: %+v", svc.Store().Config)
|
t.Errorf("config not applied: %+v", svc.store.Config)
|
||||||
|
}
|
||||||
|
|
||||||
|
// 0 means "keep everything" (see STANDARDS §Intentional behavior), not an
|
||||||
|
// invalid value, so it must be accepted and persisted rather than rejected
|
||||||
|
// or silently backfilled.
|
||||||
|
unlimited := svc.store.Config
|
||||||
|
unlimited.MaxLogFiles = 0
|
||||||
|
unlimited.MaxLogAgeDays = 0
|
||||||
|
if err := svc.UpdateSettings(unlimited); err != nil {
|
||||||
|
t.Fatalf("UpdateSettings with zero retention limits: %v", err)
|
||||||
|
}
|
||||||
|
if svc.store.Config.MaxLogFiles != 0 || svc.store.Config.MaxLogAgeDays != 0 {
|
||||||
|
t.Errorf("zero retention limits not preserved: %+v", svc.store.Config)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -530,8 +562,8 @@ func TestUpdateSettingsRejectsInvalidConfigs(t *testing.T) {
|
|||||||
{"missing jobs file", func(c *domain.Config) { c.JobsFile = " " }},
|
{"missing jobs file", func(c *domain.Config) { c.JobsFile = " " }},
|
||||||
{"jobs file without a file name", func(c *domain.Config) { c.JobsFile = "jobs" + string(filepath.Separator) }},
|
{"jobs file without a file name", func(c *domain.Config) { c.JobsFile = "jobs" + string(filepath.Separator) }},
|
||||||
{"missing logs dir", func(c *domain.Config) { c.LogsDir = "" }},
|
{"missing logs dir", func(c *domain.Config) { c.LogsDir = "" }},
|
||||||
{"non-positive max files", func(c *domain.Config) { c.MaxLogFiles = 0 }},
|
{"negative max files", func(c *domain.Config) { c.MaxLogFiles = -1 }},
|
||||||
{"non-positive max age", func(c *domain.Config) { c.MaxLogAgeDays = -1 }},
|
{"negative max age", func(c *domain.Config) { c.MaxLogAgeDays = -1 }},
|
||||||
{"negative default timeout", func(c *domain.Config) { c.DefaultTimeoutSeconds = -1 }},
|
{"negative default timeout", func(c *domain.Config) { c.DefaultTimeoutSeconds = -1 }},
|
||||||
}
|
}
|
||||||
for _, tc := range tests {
|
for _, tc := range tests {
|
||||||
@@ -693,12 +725,12 @@ func TestUpdateSettingsRefusesJobsFileSwitchWhileRunning(t *testing.T) {
|
|||||||
if err := svc.UpdateSettings(config); err == nil {
|
if err := svc.UpdateSettings(config); err == nil {
|
||||||
t.Error("expected the jobs-file switch to be refused while a job is running")
|
t.Error("expected the jobs-file switch to be refused while a job is running")
|
||||||
}
|
}
|
||||||
if svc.Store().Config.JobsFile == config.JobsFile {
|
if svc.store.Config.JobsFile == config.JobsFile {
|
||||||
t.Error("the refused switch must not have been persisted")
|
t.Error("the refused switch must not have been persisted")
|
||||||
}
|
}
|
||||||
|
|
||||||
// A setting that does not touch the jobs file still saves during a run.
|
// A setting that does not touch the jobs file still saves during a run.
|
||||||
unrelated := svc.Store().Config
|
unrelated := svc.store.Config
|
||||||
unrelated.NotifyOnFailure = !unrelated.NotifyOnFailure
|
unrelated.NotifyOnFailure = !unrelated.NotifyOnFailure
|
||||||
if err := svc.UpdateSettings(unrelated); err != nil {
|
if err := svc.UpdateSettings(unrelated); err != nil {
|
||||||
t.Errorf("unrelated setting should still save during a run: %v", err)
|
t.Errorf("unrelated setting should still save during a run: %v", err)
|
||||||
@@ -708,6 +740,102 @@ func TestUpdateSettingsRefusesJobsFileSwitchWhileRunning(t *testing.T) {
|
|||||||
waitRecord(t, done)
|
waitRecord(t, done)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Adoption reconstructs the adopted jobs' aggregate statistics from the log
|
||||||
|
// files the new configuration points at. That scan opens every log in the
|
||||||
|
// directory, so UpdateSettings runs it before taking the state lock; this pins
|
||||||
|
// that its result still reaches the runtime map.
|
||||||
|
func TestUpdateSettingsSeedsAdoptedJobsFromLogs(t *testing.T) {
|
||||||
|
svc := newTempService(t, []domain.Job{{ID: 1, Name: "Local", Schedule: "@every 1m", Command: "echo local", Enabled: true}})
|
||||||
|
|
||||||
|
logsDir := svc.store.Paths.LogsDir
|
||||||
|
if err := os.MkdirAll(logsDir, 0o755); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
log := "time: 2026-08-05 10:00:00\njob_id: 7\njob_name: Adopted\ntrigger: Schedule\nstate: Failed\ndetail: boom\nduration: 1500\n\nstdout:\n<empty>\n"
|
||||||
|
if err := os.WriteFile(filepath.Join(logsDir, "20260805-100000_Adopted.log"), []byte(log), 0o644); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
shared := filepath.Join(svc.store.Paths.AppDir, "shared.json")
|
||||||
|
data, err := json.Marshal(domain.JobsFile{Jobs: []domain.Job{
|
||||||
|
{ID: 7, Name: "Adopted", Schedule: "@every 5m", Command: "echo adopted", Enabled: true},
|
||||||
|
}})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if err := os.WriteFile(shared, data, 0o644); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
config := svc.store.Config
|
||||||
|
config.JobsFile = shared
|
||||||
|
if err := svc.UpdateSettings(config); err != nil {
|
||||||
|
t.Fatalf("UpdateSettings: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
runtime := svc.Runtime(7)
|
||||||
|
if runtime == nil {
|
||||||
|
t.Fatal("the adopted job has no runtime")
|
||||||
|
}
|
||||||
|
if runtime.RunCount != 1 || runtime.FailCount != 1 || runtime.LastDurationMS != 1500 {
|
||||||
|
t.Errorf("seeded stats: RunCount=%d FailCount=%d LastDurationMS=%d, want 1/1/1500",
|
||||||
|
runtime.RunCount, runtime.FailCount, runtime.LastDurationMS)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Job saves run after mu is released, so one operation can be writing while
|
||||||
|
// another mutates state. deferSaveLocked takes its own lock while mu is still
|
||||||
|
// held, which is what keeps writes in mutation order: whatever changed the list
|
||||||
|
// last also wrote it last, so the file ends up matching memory instead of
|
||||||
|
// holding an older snapshot.
|
||||||
|
func TestConcurrentJobOperationsLeaveTheFileMatchingMemory(t *testing.T) {
|
||||||
|
svc := newTempService(t, nil)
|
||||||
|
|
||||||
|
const workers = 8
|
||||||
|
var wg sync.WaitGroup
|
||||||
|
for i := 0; i < workers; i++ {
|
||||||
|
wg.Add(1)
|
||||||
|
go func(i int) {
|
||||||
|
defer wg.Done()
|
||||||
|
job, err := svc.CreateJob(domain.Job{Name: fmt.Sprintf("Job %d", i), Schedule: "@every 1m", Command: "echo hi", Enabled: true})
|
||||||
|
if err != nil {
|
||||||
|
t.Errorf("CreateJob %d: %v", i, err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if err := svc.SetEnabled(job.ID, false); err != nil {
|
||||||
|
t.Errorf("SetEnabled %d: %v", job.ID, err)
|
||||||
|
}
|
||||||
|
}(i)
|
||||||
|
}
|
||||||
|
wg.Wait()
|
||||||
|
|
||||||
|
memory := svc.Jobs()
|
||||||
|
if len(memory) != workers {
|
||||||
|
t.Fatalf("jobs in memory = %d, want %d", len(memory), workers)
|
||||||
|
}
|
||||||
|
saved, found, err := storage.LoadJobsFile(svc.store.Paths.JobsPath)
|
||||||
|
if err != nil || !found {
|
||||||
|
t.Fatalf("read jobs file: found=%v err=%v", found, err)
|
||||||
|
}
|
||||||
|
if len(saved) != len(memory) {
|
||||||
|
t.Fatalf("jobs on disk = %d, want %d: the last write must be the last mutation", len(saved), len(memory))
|
||||||
|
}
|
||||||
|
onDisk := make(map[int]domain.Job, len(saved))
|
||||||
|
for _, job := range saved {
|
||||||
|
onDisk[job.ID] = job
|
||||||
|
}
|
||||||
|
for _, job := range memory {
|
||||||
|
got, ok := onDisk[job.ID]
|
||||||
|
if !ok {
|
||||||
|
t.Errorf("job %d (%q) is in memory but missing from the file", job.ID, job.Name)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if got.Name != job.Name || got.Enabled != job.Enabled {
|
||||||
|
t.Errorf("job %d on disk = %q/%v, want %q/%v", job.ID, got.Name, got.Enabled, job.Name, job.Enabled)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func TestPrependLogCapsActivityList(t *testing.T) {
|
func TestPrependLogCapsActivityList(t *testing.T) {
|
||||||
runtime := &domain.JobRuntime{}
|
runtime := &domain.JobRuntime{}
|
||||||
for i := 0; i < maxJobLogs+10; i++ {
|
for i := 0; i < maxJobLogs+10; i++ {
|
||||||
|
|||||||
+16
-7
@@ -1,39 +1,48 @@
|
|||||||
package app
|
package app
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"fmt"
|
||||||
|
|
||||||
"gitea.mixdep.ru/mix/gosentry/src/platform/desktop"
|
"gitea.mixdep.ru/mix/gosentry/src/platform/desktop"
|
||||||
)
|
)
|
||||||
|
|
||||||
// InstallDesktopIcon installs the application's .desktop file and icon on
|
// InstallDesktopIcon installs the application's .desktop file and icon on
|
||||||
// Linux (no-op on other platforms). The resulting icon path is stored in
|
// 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) {
|
func (s *Service) InstallDesktopIcon(appID string, iconBytes []byte) {
|
||||||
if iconPath, err := desktop.InstallDesktopIntegration(appID, s.store.Paths.ExecutablePath, iconBytes); err == nil {
|
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.mu.Lock()
|
||||||
s.store.Paths.DesktopIcon = iconPath
|
s.store.Paths.DesktopIcon = iconPath
|
||||||
s.mu.Unlock()
|
s.mu.Unlock()
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
|
||||||
// AutostartStatus reports whether the platform autostart entry matches the
|
// AutostartStatus reports whether the platform autostart entry matches the
|
||||||
// current StartOnLogin setting in the stored config.
|
// current StartOnLogin and KeepRunningInTray settings in the stored config.
|
||||||
func (s *Service) AutostartStatus() (ok bool, message string) {
|
func (s *Service) AutostartStatus() (ok bool, message string) {
|
||||||
s.mu.Lock()
|
s.mu.Lock()
|
||||||
enabled := s.store.Config.StartOnLogin
|
enabled := s.store.Config.StartOnLogin
|
||||||
|
startInTray := s.store.Config.KeepRunningInTray
|
||||||
execPath := s.store.Paths.ExecutablePath
|
execPath := s.store.Paths.ExecutablePath
|
||||||
manager := s.manager
|
manager := s.manager
|
||||||
s.mu.Unlock()
|
s.mu.Unlock()
|
||||||
if manager == nil {
|
if manager == nil {
|
||||||
return false, "autostart not available"
|
return false, "autostart not available"
|
||||||
}
|
}
|
||||||
return manager.Status(enabled, execPath)
|
return manager.Status(enabled, startInTray, execPath)
|
||||||
}
|
}
|
||||||
|
|
||||||
// ApplyAutostart writes or removes the platform autostart entry to match the
|
// ApplyAutostart writes or removes the platform autostart entry to match the
|
||||||
// current StartOnLogin setting in the stored config. Call after UpdateSettings.
|
// current StartOnLogin and KeepRunningInTray settings. Call after UpdateSettings.
|
||||||
func (s *Service) ApplyAutostart() error {
|
func (s *Service) ApplyAutostart() error {
|
||||||
s.mu.Lock()
|
s.mu.Lock()
|
||||||
enabled := s.store.Config.StartOnLogin
|
enabled := s.store.Config.StartOnLogin
|
||||||
|
startInTray := s.store.Config.KeepRunningInTray
|
||||||
execPath := s.store.Paths.ExecutablePath
|
execPath := s.store.Paths.ExecutablePath
|
||||||
iconPath := s.store.Paths.DesktopIcon
|
iconPath := s.store.Paths.DesktopIcon
|
||||||
manager := s.manager
|
manager := s.manager
|
||||||
@@ -41,5 +50,5 @@ func (s *Service) ApplyAutostart() error {
|
|||||||
if manager == nil {
|
if manager == nil {
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
return manager.Set(enabled, execPath, iconPath)
|
return manager.Set(enabled, startInTray, execPath, iconPath)
|
||||||
}
|
}
|
||||||
|
|||||||
+29
-41
@@ -11,6 +11,13 @@ import (
|
|||||||
"gitea.mixdep.ru/mix/gosentry/src/runner"
|
"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
|
// 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
|
// 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
|
// 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()
|
s.mu.Unlock()
|
||||||
return errors.New("another job is already running (sequential mode)")
|
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()
|
s.mu.Unlock()
|
||||||
|
|
||||||
if err == nil {
|
|
||||||
// Reflect the "Running" transition; the run's completion emits again later.
|
// Reflect the "Running" transition; the run's completion emits again later.
|
||||||
s.emit(JobChanged{JobID: id})
|
s.emit(JobChanged{JobID: id})
|
||||||
}
|
return nil
|
||||||
return err
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// RunDue is the scheduler's per-tick entry point: it starts whatever is due at
|
// 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) {
|
func (s *Service) RunDue(now time.Time) {
|
||||||
s.mu.Lock()
|
s.mu.Lock()
|
||||||
var started []int
|
var started []int
|
||||||
var startErr error
|
|
||||||
if !s.paused {
|
if !s.paused {
|
||||||
sequential := s.store.Config.ExecutionMode == domain.ExecutionModeSequential
|
sequential := s.store.Config.ExecutionMode == domain.ExecutionModeSequential
|
||||||
running := s.anyRunningLocked()
|
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.
|
// The job came due again while its own run is still in flight.
|
||||||
// Apply the effective overlap policy and step past this
|
// Apply the effective overlap policy and step past this
|
||||||
// occurrence.
|
// occurrence.
|
||||||
if s.effectiveOverlapPolicy(job) == domain.OverlapPolicyQueue {
|
if s.effectiveOverlapPolicy(job) == domain.OverlapPolicyQueue && runtime.PendingRuns < maxPendingRuns {
|
||||||
runtime.PendingRuns++
|
runtime.PendingRuns++
|
||||||
}
|
}
|
||||||
s.advanceNextDueLocked(job, runtime, now)
|
s.advanceNextDueLocked(job, runtime, now)
|
||||||
@@ -88,19 +92,13 @@ func (s *Service) RunDue(now time.Time) {
|
|||||||
// tick once the in-flight run has finished.
|
// tick once the in-flight run has finished.
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
if err := s.startRunLocked(job, runtime, "Schedule", now); err != nil {
|
s.startRunLocked(job, runtime, "Schedule", now)
|
||||||
startErr = err
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
started = append(started, job.ID)
|
started = append(started, job.ID)
|
||||||
running = true
|
running = true
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
s.mu.Unlock()
|
s.mu.Unlock()
|
||||||
|
|
||||||
if startErr != nil {
|
|
||||||
s.emit(ErrorOccurred{Err: fmt.Errorf("save jobs before scheduled run: %w", startErr)})
|
|
||||||
}
|
|
||||||
for _, id := range started {
|
for _, id := range started {
|
||||||
s.emit(JobChanged{JobID: id})
|
s.emit(JobChanged{JobID: id})
|
||||||
}
|
}
|
||||||
@@ -116,29 +114,19 @@ type runEnv struct {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// startRunLocked transitions a job to "Running", advances its NextDue to the next
|
// startRunLocked transitions a job to "Running", advances its NextDue to the next
|
||||||
// scheduled occurrence, persists that, and launches the run on a background
|
// scheduled occurrence, and launches the run on a background goroutine. Neither
|
||||||
// goroutine. Advancing (rather than zeroing) NextDue keeps the schedule marching
|
// step touches a durable field — both live on JobRuntime, which is never
|
||||||
// while the run is in flight, which is what lets RunDue notice a fresh occurrence
|
// persisted — so there is nothing to save here. Advancing (rather than zeroing)
|
||||||
// firing during a long run and apply the overlap policy. The caller must hold mu.
|
// NextDue keeps the schedule marching while the run is in flight, which is what
|
||||||
// now is the reference time for next-due advancement and the running placeholder.
|
// lets RunDue notice a fresh occurrence firing during a long run and apply the
|
||||||
func (s *Service) startRunLocked(job *domain.Job, runtime *domain.JobRuntime, trigger string, now time.Time) error {
|
// 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
|
jobCopy := *job
|
||||||
prevState := runtime.LastState
|
|
||||||
prevNextRun := runtime.NextRun
|
|
||||||
prevOutput := runtime.Output
|
|
||||||
prevNextDue := runtime.NextDue
|
|
||||||
|
|
||||||
runtime.LastState = "Running"
|
runtime.LastState = "Running"
|
||||||
runtime.NextRun = "Running"
|
runtime.NextRun = "Running"
|
||||||
runtime.Output = runningOutput(jobCopy, trigger, now)
|
runtime.Output = runningOutput(jobCopy, trigger, now)
|
||||||
s.advanceNextDueLocked(job, runtime, 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{
|
env := runEnv{
|
||||||
logsDir: s.store.Paths.LogsDir,
|
logsDir: s.store.Paths.LogsDir,
|
||||||
maxFiles: s.store.Config.MaxLogFiles,
|
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
|
// Capture ctx under the lock so a concurrent Start/Stop cannot swap it out
|
||||||
// from under the goroutine after we release mu.
|
// from under the goroutine after we release mu.
|
||||||
go s.executeRun(s.ctx, jobCopy, trigger, env)
|
go s.executeRun(s.ctx, jobCopy, trigger, env)
|
||||||
return nil
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// executeRun runs the job off the lock, then records the result back through the
|
// 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)
|
record, logErr := s.runJob(ctx, &jobCopy, trigger, env.logsDir, env.timeout)
|
||||||
|
|
||||||
s.mu.Lock()
|
s.mu.Lock()
|
||||||
var cleanupErr, saveErr error
|
|
||||||
var rerunStarted bool
|
var rerunStarted bool
|
||||||
if current := s.findByIDLocked(jobCopy.ID); current != nil {
|
if current := s.findByIDLocked(jobCopy.ID); current != nil {
|
||||||
runtime := s.runtimeForLocked(current)
|
runtime := s.runtimeForLocked(current)
|
||||||
@@ -174,25 +160,26 @@ func (s *Service) executeRun(ctx context.Context, jobCopy domain.Job, trigger st
|
|||||||
runtime.PendingRuns--
|
runtime.PendingRuns--
|
||||||
// A scheduled occurrence fired while this run was active under the
|
// A scheduled occurrence fired while this run was active under the
|
||||||
// "queue" policy; start one deferred run now.
|
// "queue" policy; start one deferred run now.
|
||||||
saveErr = s.startRunLocked(current, runtime, "Schedule", time.Now())
|
s.startRunLocked(current, runtime, "Schedule", time.Now())
|
||||||
rerunStarted = saveErr == nil
|
rerunStarted = true
|
||||||
} else {
|
} else {
|
||||||
s.refreshNextRunLocked(current, runtime)
|
s.refreshNextRunLocked(current, runtime)
|
||||||
saveErr = s.store.SaveJobs(s.jobs)
|
|
||||||
}
|
}
|
||||||
cleanupErr = runner.CleanupLogs(env.logsDir, env.maxFiles, env.maxAge)
|
|
||||||
}
|
}
|
||||||
s.mu.Unlock()
|
s.mu.Unlock()
|
||||||
|
|
||||||
|
// Cleanup is a directory scan plus up to MaxLogFiles unlinks. It needs only
|
||||||
|
// the values already snapshotted into runEnv, so it runs after mu is released
|
||||||
|
// rather than making every UI refresh wait behind it. It runs even when the
|
||||||
|
// job is gone, because the run still wrote a log file that retention covers.
|
||||||
|
cleanupErr := runner.CleanupLogs(env.logsDir, env.maxFiles, env.maxAge)
|
||||||
|
|
||||||
if logErr != nil {
|
if logErr != nil {
|
||||||
s.emit(ErrorOccurred{Err: fmt.Errorf("write run log for %q: %w", jobCopy.Name, logErr)})
|
s.emit(ErrorOccurred{Err: fmt.Errorf("write run log for %q: %w", jobCopy.Name, logErr)})
|
||||||
}
|
}
|
||||||
if cleanupErr != nil {
|
if cleanupErr != nil {
|
||||||
s.emit(ErrorOccurred{Err: fmt.Errorf("log cleanup after run %q: %w", jobCopy.Name, cleanupErr)})
|
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})
|
s.emit(RunRecorded{Record: record})
|
||||||
if !rerunStarted {
|
if !rerunStarted {
|
||||||
s.emit(JobChanged{JobID: jobCopy.ID})
|
s.emit(JobChanged{JobID: jobCopy.ID})
|
||||||
@@ -266,7 +253,8 @@ func updateStats(rt *domain.JobRuntime, r domain.RunRecord) {
|
|||||||
rt.MaxDurationMS = r.DurationMS
|
rt.MaxDurationMS = r.DurationMS
|
||||||
}
|
}
|
||||||
rt.TimedRunCount++
|
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
|
// runningOutput is the placeholder output shown while a job is running, before
|
||||||
|
|||||||
+56
-33
@@ -2,7 +2,6 @@ package app
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
"os"
|
|
||||||
"sync/atomic"
|
"sync/atomic"
|
||||||
"testing"
|
"testing"
|
||||||
"time"
|
"time"
|
||||||
@@ -104,6 +103,15 @@ func TestUpdateStats(t *testing.T) {
|
|||||||
if rt.AvgDurationMS != 233 {
|
if rt.AvgDurationMS != 233 {
|
||||||
t.Errorf("after run 3: avg=%d, want 233", rt.AvgDurationMS)
|
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) {
|
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
|
// TestRunDuePerJobQueueOverridesGlobalSkip verifies that a job carrying its own
|
||||||
// "queue" policy queues a re-run even though the global default is "skip": the
|
// "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.
|
// effective policy is resolved per job, so the job-level value wins.
|
||||||
@@ -493,37 +537,9 @@ func TestRunNowSequentialGuard(t *testing.T) {
|
|||||||
waitRecord(t, done)
|
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
|
// 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) {
|
func TestRunDueQueueDrainSkippedWhenPaused(t *testing.T) {
|
||||||
svc := newQueueService(t, domain.ExecutionModeParallel, domain.OverlapPolicyQueue, []domain.Job{
|
svc := newQueueService(t, domain.ExecutionModeParallel, domain.OverlapPolicyQueue, []domain.Job{
|
||||||
{ID: 1, Name: "A", Schedule: "@every 1h", Command: "echo", Enabled: true},
|
{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)
|
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)
|
close(release)
|
||||||
waitRecord(t, done)
|
waitRecord(t, done)
|
||||||
expectNoEntry(t, entered)
|
expectNoEntry(t, entered)
|
||||||
@@ -568,8 +591,8 @@ func TestRunDueQueueDrainSkippedWhenPaused(t *testing.T) {
|
|||||||
svc.mu.Lock()
|
svc.mu.Lock()
|
||||||
pending = svc.runtimes[1].PendingRuns
|
pending = svc.runtimes[1].PendingRuns
|
||||||
svc.mu.Unlock()
|
svc.mu.Unlock()
|
||||||
if pending != 1 {
|
if pending != 0 {
|
||||||
t.Errorf("paused scheduler must not drain queue, PendingRuns = %d, want 1", pending)
|
t.Errorf("paused scheduler must not drain queue, PendingRuns = %d, want 0", pending)
|
||||||
}
|
}
|
||||||
if got := atomic.LoadInt32(&calls); got != 1 {
|
if got := atomic.LoadInt32(&calls); got != 1 {
|
||||||
t.Errorf("runner called %d time(s), want 1", got)
|
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.
|
// it; unexported helpers ending in "Locked" assume the caller already holds it.
|
||||||
// The Service must never call back into the UI (or any code that might re-enter
|
// The Service must never call back into the UI (or any code that might re-enter
|
||||||
// the Service) while holding mu — in particular emit() is always called after
|
// the Service) while holding mu — in particular emit() is always called after
|
||||||
// mu is released.
|
// mu is released. Blocking file I/O follows the same rule: mu is the lock the
|
||||||
|
// Fyne main thread takes on every Jobs() and Runtime() call, so a JSON write, a
|
||||||
|
// log-directory scan, or a pass over every log header must not happen inside it
|
||||||
|
// (see deferSaveLocked, executeRun, and applySeededStatsLocked).
|
||||||
type Service struct {
|
type Service struct {
|
||||||
mu sync.Mutex
|
mu sync.Mutex
|
||||||
store *storage.Store
|
store *storage.Store
|
||||||
@@ -56,6 +59,13 @@ type Service struct {
|
|||||||
// do not exercise autostart; Open() wires it via autostart.New().
|
// do not exercise autostart; Open() wires it via autostart.New().
|
||||||
manager autostart.Manager
|
manager autostart.Manager
|
||||||
|
|
||||||
|
// saveMu serializes the store writes that operations prepare under mu and run
|
||||||
|
// after releasing it. It is taken while mu is still held and released once the
|
||||||
|
// write is done, so writes reach the file in the same order their snapshots
|
||||||
|
// were taken and an older snapshot can never land on top of a newer one.
|
||||||
|
// Nothing may take mu while holding saveMu.
|
||||||
|
saveMu sync.Mutex
|
||||||
|
|
||||||
// observers and their guard live in events.go. dispatchMu is separate from mu
|
// observers and their guard live in events.go. dispatchMu is separate from mu
|
||||||
// so that emitting an event never requires (or is held under) the state lock:
|
// so that emitting an event never requires (or is held under) the state lock:
|
||||||
// the Service must release mu before dispatching, per the locking contract.
|
// the Service must release mu before dispatching, per the locking contract.
|
||||||
@@ -63,6 +73,26 @@ type Service struct {
|
|||||||
observers []Observer
|
observers []Observer
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// deferSaveLocked prepares the store writes for the caller to run after mu is
|
||||||
|
// released, and takes saveMu now so a later operation's write cannot overtake
|
||||||
|
// this one. The caller must hold mu, must unlock it before calling the returned
|
||||||
|
// function, and must call that function exactly once. Keeping the marshal, the
|
||||||
|
// fsync, and the rename out of the critical section is what stops a settings
|
||||||
|
// change or a job edit from blocking a scheduler tick or a finishing run. The
|
||||||
|
// writes run in the order given and stop at the first error.
|
||||||
|
func (s *Service) deferSaveLocked(writes ...func() error) func() error {
|
||||||
|
s.saveMu.Lock()
|
||||||
|
return func() error {
|
||||||
|
defer s.saveMu.Unlock()
|
||||||
|
for _, write := range writes {
|
||||||
|
if err := write(); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// NewService wires the Service to a loaded store and its jobs. It builds the
|
// NewService wires the Service to a loaded store and its jobs. It builds the
|
||||||
// initial runtime map from the durable jobs so every job has transient state
|
// initial runtime map from the durable jobs so every job has transient state
|
||||||
// from the moment the Service exists, and parses each job's schedule once. The
|
// from the moment the Service exists, and parses each job's schedule once. The
|
||||||
@@ -77,18 +107,19 @@ func NewService(store *storage.Store, jobs []domain.Job) *Service {
|
|||||||
// No lock is needed here: construction is single-threaded, before Start
|
// No lock is needed here: construction is single-threaded, before Start
|
||||||
// launches the timing loop.
|
// launches the timing loop.
|
||||||
s.adoptJobsLocked(jobs)
|
s.adoptJobsLocked(jobs)
|
||||||
|
s.applySeededStatsLocked(runner.SeedStats(store.Paths.LogsDir, s.jobs, store.Config.MaxLogFiles))
|
||||||
return s
|
return s
|
||||||
}
|
}
|
||||||
|
|
||||||
// adoptJobsLocked makes jobs the Service's durable state and rebuilds everything
|
// adoptJobsLocked makes jobs the Service's durable state and rebuilds everything
|
||||||
// derived from it: the runtime map, the parsed-schedule cache, each job's first
|
// derived from it: the runtime map, the parsed-schedule cache, and each job's
|
||||||
// next-run — so the Service is ready to schedule the moment it exists, mirroring
|
// first next-run — so the Service is ready to schedule the moment it exists,
|
||||||
// the old scheduler's reset-on-construction — and the statistics seeded from
|
// mirroring the old scheduler's reset-on-construction.
|
||||||
// existing log files, so the details panel shows accumulated run history
|
|
||||||
// immediately rather than only runs since this process started.
|
|
||||||
//
|
//
|
||||||
// It backs both construction and a Settings change that points at a different
|
// It backs both construction and a Settings change that points at a different
|
||||||
// jobs file. The caller must hold mu.
|
// jobs file. Statistics seeded from existing log files are applied separately by
|
||||||
|
// applySeededStatsLocked, because reconstructing them is file I/O. The caller
|
||||||
|
// must hold mu.
|
||||||
func (s *Service) adoptJobsLocked(jobs []domain.Job) {
|
func (s *Service) adoptJobsLocked(jobs []domain.Job) {
|
||||||
s.jobs = jobs
|
s.jobs = jobs
|
||||||
s.runtimes = domain.NewRuntimes(jobs)
|
s.runtimes = domain.NewRuntimes(jobs)
|
||||||
@@ -100,7 +131,16 @@ func (s *Service) adoptJobsLocked(jobs []domain.Job) {
|
|||||||
s.parseScheduleLocked(job)
|
s.parseScheduleLocked(job)
|
||||||
s.refreshNextRunFromLocked(job, s.runtimes[job.ID], now)
|
s.refreshNextRunFromLocked(job, s.runtimes[job.ID], now)
|
||||||
}
|
}
|
||||||
for id, seed := range runner.SeedStats(s.store.Paths.LogsDir, s.jobs, s.store.Config.MaxLogFiles) {
|
}
|
||||||
|
|
||||||
|
// applySeededStatsLocked folds statistics reconstructed from existing log files
|
||||||
|
// into the runtime map, so the details panel shows accumulated run history
|
||||||
|
// immediately rather than only runs since this process started. It is separate
|
||||||
|
// from adoptJobsLocked because producing the seeds opens every log file in the
|
||||||
|
// directory, which must not happen under mu: callers compute the map first and
|
||||||
|
// apply it here. The caller must hold mu.
|
||||||
|
func (s *Service) applySeededStatsLocked(seeds map[int]runner.SeededStats) {
|
||||||
|
for id, seed := range seeds {
|
||||||
runtime := s.runtimes[id]
|
runtime := s.runtimes[id]
|
||||||
if runtime == nil {
|
if runtime == nil {
|
||||||
continue
|
continue
|
||||||
@@ -111,6 +151,7 @@ func (s *Service) adoptJobsLocked(jobs []domain.Job) {
|
|||||||
runtime.AvgDurationMS = seed.AvgDurationMS
|
runtime.AvgDurationMS = seed.AvgDurationMS
|
||||||
runtime.MaxDurationMS = seed.MaxDurationMS
|
runtime.MaxDurationMS = seed.MaxDurationMS
|
||||||
runtime.TimedRunCount = seed.TimedRunCount
|
runtime.TimedRunCount = seed.TimedRunCount
|
||||||
|
runtime.DurationSumMS = seed.DurationSumMS
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -164,11 +205,27 @@ func Open() (*Service, error) {
|
|||||||
return svc, nil
|
return svc, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// Store returns the underlying store. It is exposed so callers that still need
|
// Config returns a copy of the current application configuration, safe to
|
||||||
// resolved paths and config (the GUI, during the transition) can reach them;
|
// call from any goroutine. UpdateSettings, SetGlobalPause, and SetJobListView
|
||||||
// later phases narrow this surface.
|
// are the only writers and all mutate store.Config under mu; copying under the
|
||||||
func (s *Service) Store() *storage.Store {
|
// same lock is what keeps a UI read from racing them, instead of holding onto
|
||||||
return s.store
|
// the *storage.Store this used to hand out (see STANDARDS: the UI reads
|
||||||
|
// Service state through typed events and accessors, never shared mutable
|
||||||
|
// state).
|
||||||
|
func (s *Service) Config() domain.Config {
|
||||||
|
s.mu.Lock()
|
||||||
|
defer s.mu.Unlock()
|
||||||
|
return s.store.Config
|
||||||
|
}
|
||||||
|
|
||||||
|
// Paths returns a copy of the store's resolved filesystem paths. AppDir and
|
||||||
|
// ConfigPath are fixed for the process; JobsPath, JobsDir, and LogsDir are
|
||||||
|
// re-derived under mu on every settings save (storage.Store.applyConfigPaths),
|
||||||
|
// so this copies under the same lock as Config for the same reason.
|
||||||
|
func (s *Service) Paths() storage.Paths {
|
||||||
|
s.mu.Lock()
|
||||||
|
defer s.mu.Unlock()
|
||||||
|
return s.store.Paths
|
||||||
}
|
}
|
||||||
|
|
||||||
// Jobs returns a copy of the durable jobs slice. Returning a copy keeps callers
|
// Jobs returns a copy of the durable jobs slice. Returning a copy keeps callers
|
||||||
|
|||||||
+1
-1
@@ -3,4 +3,4 @@ package app
|
|||||||
// Version is the application version shown in the GUI and used by build
|
// 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
|
// 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.
|
// can override it with Go ldflags when CI tags a build.
|
||||||
var Version = "1.0.0"
|
var Version = "1.0.3"
|
||||||
|
|||||||
@@ -5,6 +5,23 @@ package domain
|
|||||||
// launches omit this flag and open the normal window.
|
// launches omit this flag and open the normal window.
|
||||||
const StartInTrayArgument = "--start-in-tray"
|
const StartInTrayArgument = "--start-in-tray"
|
||||||
|
|
||||||
|
// AutostartArguments returns the command-line suffix written to a platform
|
||||||
|
// autostart entry when KeepRunningInTray is enabled. An empty string means the
|
||||||
|
// app should open its window normally after sign-in.
|
||||||
|
func AutostartArguments(keepInTray bool) string {
|
||||||
|
if keepInTray {
|
||||||
|
return StartInTrayArgument
|
||||||
|
}
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
|
||||||
|
// ResolveStartHidden reports whether an autostart launch should skip showing
|
||||||
|
// the main window. The CLI flag is ignored when KeepRunningInTray is off so a
|
||||||
|
// stale shortcut cannot hide the app with no tray icon to restore it.
|
||||||
|
func ResolveStartHidden(cliStartInTray, keepInTray bool) bool {
|
||||||
|
return cliStartInTray && keepInTray
|
||||||
|
}
|
||||||
|
|
||||||
// ExecutionMode controls whether due jobs run concurrently or one at a time.
|
// ExecutionMode controls whether due jobs run concurrently or one at a time.
|
||||||
type ExecutionMode string
|
type ExecutionMode string
|
||||||
|
|
||||||
|
|||||||
@@ -2,6 +2,31 @@ package domain
|
|||||||
|
|
||||||
import "testing"
|
import "testing"
|
||||||
|
|
||||||
|
func TestAutostartArguments(t *testing.T) {
|
||||||
|
if got := AutostartArguments(true); got != StartInTrayArgument {
|
||||||
|
t.Errorf("AutostartArguments(true) = %q, want %q", got, StartInTrayArgument)
|
||||||
|
}
|
||||||
|
if got := AutostartArguments(false); got != "" {
|
||||||
|
t.Errorf("AutostartArguments(false) = %q, want empty", got)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestResolveStartHidden(t *testing.T) {
|
||||||
|
cases := []struct {
|
||||||
|
cli, keep, want bool
|
||||||
|
}{
|
||||||
|
{true, true, true},
|
||||||
|
{true, false, false},
|
||||||
|
{false, true, false},
|
||||||
|
{false, false, false},
|
||||||
|
}
|
||||||
|
for _, tc := range cases {
|
||||||
|
if got := ResolveStartHidden(tc.cli, tc.keep); got != tc.want {
|
||||||
|
t.Errorf("ResolveStartHidden(%v, %v) = %v, want %v", tc.cli, tc.keep, got, tc.want)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// TestJobListViewIsCompact pins the normalization rule: only the exact
|
// TestJobListViewIsCompact pins the normalization rule: only the exact
|
||||||
// "compact" value selects the one-line rows, so empty and unrecognised values
|
// "compact" value selects the one-line rows, so empty and unrecognised values
|
||||||
// (including configs written before the field existed) keep the detailed look.
|
// (including configs written before the field existed) keep the detailed look.
|
||||||
@@ -22,9 +47,3 @@ func TestJobListViewIsCompact(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestDefaultConfigUsesDetailedJobList(t *testing.T) {
|
|
||||||
if got := DefaultConfig().JobListView; got != JobListViewDetailed {
|
|
||||||
t.Errorf("default JobListView = %q, want %q", got, JobListViewDetailed)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -4,13 +4,13 @@ package domain
|
|||||||
// output is also written to a log file; the in-memory Output copy exists so the
|
// 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.
|
// latest run can be displayed without reopening the log on every repaint.
|
||||||
type RunRecord struct {
|
type RunRecord struct {
|
||||||
Time string `yaml:"time"`
|
Time string
|
||||||
JobID int `yaml:"job_id"`
|
JobID int
|
||||||
JobName string `yaml:"job_name"`
|
JobName string
|
||||||
Trigger string `yaml:"trigger,omitempty"`
|
Trigger string
|
||||||
State string `yaml:"state"`
|
State string
|
||||||
Detail string `yaml:"detail"`
|
Detail string
|
||||||
LogFile string `yaml:"log_file,omitempty"`
|
LogFile string
|
||||||
Output string `yaml:"output,omitempty"`
|
Output string
|
||||||
DurationMS int64 `yaml:"duration_ms,omitempty"`
|
DurationMS int64
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -36,6 +36,14 @@ type JobRuntime struct {
|
|||||||
// launches that round to 0) increment RunCount but not this. StartOnly runs
|
// launches that round to 0) increment RunCount but not this. StartOnly runs
|
||||||
// otherwise contribute their launch latency.
|
// otherwise contribute their launch latency.
|
||||||
TimedRunCount int
|
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
|
// NewRuntime builds the initial runtime state for a freshly loaded or created
|
||||||
|
|||||||
@@ -3,7 +3,9 @@ package autostart
|
|||||||
// Manager controls platform autostart for the application.
|
// Manager controls platform autostart for the application.
|
||||||
type Manager interface {
|
type Manager interface {
|
||||||
// Set writes or removes the platform autostart entry to match enabled.
|
// Set writes or removes the platform autostart entry to match enabled.
|
||||||
Set(enabled bool, executablePath, iconPath string) error
|
// When enabled, startInTray selects whether the entry passes --start-in-tray.
|
||||||
// Status reports whether the platform autostart entry matches expectedEnabled.
|
Set(enabled, startInTray bool, executablePath, iconPath string) error
|
||||||
Status(expectedEnabled bool, executablePath string) (ok bool, message string)
|
// Status reports whether the platform autostart entry matches expectedEnabled
|
||||||
|
// and startInTray.
|
||||||
|
Status(expectedEnabled, startInTray bool, executablePath string) (ok bool, message string)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -17,17 +17,17 @@ type linuxManager struct{}
|
|||||||
// New returns the Linux autostart Manager.
|
// New returns the Linux autostart Manager.
|
||||||
func New() Manager { return linuxManager{} }
|
func New() Manager { return linuxManager{} }
|
||||||
|
|
||||||
func (linuxManager) Set(enabled bool, executablePath, iconPath string) error {
|
func (linuxManager) Set(enabled, startInTray bool, executablePath, iconPath string) error {
|
||||||
return SetAutostart(enabled, executablePath, iconPath)
|
return setAutostart(enabled, startInTray, executablePath, iconPath)
|
||||||
}
|
}
|
||||||
|
|
||||||
func (linuxManager) Status(expectedEnabled bool, executablePath string) (bool, string) {
|
func (linuxManager) Status(expectedEnabled, startInTray bool, executablePath string) (bool, string) {
|
||||||
return AutostartStatus(expectedEnabled, executablePath)
|
return autostartStatus(expectedEnabled, startInTray, executablePath)
|
||||||
}
|
}
|
||||||
|
|
||||||
const autostartDesktopFileName = "gosentry.desktop"
|
const autostartDesktopFileName = "gosentry.desktop"
|
||||||
|
|
||||||
func SetAutostart(enabled bool, executablePath string, iconPath string) error {
|
func setAutostart(enabled bool, startInTray bool, executablePath string, iconPath string) error {
|
||||||
desktopPath, err := autostartDesktopPath()
|
desktopPath, err := autostartDesktopPath()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
@@ -36,15 +36,19 @@ func SetAutostart(enabled bool, executablePath string, iconPath string) error {
|
|||||||
if err := os.MkdirAll(filepath.Dir(desktopPath), 0o755); err != nil {
|
if err := os.MkdirAll(filepath.Dir(desktopPath), 0o755); err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
execLine := quoteDesktopExec(executablePath)
|
||||||
|
if args := domain.AutostartArguments(startInTray); args != "" {
|
||||||
|
execLine += " " + args
|
||||||
|
}
|
||||||
desktopFile := fmt.Sprintf(`[Desktop Entry]
|
desktopFile := fmt.Sprintf(`[Desktop Entry]
|
||||||
Type=Application
|
Type=Application
|
||||||
Name=GoSentry
|
Name=GoSentry
|
||||||
Comment=GoSentry desktop scheduler
|
Comment=GoSentry desktop scheduler
|
||||||
Exec=%s %s
|
Exec=%s
|
||||||
%s
|
%s
|
||||||
Terminal=false
|
Terminal=false
|
||||||
X-GNOME-Autostart-enabled=true
|
X-GNOME-Autostart-enabled=true
|
||||||
`, quoteDesktopExec(executablePath), domain.StartInTrayArgument, desktopIconLine(iconPath))
|
`, execLine, desktopIconLine(iconPath))
|
||||||
return os.WriteFile(desktopPath, []byte(desktopFile), 0o644)
|
return os.WriteFile(desktopPath, []byte(desktopFile), 0o644)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -54,7 +58,7 @@ X-GNOME-Autostart-enabled=true
|
|||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func AutostartStatus(expectedEnabled bool, executablePath string) (bool, string) {
|
func autostartStatus(expectedEnabled bool, startInTray bool, executablePath string) (bool, string) {
|
||||||
desktopPath, err := autostartDesktopPath()
|
desktopPath, err := autostartDesktopPath()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return false, "Cannot resolve XDG autostart directory"
|
return false, "Cannot resolve XDG autostart directory"
|
||||||
@@ -70,9 +74,15 @@ func AutostartStatus(expectedEnabled bool, executablePath string) (bool, string)
|
|||||||
if readErr != nil {
|
if readErr != nil {
|
||||||
return false, "Autostart desktop entry is missing"
|
return false, "Autostart desktop entry is missing"
|
||||||
}
|
}
|
||||||
expectedExec := "Exec=" + quoteDesktopExec(executablePath) + " " + domain.StartInTrayArgument
|
expectedExec := "Exec=" + quoteDesktopExec(executablePath)
|
||||||
|
if args := domain.AutostartArguments(startInTray); args != "" {
|
||||||
|
expectedExec += " " + args
|
||||||
|
}
|
||||||
if !strings.Contains(string(data), expectedExec) {
|
if !strings.Contains(string(data), expectedExec) {
|
||||||
return false, "Autostart desktop entry points to another executable"
|
if startInTray {
|
||||||
|
return false, "Autostart desktop entry does not start in tray"
|
||||||
|
}
|
||||||
|
return false, "Autostart desktop entry starts in tray while setting is off"
|
||||||
}
|
}
|
||||||
return true, "Autostart is configured"
|
return true, "Autostart is configured"
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -14,7 +14,7 @@ func TestLinuxAutostartStartsInTray(t *testing.T) {
|
|||||||
t.Setenv("XDG_CONFIG_HOME", t.TempDir())
|
t.Setenv("XDG_CONFIG_HOME", t.TempDir())
|
||||||
|
|
||||||
executablePath := "/opt/Go Sentry/gosentry"
|
executablePath := "/opt/Go Sentry/gosentry"
|
||||||
if err := SetAutostart(true, executablePath, "/opt/Go Sentry/gosentry.png"); err != nil {
|
if err := setAutostart(true, true, executablePath, "/opt/Go Sentry/gosentry.png"); err != nil {
|
||||||
t.Fatalf("enable autostart: %v", err)
|
t.Fatalf("enable autostart: %v", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -33,3 +33,29 @@ func TestLinuxAutostartStartsInTray(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
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 {
|
||||||
|
t.Fatalf("enable autostart: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
desktopPath, err := autostartDesktopPath()
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("resolve desktop path: %v", err)
|
||||||
|
}
|
||||||
|
data, err := os.ReadFile(desktopPath)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("read desktop entry: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
expectedExec := "Exec=" + quoteDesktopExec(executablePath)
|
||||||
|
if !strings.Contains(string(data), expectedExec) {
|
||||||
|
t.Fatalf("desktop entry should not include tray flag: %s", data)
|
||||||
|
}
|
||||||
|
if strings.Contains(string(data), domain.StartInTrayArgument) {
|
||||||
|
t.Fatalf("desktop entry must not pass --start-in-tray: %s", data)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -9,22 +9,22 @@ type otherManager struct{}
|
|||||||
// New returns the stub autostart Manager for unsupported platforms.
|
// New returns the stub autostart Manager for unsupported platforms.
|
||||||
func New() Manager { return otherManager{} }
|
func New() Manager { return otherManager{} }
|
||||||
|
|
||||||
func (otherManager) Set(enabled bool, executablePath, iconPath string) error {
|
func (otherManager) Set(enabled, startInTray bool, executablePath, iconPath string) error {
|
||||||
return SetAutostart(enabled, executablePath, iconPath)
|
return setAutostart(enabled, startInTray, executablePath, iconPath)
|
||||||
}
|
}
|
||||||
|
|
||||||
func (otherManager) Status(expectedEnabled bool, executablePath string) (bool, string) {
|
func (otherManager) Status(expectedEnabled, startInTray bool, executablePath string) (bool, string) {
|
||||||
return AutostartStatus(expectedEnabled, executablePath)
|
return autostartStatus(expectedEnabled, startInTray, executablePath)
|
||||||
}
|
}
|
||||||
|
|
||||||
func SetAutostart(enabled bool, executablePath string, iconPath string) error {
|
func setAutostart(enabled bool, startInTray bool, executablePath string, iconPath string) error {
|
||||||
if !enabled {
|
if !enabled {
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
return fmt.Errorf("autostart is not implemented for this platform")
|
return fmt.Errorf("autostart is not implemented for this platform")
|
||||||
}
|
}
|
||||||
|
|
||||||
func AutostartStatus(expectedEnabled bool, executablePath string) (bool, string) {
|
func autostartStatus(expectedEnabled bool, startInTray bool, executablePath string) (bool, string) {
|
||||||
if !expectedEnabled {
|
if !expectedEnabled {
|
||||||
return true, "Autostart is off"
|
return true, "Autostart is off"
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -16,18 +16,18 @@ type windowsManager struct{}
|
|||||||
// New returns the Windows autostart Manager.
|
// New returns the Windows autostart Manager.
|
||||||
func New() Manager { return windowsManager{} }
|
func New() Manager { return windowsManager{} }
|
||||||
|
|
||||||
func (windowsManager) Set(enabled bool, executablePath, iconPath string) error {
|
func (windowsManager) Set(enabled, startInTray bool, executablePath, iconPath string) error {
|
||||||
return SetAutostart(enabled, executablePath, iconPath)
|
return setAutostart(enabled, startInTray, executablePath, iconPath)
|
||||||
}
|
}
|
||||||
|
|
||||||
func (windowsManager) Status(expectedEnabled bool, executablePath string) (bool, string) {
|
func (windowsManager) Status(expectedEnabled, startInTray bool, executablePath string) (bool, string) {
|
||||||
return AutostartStatus(expectedEnabled, executablePath)
|
return autostartStatus(expectedEnabled, startInTray, executablePath)
|
||||||
}
|
}
|
||||||
|
|
||||||
const autostartName = "GoSentry"
|
const autostartName = "GoSentry"
|
||||||
const startupShortcutFile = autostartName + ".lnk"
|
const startupShortcutFile = autostartName + ".lnk"
|
||||||
|
|
||||||
func SetAutostart(enabled bool, executablePath string, iconPath string) error {
|
func setAutostart(enabled bool, startInTray bool, executablePath string, iconPath string) error {
|
||||||
// Windows autostart used to write HKCU\Run values, but that approach became
|
// Windows autostart used to write HKCU\Run values, but that approach became
|
||||||
// brittle once paths with spaces and the "--start-in-tray" argument entered
|
// brittle once paths with spaces and the "--start-in-tray" argument entered
|
||||||
// the picture. A Startup-folder shortcut stores target path and arguments as
|
// the picture. A Startup-folder shortcut stores target path and arguments as
|
||||||
@@ -39,12 +39,12 @@ func SetAutostart(enabled bool, executablePath string, iconPath string) error {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if enabled {
|
if enabled {
|
||||||
return createStartupShortcut(shortcutPath, executablePath, iconPath)
|
return createStartupShortcut(shortcutPath, executablePath, iconPath, domain.AutostartArguments(startInTray))
|
||||||
}
|
}
|
||||||
return removeIfExists(shortcutPath)
|
return removeIfExists(shortcutPath)
|
||||||
}
|
}
|
||||||
|
|
||||||
func AutostartStatus(expectedEnabled bool, executablePath string) (bool, string) {
|
func autostartStatus(expectedEnabled bool, startInTray bool, executablePath string) (bool, string) {
|
||||||
shortcutPath, err := startupShortcutPath()
|
shortcutPath, err := startupShortcutPath()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return false, "Startup folder cannot be resolved"
|
return false, "Startup folder cannot be resolved"
|
||||||
@@ -74,9 +74,13 @@ func AutostartStatus(expectedEnabled bool, executablePath string) (bool, string)
|
|||||||
if !sameWindowsPath(actual, executablePath) {
|
if !sameWindowsPath(actual, executablePath) {
|
||||||
return false, "Autostart shortcut points to another executable"
|
return false, "Autostart shortcut points to another executable"
|
||||||
}
|
}
|
||||||
if strings.TrimSpace(arguments) != domain.StartInTrayArgument {
|
expectedArgs := domain.AutostartArguments(startInTray)
|
||||||
|
if strings.TrimSpace(arguments) != expectedArgs {
|
||||||
|
if startInTray {
|
||||||
return false, "Autostart shortcut does not start in tray"
|
return false, "Autostart shortcut does not start in tray"
|
||||||
}
|
}
|
||||||
|
return false, "Autostart shortcut starts in tray while setting is off"
|
||||||
|
}
|
||||||
return true, "Autostart is configured"
|
return true, "Autostart is configured"
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -88,7 +92,7 @@ func startupShortcutPath() (string, error) {
|
|||||||
return filepath.Join(appData, "Microsoft", "Windows", "Start Menu", "Programs", "Startup", startupShortcutFile), nil
|
return filepath.Join(appData, "Microsoft", "Windows", "Start Menu", "Programs", "Startup", startupShortcutFile), nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func createStartupShortcut(shortcutPath string, executablePath string, iconPath string) error {
|
func createStartupShortcut(shortcutPath string, executablePath string, iconPath string, arguments string) error {
|
||||||
if err := os.MkdirAll(filepath.Dir(shortcutPath), 0755); err != nil {
|
if err := os.MkdirAll(filepath.Dir(shortcutPath), 0755); err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
@@ -106,7 +110,7 @@ func createStartupShortcut(shortcutPath string, executablePath string, iconPath
|
|||||||
command.Env = append(os.Environ(),
|
command.Env = append(os.Environ(),
|
||||||
"GOSENTRY_SHORTCUT_PATH="+shortcutPath,
|
"GOSENTRY_SHORTCUT_PATH="+shortcutPath,
|
||||||
"GOSENTRY_TARGET_PATH="+executablePath,
|
"GOSENTRY_TARGET_PATH="+executablePath,
|
||||||
"GOSENTRY_ARGUMENTS="+domain.StartInTrayArgument,
|
"GOSENTRY_ARGUMENTS="+arguments,
|
||||||
"GOSENTRY_WORKING_DIRECTORY="+workingDirectory,
|
"GOSENTRY_WORKING_DIRECTORY="+workingDirectory,
|
||||||
"GOSENTRY_ICON_PATH="+iconPath,
|
"GOSENTRY_ICON_PATH="+iconPath,
|
||||||
)
|
)
|
||||||
@@ -122,7 +126,7 @@ func readShortcut(shortcutPath string) (string, string, error) {
|
|||||||
// OEM code page (e.g. CP866 on Russian Windows). Without this override,
|
// OEM code page (e.g. CP866 on Russian Windows). Without this override,
|
||||||
// [Console]::Out.Write encodes Cyrillic and other non-ASCII characters as
|
// [Console]::Out.Write encodes Cyrillic and other non-ASCII characters as
|
||||||
// OEM bytes; Go then reads them as UTF-8 and gets a different string from
|
// OEM bytes; Go then reads them as UTF-8 and gets a different string from
|
||||||
// os.Executable, causing AutostartStatus to report "shortcut points to
|
// os.Executable, causing autostartStatus to report "shortcut points to
|
||||||
// another executable" for any install path that contains non-ASCII chars.
|
// another executable" for any install path that contains non-ASCII chars.
|
||||||
// New-Object System.Text.UTF8Encoding($false) is UTF-8 without BOM.
|
// New-Object System.Text.UTF8Encoding($false) is UTF-8 without BOM.
|
||||||
script := `[Console]::OutputEncoding = New-Object System.Text.UTF8Encoding($false); $shell = New-Object -ComObject WScript.Shell; $shortcut = $shell.CreateShortcut($env:GOSENTRY_SHORTCUT_PATH); [Console]::Out.Write($shortcut.TargetPath + [Environment]::NewLine + $shortcut.Arguments)`
|
script := `[Console]::OutputEncoding = New-Object System.Text.UTF8Encoding($false); $shell = New-Object -ComObject WScript.Shell; $shortcut = $shell.CreateShortcut($env:GOSENTRY_SHORTCUT_PATH); [Console]::Out.Write($shortcut.TargetPath + [Environment]::NewLine + $shortcut.Arguments)`
|
||||||
|
|||||||
@@ -82,7 +82,7 @@ func TestCreateStartupShortcutHandlesCyrillicPath(t *testing.T) {
|
|||||||
t.Fatalf("create target file: %v", err)
|
t.Fatalf("create target file: %v", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
if err := createStartupShortcut(shortcutPath, targetPath, ""); err != nil {
|
if err := createStartupShortcut(shortcutPath, targetPath, "", domain.StartInTrayArgument); err != nil {
|
||||||
t.Fatalf("create shortcut: %v", err)
|
t.Fatalf("create shortcut: %v", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -98,6 +98,51 @@ func TestCreateStartupShortcutHandlesCyrillicPath(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestCreateStartupShortcutWithoutTrayFlag(t *testing.T) {
|
||||||
|
tempDir := t.TempDir()
|
||||||
|
shortcutPath := filepath.Join(tempDir, "GoSentry.lnk")
|
||||||
|
targetPath := filepath.Join(tempDir, "gosentry.exe")
|
||||||
|
if err := os.WriteFile(targetPath, []byte("test"), 0644); err != nil {
|
||||||
|
t.Fatalf("create target file: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := createStartupShortcut(shortcutPath, targetPath, "", ""); err != nil {
|
||||||
|
t.Fatalf("create shortcut: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
_, arguments, err := readShortcut(shortcutPath)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("read shortcut: %v", err)
|
||||||
|
}
|
||||||
|
if arguments != "" {
|
||||||
|
t.Fatalf("shortcut arguments mismatch: got %q want empty", arguments)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestAutostartStatusRequiresMatchingTrayFlag(t *testing.T) {
|
||||||
|
tempDir := t.TempDir()
|
||||||
|
t.Setenv("APPDATA", tempDir)
|
||||||
|
shortcutPath, err := startupShortcutPath()
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("startupShortcutPath: %v", err)
|
||||||
|
}
|
||||||
|
targetPath := filepath.Join(tempDir, "gosentry.exe")
|
||||||
|
if err := os.WriteFile(targetPath, []byte("test"), 0644); err != nil {
|
||||||
|
t.Fatalf("create target: %v", err)
|
||||||
|
}
|
||||||
|
if err := createStartupShortcut(shortcutPath, targetPath, "", domain.StartInTrayArgument); err != nil {
|
||||||
|
t.Fatalf("create shortcut: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
ok, message := autostartStatus(true, false, targetPath)
|
||||||
|
if ok {
|
||||||
|
t.Fatalf("expected problem when tray flag mismatches, got OK: %s", message)
|
||||||
|
}
|
||||||
|
if message != "Autostart shortcut starts in tray while setting is off" {
|
||||||
|
t.Fatalf("unexpected message: %q", message)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func TestCreateStartupShortcutHandlesSpaces(t *testing.T) {
|
func TestCreateStartupShortcutHandlesSpaces(t *testing.T) {
|
||||||
tempDir := t.TempDir()
|
tempDir := t.TempDir()
|
||||||
shortcutPath := filepath.Join(tempDir, "GoSentry test.lnk")
|
shortcutPath := filepath.Join(tempDir, "GoSentry test.lnk")
|
||||||
@@ -109,7 +154,7 @@ func TestCreateStartupShortcutHandlesSpaces(t *testing.T) {
|
|||||||
t.Fatalf("create target file: %v", err)
|
t.Fatalf("create target file: %v", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
if err := createStartupShortcut(shortcutPath, targetPath, ""); err != nil {
|
if err := createStartupShortcut(shortcutPath, targetPath, "", domain.StartInTrayArgument); err != nil {
|
||||||
t.Fatalf("create shortcut: %v", err)
|
t.Fatalf("create shortcut: %v", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -9,6 +9,11 @@ import (
|
|||||||
"time"
|
"time"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
// CleanupLogs enforces the count and age retention policies on the .log files
|
||||||
|
// in logsDir. maxFiles <= 0 disables the count policy and maxAgeDays <= 0
|
||||||
|
// disables the age policy, independently — "keep everything" is a value the
|
||||||
|
// user can choose in Settings, not just an internal default (STANDARDS
|
||||||
|
// §Intentional behavior).
|
||||||
func CleanupLogs(logsDir string, maxFiles int, maxAgeDays int) error {
|
func CleanupLogs(logsDir string, maxFiles int, maxAgeDays int) error {
|
||||||
entries, err := os.ReadDir(logsDir)
|
entries, err := os.ReadDir(logsDir)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
|
|||||||
@@ -64,5 +64,3 @@ func LogArguments(arguments string) string {
|
|||||||
}
|
}
|
||||||
return strings.ReplaceAll(strings.TrimSpace(arguments), "\r\n", "\n")
|
return strings.ReplaceAll(strings.TrimSpace(arguments), "\r\n", "\n")
|
||||||
}
|
}
|
||||||
|
|
||||||
func logArguments(arguments string) string { return LogArguments(arguments) }
|
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ import (
|
|||||||
"strings"
|
"strings"
|
||||||
"syscall"
|
"syscall"
|
||||||
"unicode"
|
"unicode"
|
||||||
|
"unicode/utf8"
|
||||||
)
|
)
|
||||||
|
|
||||||
func shellCommand(ctx context.Context, command string) *exec.Cmd {
|
func shellCommand(ctx context.Context, command string) *exec.Cmd {
|
||||||
@@ -32,19 +33,45 @@ func quoteLeadingWindowsProgramPath(command string) string {
|
|||||||
}
|
}
|
||||||
|
|
||||||
lower := strings.ToLower(trimmed)
|
lower := strings.ToLower(trimmed)
|
||||||
|
pathEnd := -1
|
||||||
for _, extension := range []string{".exe", ".cmd", ".bat", ".com"} {
|
for _, extension := range []string{".exe", ".cmd", ".bat", ".com"} {
|
||||||
index := strings.Index(lower, extension)
|
end := earliestBoundedExtensionEnd(lower, extension)
|
||||||
if index < 0 {
|
if end >= 0 && (pathEnd < 0 || end < pathEnd) {
|
||||||
continue
|
pathEnd = end
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if pathEnd < 0 {
|
||||||
|
return command
|
||||||
}
|
}
|
||||||
pathEnd := index + len(extension)
|
|
||||||
programPath := trimmed[:pathEnd]
|
programPath := trimmed[:pathEnd]
|
||||||
if !strings.ContainsFunc(programPath, unicode.IsSpace) {
|
if !strings.ContainsFunc(programPath, unicode.IsSpace) {
|
||||||
return command
|
return command
|
||||||
}
|
}
|
||||||
return leadingWhitespace + `"` + programPath + `"` + trimmed[pathEnd:]
|
return leadingWhitespace + `"` + programPath + `"` + trimmed[pathEnd:]
|
||||||
}
|
}
|
||||||
return command
|
|
||||||
|
// 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 {
|
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
|
// 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.
|
// avoid characters that are invalid on Windows or awkward on shells.
|
||||||
fileName := started.Format("20060102-150405") + "_" + sanitizeFileName(job.Name) + ".log"
|
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",
|
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)
|
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 {
|
if err := writeFileAtomic(logsDir, path, []byte(content), 0o644); err != nil {
|
||||||
return "", fmt.Errorf("write log file: %w", err)
|
return "", fmt.Errorf("write log file: %w", err)
|
||||||
}
|
}
|
||||||
return path, nil
|
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 {
|
func sanitizeFileName(name string) string {
|
||||||
name = strings.TrimSpace(name)
|
name = strings.TrimSpace(name)
|
||||||
if 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 detail string
|
||||||
var durationMS int64
|
var durationMS int64
|
||||||
if job.StartOnly {
|
if job.StartOnly {
|
||||||
invocation := jobInvocation(ctx, *job)
|
// A StartOnly process is deliberately never waited for, so it must not be
|
||||||
|
// tied to any cancelable context: exec.CommandContext leaves a watcher
|
||||||
|
// goroutine alive until Wait returns or the context is done, and since
|
||||||
|
// StartOnly never calls Wait that goroutine would live for the rest of the
|
||||||
|
// process — one per run — and then try to kill a process whose handle
|
||||||
|
// startJobOnly has already released. context.Background() has a nil Done
|
||||||
|
// channel, so os/exec starts no watcher at all and the started process is
|
||||||
|
// left to outlive GoSentry, which is the point of the option.
|
||||||
|
invocation := jobInvocation(context.Background(), *job)
|
||||||
// StartOnly jobs don't wait for process exit, so the duration measures
|
// StartOnly jobs don't wait for process exit, so the duration measures
|
||||||
// launch latency (time to spawn the process) rather than run time.
|
// launch latency (time to spawn the process) rather than run time.
|
||||||
state, detail, output, durationMS = startJobOnly(invocation, *job, started)
|
state, detail, output, durationMS = startJobOnly(invocation, *job, started)
|
||||||
@@ -108,7 +116,7 @@ func startOnlyOutput(job domain.Job, pid int) string {
|
|||||||
builder.WriteString("command:\n")
|
builder.WriteString("command:\n")
|
||||||
builder.WriteString(job.Command + "\n\n")
|
builder.WriteString(job.Command + "\n\n")
|
||||||
builder.WriteString("arguments:\n")
|
builder.WriteString("arguments:\n")
|
||||||
builder.WriteString(logArguments(job.Arguments))
|
builder.WriteString(LogArguments(job.Arguments))
|
||||||
builder.WriteString("\n\nstart_only:\ntrue")
|
builder.WriteString("\n\nstart_only:\ntrue")
|
||||||
return builder.String()
|
return builder.String()
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -135,8 +135,8 @@ func TestLogArguments(t *testing.T) {
|
|||||||
{"--flag\n--value", "--flag\n--value"},
|
{"--flag\n--value", "--flag\n--value"},
|
||||||
}
|
}
|
||||||
for _, tc := range cases {
|
for _, tc := range cases {
|
||||||
if got := logArguments(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)
|
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) {
|
func TestRunJobStartOnlyIgnoresTimeout(t *testing.T) {
|
||||||
command := "sh"
|
command := "sh"
|
||||||
arguments := "-c\nsleep 5"
|
arguments := "-c\nsleep 5"
|
||||||
|
|||||||
@@ -51,3 +51,44 @@ func TestWindowsShellCommandLineQuotesUnquotedProgramPath(t *testing.T) {
|
|||||||
t.Fatalf("expected command line %q, got %q", want, got)
|
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
|
AvgDurationMS int64
|
||||||
MaxDurationMS int64
|
MaxDurationMS int64
|
||||||
TimedRunCount int
|
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
|
// 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
|
return result
|
||||||
}
|
}
|
||||||
|
|
||||||
byID := make(map[int][]string)
|
byID := make(map[int][]logSummary)
|
||||||
byName := make(map[string][]string)
|
byName := make(map[string][]logSummary)
|
||||||
for _, entry := range entries {
|
for _, entry := range entries {
|
||||||
if entry.IsDir() {
|
if entry.IsDir() {
|
||||||
continue
|
continue
|
||||||
@@ -55,9 +59,10 @@ func SeedStats(logsDir string, jobs []domain.Job, maxFiles int) map[int]SeededSt
|
|||||||
if !strings.HasSuffix(strings.ToLower(name), ".log") {
|
if !strings.HasSuffix(strings.ToLower(name), ".log") {
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
path := filepath.Join(logsDir, name)
|
summary := readLogSummary(filepath.Join(logsDir, name))
|
||||||
if jobID, ok := readLogJobID(path); ok {
|
summary.name = name
|
||||||
byID[jobID] = append(byID[jobID], name)
|
if summary.hasJobID {
|
||||||
|
byID[summary.jobID] = append(byID[summary.jobID], summary)
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
base := name[:len(name)-len(".log")]
|
base := name[:len(name)-len(".log")]
|
||||||
@@ -65,7 +70,7 @@ func SeedStats(logsDir string, jobs []domain.Job, maxFiles int) map[int]SeededSt
|
|||||||
if idx < 0 {
|
if idx < 0 {
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
byName[base[idx+1:]] = append(byName[base[idx+1:]], name)
|
byName[base[idx+1:]] = append(byName[base[idx+1:]], summary)
|
||||||
}
|
}
|
||||||
|
|
||||||
for _, job := range jobs {
|
for _, job := range jobs {
|
||||||
@@ -76,80 +81,71 @@ func SeedStats(logsDir string, jobs []domain.Job, maxFiles int) map[int]SeededSt
|
|||||||
if len(files) == 0 {
|
if len(files) == 0 {
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
// The timestamp prefix sorts chronologically, so a lexical sort puts the
|
// The timestamp prefix sorts chronologically, so a lexical sort by file
|
||||||
// oldest first; keep the newest maxFiles to honor the retention bound.
|
// name puts the oldest first; keep the newest maxFiles to honor the
|
||||||
sort.Strings(files)
|
// retention bound.
|
||||||
|
sort.Slice(files, func(i, j int) bool { return files[i].name < files[j].name })
|
||||||
if maxFiles > 0 && len(files) > maxFiles {
|
if maxFiles > 0 && len(files) > maxFiles {
|
||||||
files = files[len(files)-maxFiles:]
|
files = files[len(files)-maxFiles:]
|
||||||
}
|
}
|
||||||
result[job.ID] = aggregateLogStats(logsDir, files)
|
result[job.ID] = aggregateLogStats(files)
|
||||||
}
|
}
|
||||||
return result
|
return result
|
||||||
}
|
}
|
||||||
|
|
||||||
// aggregateLogStats folds the header of each log file (oldest first) into one
|
// aggregateLogStats folds the already-read header of each log file (oldest
|
||||||
// SeededStats. Files lacking a duration line contribute to the run/fail counts
|
// first) into one SeededStats. Files lacking a duration line contribute to the
|
||||||
// but not to the duration aggregates.
|
// run/fail counts but not to the duration aggregates.
|
||||||
func aggregateLogStats(logsDir string, files []string) SeededStats {
|
func aggregateLogStats(files []logSummary) SeededStats {
|
||||||
var stats SeededStats
|
var stats SeededStats
|
||||||
var durationSum int64
|
var durationSum int64
|
||||||
var durationCount int
|
var durationCount int
|
||||||
for _, file := range files {
|
for _, file := range files {
|
||||||
state, durationMS, hasDuration := readLogHeader(filepath.Join(logsDir, file))
|
|
||||||
stats.RunCount++
|
stats.RunCount++
|
||||||
if state == "Failed" {
|
if file.state == "Failed" {
|
||||||
stats.FailCount++
|
stats.FailCount++
|
||||||
}
|
}
|
||||||
if hasDuration {
|
if file.hasDuration {
|
||||||
// Files are oldest first, so the last assignment is the newest run.
|
// Files are oldest first, so the last assignment is the newest run.
|
||||||
stats.LastDurationMS = durationMS
|
stats.LastDurationMS = file.durationMS
|
||||||
if durationMS > stats.MaxDurationMS {
|
if file.durationMS > stats.MaxDurationMS {
|
||||||
stats.MaxDurationMS = durationMS
|
stats.MaxDurationMS = file.durationMS
|
||||||
}
|
}
|
||||||
durationSum += durationMS
|
durationSum += file.durationMS
|
||||||
durationCount++
|
durationCount++
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if durationCount > 0 {
|
if durationCount > 0 {
|
||||||
stats.TimedRunCount = durationCount
|
stats.TimedRunCount = durationCount
|
||||||
|
stats.DurationSumMS = durationSum
|
||||||
stats.AvgDurationMS = durationSum / int64(durationCount)
|
stats.AvgDurationMS = durationSum / int64(durationCount)
|
||||||
}
|
}
|
||||||
return stats
|
return stats
|
||||||
}
|
}
|
||||||
|
|
||||||
// readLogJobID reads the job_id field from a log file header.
|
// logSummary is everything SeedStats needs from one run log: the file name it
|
||||||
func readLogJobID(path string) (int, bool) {
|
// sorts by, which job wrote it, how the run ended, and how long it took.
|
||||||
file, err := os.Open(path)
|
type logSummary struct {
|
||||||
if err != nil {
|
name string
|
||||||
return 0, false
|
jobID int
|
||||||
}
|
hasJobID bool
|
||||||
defer file.Close()
|
state string
|
||||||
|
durationMS int64
|
||||||
scanner := bufio.NewScanner(file)
|
hasDuration bool
|
||||||
for scanner.Scan() {
|
|
||||||
line := scanner.Text()
|
|
||||||
if line == "" {
|
|
||||||
break
|
|
||||||
}
|
|
||||||
if rest, ok := strings.CutPrefix(line, "job_id: "); ok {
|
|
||||||
id, err := strconv.Atoi(strings.TrimSpace(rest))
|
|
||||||
if err != nil {
|
|
||||||
return 0, false
|
|
||||||
}
|
|
||||||
return id, true
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return 0, false
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// readLogHeader reads the "state" and "duration" fields from a log file's
|
// readLogSummary reads the job_id, state, and duration fields from a log file's
|
||||||
// header (the lines before the first blank line). hasDuration reports whether a
|
// header (the lines before the first blank line) in a single pass, so seeding
|
||||||
// well-formed duration line was present, distinguishing a legacy duration-less
|
// opens each log once rather than once to find its job and again to read its
|
||||||
// log from one that genuinely recorded a zero-millisecond run.
|
// result. The has* flags report whether a well-formed line was present,
|
||||||
func readLogHeader(path string) (state string, durationMS int64, hasDuration bool) {
|
// distinguishing a legacy log written before the field existed from one that
|
||||||
|
// genuinely recorded a zero value. An unreadable file yields a zero summary,
|
||||||
|
// which falls back to matching by the job name in the file name.
|
||||||
|
func readLogSummary(path string) logSummary {
|
||||||
|
var summary logSummary
|
||||||
file, err := os.Open(path)
|
file, err := os.Open(path)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return "", 0, false
|
return summary
|
||||||
}
|
}
|
||||||
defer file.Close()
|
defer file.Close()
|
||||||
|
|
||||||
@@ -159,14 +155,19 @@ func readLogHeader(path string) (state string, durationMS int64, hasDuration boo
|
|||||||
if line == "" {
|
if line == "" {
|
||||||
break // end of header
|
break // end of header
|
||||||
}
|
}
|
||||||
if rest, ok := strings.CutPrefix(line, "state: "); ok {
|
if rest, ok := strings.CutPrefix(line, "job_id: "); ok {
|
||||||
state = strings.TrimSpace(rest)
|
if id, err := strconv.Atoi(strings.TrimSpace(rest)); err == nil {
|
||||||
|
summary.jobID = id
|
||||||
|
summary.hasJobID = true
|
||||||
|
}
|
||||||
|
} else if rest, ok := strings.CutPrefix(line, "state: "); ok {
|
||||||
|
summary.state = strings.TrimSpace(rest)
|
||||||
} else if rest, ok := strings.CutPrefix(line, "duration: "); ok {
|
} else if rest, ok := strings.CutPrefix(line, "duration: "); ok {
|
||||||
if value, err := strconv.ParseInt(strings.TrimSpace(rest), 10, 64); err == nil {
|
if value, err := strconv.ParseInt(strings.TrimSpace(rest), 10, 64); err == nil {
|
||||||
durationMS = value
|
summary.durationMS = value
|
||||||
hasDuration = true
|
summary.hasDuration = true
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
return state, durationMS, hasDuration
|
return summary
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -61,6 +61,9 @@ func TestSeedStatsBasic(t *testing.T) {
|
|||||||
if s.AvgDurationMS != 400 {
|
if s.AvgDurationMS != 400 {
|
||||||
t.Errorf("AvgDurationMS = %d, want 400", s.AvgDurationMS)
|
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
|
// TestSeedStatsDurationLessLegacyLog verifies that a log without a duration
|
||||||
|
|||||||
+138
-24
@@ -16,6 +16,28 @@ type Store struct {
|
|||||||
Config domain.Config
|
Config domain.Config
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 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 {
|
||||||
|
return domain.DefaultConfig().KeepRunningInTray
|
||||||
|
}
|
||||||
|
config, err := loadOrCreateConfig(paths)
|
||||||
|
if err != nil {
|
||||||
|
return domain.DefaultConfig().KeepRunningInTray
|
||||||
|
}
|
||||||
|
return config.KeepRunningInTray
|
||||||
|
}
|
||||||
|
|
||||||
func OpenStore() (*Store, []domain.Job, error) {
|
func OpenStore() (*Store, []domain.Job, error) {
|
||||||
paths, err := ResolvePaths()
|
paths, err := ResolvePaths()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -50,19 +72,47 @@ func OpenStore() (*Store, []domain.Job, error) {
|
|||||||
return store, jobs, nil
|
return store, jobs, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func (s *Store) SaveConfig() error {
|
// PrepareSaveConfig re-resolves the derived paths from the current config and
|
||||||
|
// snapshots everything the write needs, returning the write itself as a closure.
|
||||||
|
// It exists so a caller that guards the Store with its own lock can do the file
|
||||||
|
// I/O — a marshal, an fsync, and a rename — after releasing that lock: the
|
||||||
|
// snapshot cannot change under the closure, so running it unlocked is safe.
|
||||||
|
// Prepared writes must be run in the order they were prepared, or an older
|
||||||
|
// snapshot can land on top of a newer one.
|
||||||
|
func (s *Store) PrepareSaveConfig() func() error {
|
||||||
s.applyConfigPaths()
|
s.applyConfigPaths()
|
||||||
if err := os.MkdirAll(s.Paths.AppDir, 0o755); err != nil {
|
dir := s.Paths.AppDir
|
||||||
|
path := s.Paths.ConfigPath
|
||||||
|
config := s.Config
|
||||||
|
return func() error {
|
||||||
|
if err := os.MkdirAll(dir, 0o755); err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
return writeJSON(s.Paths.ConfigPath, s.Config)
|
return writeJSON(path, config)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// PrepareSaveJobs is PrepareSaveConfig for the jobs file. The jobs slice is
|
||||||
|
// copied, so the caller may keep mutating its own slice as soon as this returns.
|
||||||
|
func (s *Store) PrepareSaveJobs(jobs []domain.Job) func() error {
|
||||||
|
dir := s.Paths.JobsDir
|
||||||
|
path := s.Paths.JobsPath
|
||||||
|
snapshot := make([]domain.Job, len(jobs))
|
||||||
|
copy(snapshot, jobs)
|
||||||
|
return func() error {
|
||||||
|
if err := os.MkdirAll(dir, 0o755); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
return writeJSON(path, domain.JobsFile{Jobs: snapshot})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Store) SaveConfig() error {
|
||||||
|
return s.PrepareSaveConfig()()
|
||||||
}
|
}
|
||||||
|
|
||||||
func (s *Store) SaveJobs(jobs []domain.Job) error {
|
func (s *Store) SaveJobs(jobs []domain.Job) error {
|
||||||
if err := os.MkdirAll(s.Paths.JobsDir, 0o755); err != nil {
|
return s.PrepareSaveJobs(jobs)()
|
||||||
return err
|
|
||||||
}
|
|
||||||
return writeJSON(s.Paths.JobsPath, domain.JobsFile{Jobs: jobs})
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func loadOrCreateConfig(paths Paths) (domain.Config, error) {
|
func loadOrCreateConfig(paths Paths) (domain.Config, error) {
|
||||||
@@ -101,12 +151,12 @@ func loadOrCreateConfig(paths Paths) (domain.Config, error) {
|
|||||||
if strings.TrimSpace(config.LogsDir) == "" {
|
if strings.TrimSpace(config.LogsDir) == "" {
|
||||||
config.LogsDir = "logs"
|
config.LogsDir = "logs"
|
||||||
}
|
}
|
||||||
if config.MaxLogFiles <= 0 {
|
// MaxLogFiles and MaxLogAgeDays are deliberately not normalized: 0 means
|
||||||
config.MaxLogFiles = 100
|
// "keep everything" (see runner.CleanupLogs), not a missing value, so
|
||||||
}
|
// backfilling it here would make that choice impossible to persist. A config
|
||||||
if config.MaxLogAgeDays <= 0 {
|
// written before either field existed already carries 0 from json.Unmarshal
|
||||||
config.MaxLogAgeDays = 30
|
// leaving the DefaultConfig() value in config untouched, so old files still
|
||||||
}
|
// pick up 100 / 30 without an explicit backfill.
|
||||||
if config.ExecutionMode == "" {
|
if config.ExecutionMode == "" {
|
||||||
config.ExecutionMode = domain.ExecutionModeParallel
|
config.ExecutionMode = domain.ExecutionModeParallel
|
||||||
}
|
}
|
||||||
@@ -154,8 +204,9 @@ func loadOrCreateJobs(path string) ([]domain.Job, error) {
|
|||||||
if found {
|
if found {
|
||||||
return jobs, nil
|
return jobs, nil
|
||||||
}
|
}
|
||||||
// Seed harmless sample jobs so a new user can immediately see scheduled
|
// Seed sample jobs so a new user can immediately see scheduled and manual
|
||||||
// and manual execution without inventing a command.
|
// execution without inventing a command. The failure sample stays disabled
|
||||||
|
// so it does not spam notifications; Run now still works for testing.
|
||||||
jobs = defaultJobs()
|
jobs = defaultJobs()
|
||||||
normalizeJobs(jobs)
|
normalizeJobs(jobs)
|
||||||
return jobs, writeJSON(path, domain.JobsFile{Jobs: jobs})
|
return jobs, writeJSON(path, domain.JobsFile{Jobs: jobs})
|
||||||
@@ -163,13 +214,18 @@ func loadOrCreateJobs(path string) ([]domain.Job, error) {
|
|||||||
|
|
||||||
func normalizeJobs(jobs []domain.Job) {
|
func normalizeJobs(jobs []domain.Job) {
|
||||||
next := 1
|
next := 1
|
||||||
|
seen := make(map[int]bool, len(jobs))
|
||||||
for index := range jobs {
|
for index := range jobs {
|
||||||
job := &jobs[index]
|
job := &jobs[index]
|
||||||
if job.ID <= 0 {
|
if job.ID <= 0 || seen[job.ID] {
|
||||||
// IDs are assigned only when absent. Existing IDs stay stable because
|
// IDs are assigned only when absent or already claimed by an earlier job
|
||||||
// History and future log associations use them to identify jobs.
|
// 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
|
job.ID = next
|
||||||
}
|
}
|
||||||
|
seen[job.ID] = true
|
||||||
if job.ID >= next {
|
if job.ID >= next {
|
||||||
next = job.ID + 1
|
next = job.ID + 1
|
||||||
}
|
}
|
||||||
@@ -197,7 +253,12 @@ func normalizeJobs(jobs []domain.Job) {
|
|||||||
// apply the same rule to a path the user has typed but not yet saved.
|
// apply the same rule to a path the user has typed but not yet saved.
|
||||||
func ResolveConfiguredPath(appDir string, path string) string {
|
func ResolveConfiguredPath(appDir string, path string) string {
|
||||||
if filepath.IsAbs(path) {
|
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
|
// Relative paths are resolved against the executable directory, not the
|
||||||
// process working directory. This matches ResolvePaths and keeps shortcuts,
|
// process working directory. This matches ResolvePaths and keeps shortcuts,
|
||||||
@@ -214,7 +275,8 @@ func (s *Store) applyConfigPaths() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func writeJSON(path string, value any) error {
|
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
|
return err
|
||||||
}
|
}
|
||||||
data, err := json.MarshalIndent(value, "", " ")
|
data, err := json.MarshalIndent(value, "", " ")
|
||||||
@@ -224,10 +286,47 @@ func writeJSON(path string, value any) error {
|
|||||||
// A trailing newline keeps the file friendly to editors and diff tools that
|
// A trailing newline keeps the file friendly to editors and diff tools that
|
||||||
// expect text files to end with one.
|
// expect text files to end with one.
|
||||||
data = append(data, '\n')
|
data = append(data, '\n')
|
||||||
// WriteFile replaces the full file instead of patching it in place. For small
|
return writeFileAtomic(dir, path, data, 0o644)
|
||||||
// JSON files this is simpler and prevents stale keys from older versions from
|
}
|
||||||
// lingering after the schema changes.
|
|
||||||
return os.WriteFile(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 {
|
func defaultJobs() []domain.Job {
|
||||||
@@ -255,9 +354,24 @@ func defaultJobs() []domain.Job {
|
|||||||
Command: echoCommand("This paused sample should not run until enabled"),
|
Command: echoCommand("This paused sample should not run until enabled"),
|
||||||
Enabled: false,
|
Enabled: false,
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
ID: 4,
|
||||||
|
Name: "Failure notification test",
|
||||||
|
Folder: "Examples",
|
||||||
|
Schedule: "@every 1m",
|
||||||
|
Command: failCommand(),
|
||||||
|
Enabled: false,
|
||||||
|
},
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func failCommand() string {
|
||||||
|
if runtime.GOOS == "windows" {
|
||||||
|
return "exit /b 1"
|
||||||
|
}
|
||||||
|
return "exit 1"
|
||||||
|
}
|
||||||
|
|
||||||
func echoCommand(message string) string {
|
func echoCommand(message string) string {
|
||||||
if runtime.GOOS == "windows" {
|
if runtime.GOOS == "windows" {
|
||||||
return "echo " + message
|
return "echo " + message
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import (
|
|||||||
"encoding/json"
|
"encoding/json"
|
||||||
"os"
|
"os"
|
||||||
"path/filepath"
|
"path/filepath"
|
||||||
|
"runtime"
|
||||||
"strings"
|
"strings"
|
||||||
"testing"
|
"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) {
|
func TestLoadOrCreateConfigCreatesDefaultsOnFirstRun(t *testing.T) {
|
||||||
dir := t.TempDir()
|
dir := t.TempDir()
|
||||||
paths := Paths{
|
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
|
// TestLoadOrCreateJobsSeedsSampleJobsOnFirstRun verifies that a missing
|
||||||
// jobs.json is created with the sample jobs from defaultJobs, so a new user
|
// jobs.json is created with the sample jobs from defaultJobs, so a new user
|
||||||
// sees scheduled and manual execution without inventing a command.
|
// sees scheduled and manual execution without inventing a command.
|
||||||
@@ -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"
|
"strings"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"gitea.mixdep.ru/mix/gosentry/src/domain"
|
|
||||||
|
|
||||||
"fyne.io/fyne/v2"
|
"fyne.io/fyne/v2"
|
||||||
"fyne.io/fyne/v2/container"
|
"fyne.io/fyne/v2/container"
|
||||||
"fyne.io/fyne/v2/theme"
|
"fyne.io/fyne/v2/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.
|
// textWidth measures how wide s renders at the theme's current body text size.
|
||||||
func textWidth(s string) float32 {
|
func textWidth(s string) float32 {
|
||||||
return fyne.MeasureText(s, theme.TextSize(), fyne.TextStyle{}).Width
|
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
|
// Detail and Log are free text, so their width tracks the values actually
|
||||||
// present, bounded the same way the Log column always was.
|
// present, bounded the same way the Log column always was.
|
||||||
func historyColumnWidths(rows []event) [6]float32 {
|
func historyColumnWidths(rows []event) [6]float32 {
|
||||||
jobNames := make([]string, 0, len(rows))
|
var content [3][]string
|
||||||
details := make([]string, 0, len(rows))
|
for i := range content {
|
||||||
logNames := make([]string, 0, len(rows))
|
content[i] = make([]string, 0, len(rows))
|
||||||
|
}
|
||||||
for _, current := range rows {
|
for _, current := range rows {
|
||||||
jobNames = append(jobNames, current.JobName)
|
for i, value := range historyContentValues(current) {
|
||||||
details = append(details, current.Detail)
|
content[i] = append(content[i], value)
|
||||||
logNames = append(logNames, logFileName(current.LogFile))
|
}
|
||||||
}
|
}
|
||||||
min, max := textColumnMinWidth(), textColumnMaxWidth()
|
min, max := textColumnMinWidth(), textColumnMaxWidth()
|
||||||
return [6]float32{
|
widths := [6]float32{
|
||||||
textWidth(historyTimeSample) + cellPadding(),
|
0: textWidth(historyTimeSample) + cellPadding(),
|
||||||
textColumnWidth(historyTriggerSamples, min, max),
|
1: textColumnWidth(historyTriggerSamples, min, max),
|
||||||
textColumnWidth(jobNames, min, max),
|
3: textColumnWidth(historyStateSamples, min, max),
|
||||||
textColumnWidth(historyStateSamples, min, max),
|
|
||||||
textColumnWidth(details, min, max),
|
|
||||||
textColumnWidth(logNames, 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.
|
// 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.
|
// Time caption is built per update because it carries the sort direction arrow.
|
||||||
var historyHeaders = [...]string{"Time", "Trigger", "Job", "State", "Detail", "Log"}
|
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
|
descending := false
|
||||||
headerText := func(id widget.TableCellID) string {
|
headerText := func(id widget.TableCellID) string {
|
||||||
if id.Row < 0 && id.Col == 0 {
|
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().
|
// per redraw: at build time, on a sort toggle, and from refresh().
|
||||||
var rows []event
|
var rows []event
|
||||||
resort := func() {
|
resort := func() {
|
||||||
rows = append(rows[:0], (*events)...)
|
rows = append(rows[:0], log.records...)
|
||||||
sort.SliceStable(rows, func(left int, right int) bool {
|
sort.SliceStable(rows, func(left int, right int) bool {
|
||||||
if descending {
|
if descending {
|
||||||
return rows[left].Time > rows[right].Time
|
return rows[left].Time > rows[right].Time
|
||||||
@@ -224,16 +296,17 @@ func newHistoryView(events *[]event) (*fyne.Container, func()) {
|
|||||||
table.Unselect(id)
|
table.Unselect(id)
|
||||||
}
|
}
|
||||||
setColumnWidths := func() {
|
setColumnWidths := func() {
|
||||||
for col, width := range historyColumnWidths(rows) {
|
for col, width := range log.columnWidths() {
|
||||||
table.SetColumnWidth(col, width)
|
table.SetColumnWidth(col, width)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
setColumnWidths()
|
setColumnWidths()
|
||||||
|
|
||||||
// refresh re-reads the event list into the sorted snapshot and recomputes
|
// refresh re-reads the event list into the sorted snapshot and re-applies
|
||||||
// every content-fit column width before redrawing, so newly recorded events
|
// the column widths before redrawing, so newly recorded events appear in
|
||||||
// appear in the current sort order and longer values widen their column
|
// the current sort order and longer values widen their column instead of
|
||||||
// instead of being truncated.
|
// being truncated. The widths come from historyLog, which folded each new
|
||||||
|
// record in as it arrived — this does not rescan every row.
|
||||||
refresh := func() {
|
refresh := func() {
|
||||||
resort()
|
resort()
|
||||||
setColumnWidths()
|
setColumnWidths()
|
||||||
|
|||||||
+95
-31
@@ -1,12 +1,11 @@
|
|||||||
package ui
|
package ui
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"strconv"
|
||||||
"strings"
|
"strings"
|
||||||
"testing"
|
"testing"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"gitea.mixdep.ru/mix/gosentry/src/domain"
|
|
||||||
|
|
||||||
"fyne.io/fyne/v2"
|
"fyne.io/fyne/v2"
|
||||||
"fyne.io/fyne/v2/test"
|
"fyne.io/fyne/v2/test"
|
||||||
"fyne.io/fyne/v2/widget"
|
"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) {
|
func TestHistoryCellText(t *testing.T) {
|
||||||
events := []event{{
|
events := []event{{
|
||||||
Time: "2026-06-01 12:00:00",
|
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 11:00:00", JobName: "B"},
|
||||||
{Time: "2026-06-01 12:00:00", JobName: "C"},
|
{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)
|
table, ok := content.Objects[0].(*widget.Table)
|
||||||
if !ok {
|
if !ok {
|
||||||
t.Fatal("history view does not wrap a table")
|
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
|
// 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.
|
// 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()
|
refresh()
|
||||||
assertOrder("descending after refresh", "D", "C", "B", "A")
|
assertOrder("descending after refresh", "D", "C", "B", "A")
|
||||||
|
|
||||||
@@ -207,8 +182,7 @@ func TestHistoryCellTemplateIsPlainText(t *testing.T) {
|
|||||||
testApp := test.NewApp()
|
testApp := test.NewApp()
|
||||||
defer testApp.Quit()
|
defer testApp.Quit()
|
||||||
|
|
||||||
var events []event
|
content, _ := newHistoryView(newHistoryLog(nil))
|
||||||
content, _ := newHistoryView(&events)
|
|
||||||
table := content.Objects[0].(*widget.Table)
|
table := content.Objects[0].(*widget.Table)
|
||||||
label, ok := table.CreateCell().(*widget.Label)
|
label, ok := table.CreateCell().(*widget.Label)
|
||||||
if !ok {
|
if !ok {
|
||||||
@@ -292,6 +266,96 @@ func TestHistoryColumnsFitTheirContent(t *testing.T) {
|
|||||||
check("scaled theme")
|
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) {
|
func TestNewEventUsesConsistentTimestampShape(t *testing.T) {
|
||||||
ev := newEvent(1, "Job", "OK", "detail")
|
ev := newEvent(1, "Job", "OK", "detail")
|
||||||
if _, err := time.Parse("2006-01-02 15:04:05", ev.Time); err != nil {
|
if _, err := time.Parse("2006-01-02 15:04:05", ev.Time); err != nil {
|
||||||
|
|||||||
+153
-308
@@ -1,8 +1,6 @@
|
|||||||
package ui
|
package ui
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"fmt"
|
|
||||||
|
|
||||||
"gitea.mixdep.ru/mix/gosentry/src/app"
|
"gitea.mixdep.ru/mix/gosentry/src/app"
|
||||||
"gitea.mixdep.ru/mix/gosentry/src/domain"
|
"gitea.mixdep.ru/mix/gosentry/src/domain"
|
||||||
|
|
||||||
@@ -22,340 +20,187 @@ const noFolder = "No folder"
|
|||||||
// view; this panel is a quick at-a-glance summary anchored below the output.
|
// view; this panel is a quick at-a-glance summary anchored below the output.
|
||||||
const maxJobActivityRows = 3
|
const maxJobActivityRows = 3
|
||||||
|
|
||||||
|
// jobsView owns the Jobs tab: the widgets, the view-only preferences they draw
|
||||||
|
// (list mode and the scheduler pause label), and the jobsViewState the widgets
|
||||||
|
// read. It replaces a single constructor whose dozen closures shared seven
|
||||||
|
// mutable locals — the state each handler touches is now named on the struct
|
||||||
|
// rather than captured, and the invariants that used to be maintained by hand in
|
||||||
|
// five places live on jobsViewState.
|
||||||
|
type jobsView struct {
|
||||||
|
w fyne.Window
|
||||||
|
svc *app.Service
|
||||||
|
state *jobsViewState
|
||||||
|
dp *detailsPanel
|
||||||
|
|
||||||
|
list *widget.List
|
||||||
|
folderSelect *widget.Select
|
||||||
|
viewButton *widget.Button
|
||||||
|
stopAllButton *widget.Button
|
||||||
|
schedulerState *widget.Label
|
||||||
|
|
||||||
|
// listView and paused mirror Service-owned config so the widgets can be
|
||||||
|
// relabelled without a round trip. Both are re-read from the Service on every
|
||||||
|
// refresh; neither is a source of truth.
|
||||||
|
listView domain.JobListView
|
||||||
|
paused bool
|
||||||
|
}
|
||||||
|
|
||||||
// newJobsView builds the Jobs tab: list sidebar, details panel, and toolbar.
|
// newJobsView builds the Jobs tab: list sidebar, details panel, and toolbar.
|
||||||
// It returns the assembled panel and a refresh function the caller invokes
|
// It returns the assembled panel and a refresh function the caller invokes
|
||||||
// whenever the service state may have changed (e.g., from the event subscriber
|
// whenever the service state may have changed (e.g., from the event subscriber
|
||||||
// in mainwindow.go). The refresh function re-reads the service snapshot and
|
// in mainwindow.go). The refresh function re-reads the service snapshot and
|
||||||
// redraws all widgets in the jobs view; it does NOT touch history or settings.
|
// redraws all widgets in the jobs view; it does NOT touch history or settings.
|
||||||
func newJobsView(w fyne.Window, svc *app.Service) (fyne.CanvasObject, func()) {
|
func newJobsView(w fyne.Window, svc *app.Service) (fyne.CanvasObject, func()) {
|
||||||
jobs := svc.Jobs()
|
config := svc.Config()
|
||||||
runtimes := make(map[int]*domain.JobRuntime, len(jobs))
|
v := &jobsView{
|
||||||
syncFromService := func() {
|
w: w,
|
||||||
jobs = svc.Jobs()
|
svc: svc,
|
||||||
for id := range runtimes {
|
state: newJobsViewState(svc),
|
||||||
delete(runtimes, id)
|
listView: config.JobListView,
|
||||||
|
paused: config.Paused,
|
||||||
}
|
}
|
||||||
for _, current := range jobs {
|
v.dp = newDetailsPanel(job{}, &domain.JobRuntime{}, config.OverlapPolicy, config.DefaultTimeoutSeconds)
|
||||||
if rt := svc.Runtime(current.ID); rt != nil {
|
v.updateDetails()
|
||||||
runtimes[current.ID] = rt
|
|
||||||
}
|
// 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.
|
||||||
syncFromService()
|
v.list = v.newList()
|
||||||
runtimeFor := func(index int) *domain.JobRuntime {
|
v.viewButton = v.newViewToggle()
|
||||||
if index < 0 || index >= len(jobs) {
|
globalControls := v.newGlobalControls()
|
||||||
return &domain.JobRuntime{}
|
v.folderSelect = v.newFolderSelect()
|
||||||
}
|
v.folderSelect.SetSelected(v.state.folder)
|
||||||
if rt := runtimes[jobs[index].ID]; rt != nil {
|
v.syncListSelection()
|
||||||
return rt
|
|
||||||
}
|
return v.assemble(globalControls), v.refresh
|
||||||
return &domain.JobRuntime{}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
selected := 0
|
// refresh re-reads the Service and redraws the whole view. It is the single
|
||||||
if len(jobs) == 0 {
|
// entry point for "something changed": the toolbar handlers call it after a
|
||||||
selected = -1
|
// successful operation, and mainwindow's event observer calls it for everything
|
||||||
}
|
// else.
|
||||||
selectedFolder := allFolders
|
func (v *jobsView) refresh() {
|
||||||
schedulerPaused := svc.Store().Config.Paused
|
v.state.sync()
|
||||||
listView := svc.Store().Config.JobListView
|
// The pause state is Service-owned and can change from outside this view, so
|
||||||
filteredJobs := filteredJobIndexes(jobs, selectedFolder)
|
// it is re-read here rather than mirrored from the tap handler alone — that is
|
||||||
|
// what makes this view a consumer of SchedulerStateChanged.
|
||||||
dp := newDetailsPanel(job{}, &domain.JobRuntime{}, svc.Store().Config.OverlapPolicy, svc.Store().Config.DefaultTimeoutSeconds)
|
v.applySchedulerState(v.svc.Config().Paused)
|
||||||
if selected >= 0 {
|
// updateDetails already ends in a d.logs.Refresh() (both its update and clear
|
||||||
dp.update(jobs[selected], runtimeFor(selected), svc.Store().Config.OverlapPolicy, svc.Store().Config.DefaultTimeoutSeconds)
|
// paths do), so refreshing the activity list again here would redraw it twice
|
||||||
} else {
|
// per call.
|
||||||
dp.clear()
|
v.updateDetails()
|
||||||
|
v.list.Refresh()
|
||||||
|
v.syncListSelection()
|
||||||
}
|
}
|
||||||
|
|
||||||
updateDetails := func(index int) {
|
// updateDetails repopulates the details pane from the current selection.
|
||||||
if index < 0 || index >= len(jobs) {
|
func (v *jobsView) updateDetails() {
|
||||||
// A folder filter can temporarily leave no selectable rows. Clearing
|
current, ok := v.state.selected()
|
||||||
// the details panel avoids showing stale information for a hidden job.
|
if !ok {
|
||||||
dp.clear()
|
// 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
|
return
|
||||||
}
|
}
|
||||||
selected = index
|
// Overlap policy and the default timeout are global settings that can change
|
||||||
dp.update(jobs[selected], runtimeFor(selected), svc.Store().Config.OverlapPolicy, svc.Store().Config.DefaultTimeoutSeconds)
|
// 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)
|
||||||
}
|
}
|
||||||
|
|
||||||
// list and folderSelect are declared early so closures below can reference
|
// syncListSelection points the list's highlight at the selected job. It is what
|
||||||
// them before the widget.NewList / widget.NewSelect calls assign the values.
|
// keeps the highlight and the details pane describing the same job when the row
|
||||||
var list *widget.List
|
// a job sits in moves — a job created or deleted above it, a folder filter
|
||||||
var folderSelect *widget.Select
|
// 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
|
||||||
refreshView := func() {
|
// fight the user's scrolling.
|
||||||
syncFromService()
|
func (v *jobsView) syncListSelection() {
|
||||||
filteredJobs = filteredJobIndexes(jobs, selectedFolder)
|
row := v.state.displayRow()
|
||||||
updateDetails(selected)
|
if row < 0 {
|
||||||
dp.logs.Refresh()
|
v.list.UnselectAll()
|
||||||
if list != nil {
|
return
|
||||||
list.Refresh()
|
|
||||||
}
|
}
|
||||||
|
v.list.Select(row)
|
||||||
}
|
}
|
||||||
|
|
||||||
// applyRowMode expresses the current view mode as visibility on the row's
|
// rebuildFolders re-derives the folder filter's options from the current jobs.
|
||||||
// four labels. widget.List caches the row template's MinSize, and
|
// Creating, editing, and deleting a job can all add or remove a folder.
|
||||||
// list.Refresh() re-creates the template and recomputes it, so hiding lines
|
func (v *jobsView) rebuildFolders() {
|
||||||
// is what actually shrinks the rows: layout.NewCustomPaddedVBoxLayout and the
|
v.folderSelect.Options = folderOptions(v.state.jobs)
|
||||||
// border layout both skip hidden children when measuring.
|
v.folderSelect.Refresh()
|
||||||
applyRowMode := func(inlineStatus, meta, status fyne.CanvasObject) {
|
|
||||||
if listView.IsCompact() {
|
|
||||||
inlineStatus.Show()
|
|
||||||
meta.Hide()
|
|
||||||
status.Hide()
|
|
||||||
return
|
|
||||||
}
|
|
||||||
inlineStatus.Hide()
|
|
||||||
meta.Show()
|
|
||||||
status.Show()
|
|
||||||
}
|
}
|
||||||
|
|
||||||
list = widget.NewList(
|
// assemble puts the sidebar (global controls, folder filter, toolbar, list) and
|
||||||
func() int { return len(filteredJobs) },
|
// the details pane into the master/detail split the tab shows.
|
||||||
func() fyne.CanvasObject {
|
func (v *jobsView) assemble(globalControls fyne.CanvasObject) fyne.CanvasObject {
|
||||||
name := widget.NewLabelWithStyle("Job name", fyne.TextAlignLeading, fyne.TextStyle{Bold: true})
|
|
||||||
// Truncating stops a long name from pushing the compact row's status
|
|
||||||
// off the right-hand edge. Labels default to TextWrapOff, which grows
|
|
||||||
// the widget to fit instead.
|
|
||||||
name.Truncation = fyne.TextTruncateClip
|
|
||||||
inlineStatus := widget.NewLabel("status")
|
|
||||||
meta := widget.NewLabel("schedule")
|
|
||||||
status := widget.NewLabel("status")
|
|
||||||
applyRowMode(inlineStatus, meta, status)
|
|
||||||
nameLine := container.NewBorder(nil, nil, nil, inlineStatus, name)
|
|
||||||
return container.New(layout.NewCustomPaddedVBoxLayout(rowOverlap()), nameLine, meta, status)
|
|
||||||
},
|
|
||||||
func(id widget.ListItemID, item fyne.CanvasObject) {
|
|
||||||
row := item.(*fyne.Container)
|
|
||||||
// NewBorder keeps the center object first and appends the border slots
|
|
||||||
// after it, so nameLine is [name, inlineStatus].
|
|
||||||
nameLine := row.Objects[0].(*fyne.Container)
|
|
||||||
name := nameLine.Objects[0].(*widget.Label)
|
|
||||||
inlineStatus := nameLine.Objects[1].(*widget.Label)
|
|
||||||
meta := row.Objects[1].(*widget.Label)
|
|
||||||
status := row.Objects[2].(*widget.Label)
|
|
||||||
|
|
||||||
current := jobs[filteredJobs[id]]
|
|
||||||
name.SetText(current.Name)
|
|
||||||
// Keep each row compact: folder, schedule, and command are shown in one
|
|
||||||
// metadata line so the left pane stays useful even with many jobs.
|
|
||||||
meta.SetText(app.DisplayFolder(current.Folder) + " " + current.Schedule + " " + app.DisplayInvocation(current))
|
|
||||||
statusText := app.StatusText(current, runtimes[current.ID])
|
|
||||||
status.SetText(statusText)
|
|
||||||
inlineStatus.SetText(statusText)
|
|
||||||
// A full Refresh reuses rows built under the previous mode, so
|
|
||||||
// visibility cannot be left to the create callback alone.
|
|
||||||
applyRowMode(inlineStatus, meta, status)
|
|
||||||
},
|
|
||||||
)
|
|
||||||
list.OnSelected = func(id widget.ListItemID) {
|
|
||||||
if id < 0 || id >= len(filteredJobs) {
|
|
||||||
updateDetails(-1)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
updateDetails(filteredJobs[id])
|
|
||||||
}
|
|
||||||
if len(filteredJobs) > 0 && selected >= 0 {
|
|
||||||
list.Select(app.DisplayIndex(filteredJobs, selected))
|
|
||||||
}
|
|
||||||
|
|
||||||
folderSelect = widget.NewSelect(folderOptions(jobs), func(value string) {
|
|
||||||
if value == "" {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
selectedFolder = value
|
|
||||||
filteredJobs = filteredJobIndexes(jobs, selectedFolder)
|
|
||||||
if len(filteredJobs) == 0 {
|
|
||||||
// The "No folder" filter is intentionally allowed to be empty. It is a
|
|
||||||
// real filter choice, not an error state, so the selection is cleared.
|
|
||||||
// This path returns without reaching refreshView(), so it is the one
|
|
||||||
// place the list has to be redrawn by hand.
|
|
||||||
selected = -1
|
|
||||||
updateDetails(-1)
|
|
||||||
list.Refresh()
|
|
||||||
return
|
|
||||||
}
|
|
||||||
selected = filteredJobs[0]
|
|
||||||
list.Select(0)
|
|
||||||
refreshView()
|
|
||||||
})
|
|
||||||
folderSelect.SetSelected(selectedFolder)
|
|
||||||
|
|
||||||
// viewToggleIcon pairs with viewToggleText: both name the action the button
|
|
||||||
// performs, not the state it is in, matching stopAllButton's convention.
|
|
||||||
viewToggleIcon := func(current domain.JobListView) fyne.Resource {
|
|
||||||
if current.IsCompact() {
|
|
||||||
return theme.ViewFullScreenIcon()
|
|
||||||
}
|
|
||||||
return theme.ListIcon()
|
|
||||||
}
|
|
||||||
viewButton := widget.NewButtonWithIcon(viewToggleText(listView), viewToggleIcon(listView), nil)
|
|
||||||
viewButton.OnTapped = func() {
|
|
||||||
next := nextJobListView(listView)
|
|
||||||
listView = next
|
|
||||||
if err := svc.SetJobListView(next); err != nil {
|
|
||||||
// Roll the mode back and leave the button as it was, so the button
|
|
||||||
// never claims a preference that did not reach disk.
|
|
||||||
listView = nextJobListView(next)
|
|
||||||
dialog.ShowError(err, w)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
viewButton.SetText(viewToggleText(listView))
|
|
||||||
viewButton.SetIcon(viewToggleIcon(listView))
|
|
||||||
// Refresh re-creates the row template, which is what recomputes the
|
|
||||||
// cached row height for the new mode. Selection is untouched.
|
|
||||||
list.Refresh()
|
|
||||||
}
|
|
||||||
|
|
||||||
addButton := widget.NewButtonWithIcon("New job", theme.ContentAddIcon(), func() {
|
|
||||||
showJobDialog(w, "New job", job{Schedule: "@every 1m", Command: "echo GoSentry job ran", Enabled: true}, func(saved job) {
|
|
||||||
created, err := svc.CreateJob(saved)
|
|
||||||
if err != nil {
|
|
||||||
dialog.ShowError(err, w)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
syncFromService()
|
|
||||||
folderSelect.Options = folderOptions(jobs)
|
|
||||||
folderSelect.Refresh()
|
|
||||||
targetFolder := filterValue(created.Folder)
|
|
||||||
if selectedFolder != allFolders && selectedFolder != targetFolder {
|
|
||||||
selectedFolder = targetFolder
|
|
||||||
folderSelect.SetSelected(targetFolder)
|
|
||||||
}
|
|
||||||
selected = indexOfID(jobs, created.ID)
|
|
||||||
filteredJobs = filteredJobIndexes(jobs, selectedFolder)
|
|
||||||
list.Select(app.DisplayIndex(filteredJobs, selected))
|
|
||||||
refreshView()
|
|
||||||
})
|
|
||||||
})
|
|
||||||
editButton := widget.NewButtonWithIcon("Edit", theme.DocumentCreateIcon(), func() {
|
|
||||||
if selected < 0 || selected >= len(jobs) {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
showJobDialog(w, "Edit job", jobs[selected], func(saved job) {
|
|
||||||
saved.ID = jobs[selected].ID
|
|
||||||
if err := svc.UpdateJob(saved); err != nil {
|
|
||||||
dialog.ShowError(err, w)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
syncFromService()
|
|
||||||
folderSelect.Options = folderOptions(jobs)
|
|
||||||
folderSelect.Refresh()
|
|
||||||
refreshView()
|
|
||||||
})
|
|
||||||
})
|
|
||||||
runButton := widget.NewButtonWithIcon("Run now", theme.MediaPlayIcon(), func() {
|
|
||||||
if selected < 0 || selected >= len(jobs) {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
// A manual run is allowed even while the scheduler is paused: pause only
|
|
||||||
// stops automatic scheduled runs, not the user's explicit "Run now".
|
|
||||||
if err := svc.RunNow(jobs[selected].ID); err != nil {
|
|
||||||
dialog.ShowError(err, w)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
refreshView()
|
|
||||||
})
|
|
||||||
|
|
||||||
stopAllText, stopAllIcon := "Disable auto", theme.MediaPauseIcon()
|
|
||||||
if schedulerPaused {
|
|
||||||
stopAllText, stopAllIcon = "Enable auto", theme.MediaPlayIcon()
|
|
||||||
}
|
|
||||||
schedulerStateText := "Scheduler running"
|
|
||||||
if schedulerPaused {
|
|
||||||
schedulerStateText = "Scheduler paused"
|
|
||||||
}
|
|
||||||
schedulerState := widget.NewLabel(schedulerStateText)
|
|
||||||
stopAllButton := widget.NewButtonWithIcon(stopAllText, stopAllIcon, nil)
|
|
||||||
stopAllButton.OnTapped = func() {
|
|
||||||
// SetGlobalPause flips the pause flag, updates every job's next-run text,
|
|
||||||
// and emits the activity record the observer logs. Revert if the save fails.
|
|
||||||
schedulerPaused = !schedulerPaused
|
|
||||||
if err := svc.SetGlobalPause(schedulerPaused); err != nil {
|
|
||||||
schedulerPaused = !schedulerPaused
|
|
||||||
dialog.ShowError(err, w)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
if schedulerPaused {
|
|
||||||
schedulerState.SetText("Scheduler paused")
|
|
||||||
stopAllButton.SetText("Enable auto")
|
|
||||||
stopAllButton.SetIcon(theme.MediaPlayIcon())
|
|
||||||
} else {
|
|
||||||
schedulerState.SetText("Scheduler running")
|
|
||||||
stopAllButton.SetText("Disable auto")
|
|
||||||
stopAllButton.SetIcon(theme.MediaPauseIcon())
|
|
||||||
}
|
|
||||||
refreshView()
|
|
||||||
}
|
|
||||||
pauseButton := widget.NewButtonWithIcon("Pause", theme.MediaPauseIcon(), func() {
|
|
||||||
if selected < 0 || selected >= len(jobs) {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
current := jobs[selected]
|
|
||||||
if err := svc.SetEnabled(current.ID, !current.Enabled); err != nil {
|
|
||||||
dialog.ShowError(err, w)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
refreshView()
|
|
||||||
})
|
|
||||||
deleteButton := widget.NewButtonWithIcon("Delete", theme.DeleteIcon(), func() {
|
|
||||||
if selected < 0 || selected >= len(jobs) {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
deleted := jobs[selected]
|
|
||||||
// Deletion is confirmed because jobs can represent real system actions.
|
|
||||||
// There is no undo yet, so accidental removal should require one more click.
|
|
||||||
dialog.ShowConfirm("Delete job", fmt.Sprintf("Delete %q?", deleted.Name), func(confirm bool) {
|
|
||||||
if !confirm {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
if err := svc.DeleteJob(deleted.ID); err != nil {
|
|
||||||
dialog.ShowError(err, w)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
syncFromService()
|
|
||||||
folderSelect.Options = folderOptions(jobs)
|
|
||||||
folderSelect.Refresh()
|
|
||||||
filteredJobs = filteredJobIndexes(jobs, selectedFolder)
|
|
||||||
if len(filteredJobs) == 0 && selectedFolder != allFolders {
|
|
||||||
selectedFolder = allFolders
|
|
||||||
folderSelect.SetSelected(allFolders)
|
|
||||||
filteredJobs = filteredJobIndexes(jobs, selectedFolder)
|
|
||||||
}
|
|
||||||
if len(filteredJobs) == 0 {
|
|
||||||
selected = -1
|
|
||||||
} else {
|
|
||||||
selected = filteredJobs[0]
|
|
||||||
}
|
|
||||||
if selected >= 0 {
|
|
||||||
list.Select(app.DisplayIndex(filteredJobs, selected))
|
|
||||||
}
|
|
||||||
refreshView()
|
|
||||||
}, w)
|
|
||||||
})
|
|
||||||
|
|
||||||
toolbar := container.NewHBox(addButton, editButton, runButton, pauseButton, deleteButton, layout.NewSpacer())
|
|
||||||
// The row sits directly under the tab bar with no AppTabs inset, while the
|
|
||||||
// default VBox gap below it is one theme padding — add the same on top so
|
|
||||||
// the button is not flush against the tabs.
|
|
||||||
globalControls := container.New(
|
|
||||||
layout.NewCustomPaddedLayout(theme.Padding(), 0, 0, 0),
|
|
||||||
container.NewHBox(stopAllButton, schedulerState, layout.NewSpacer()),
|
|
||||||
)
|
|
||||||
// The whole filter is one row: caption on the left, view toggle on the right,
|
// The whole filter is one row: caption on the left, view toggle on the right,
|
||||||
// select filling what is left. The border layout gives both edges their
|
// select filling what is left. The border layout gives both edges their
|
||||||
// MinSize, so the header is a line shorter than a stacked caption would make it.
|
// MinSize, so the header is a line shorter than a stacked caption would make it.
|
||||||
folderCaption := widget.NewLabelWithStyle("Folder", fyne.TextAlignLeading, fyne.TextStyle{Bold: true})
|
folderCaption := widget.NewLabelWithStyle("Folder", fyne.TextAlignLeading, fyne.TextStyle{Bold: true})
|
||||||
filterRow := container.NewBorder(nil, nil, folderCaption, viewButton, folderSelect)
|
filterRow := container.NewBorder(nil, nil, folderCaption, v.viewButton, v.folderSelect)
|
||||||
sidebarHeader := container.NewVBox(globalControls, widget.NewSeparator(), filterRow, toolbar)
|
sidebarHeader := container.NewVBox(globalControls, widget.NewSeparator(), filterRow, v.newToolbar())
|
||||||
sidebar := container.NewBorder(sidebarHeader, nil, nil, nil, list)
|
sidebar := container.NewBorder(sidebarHeader, nil, nil, nil, v.list)
|
||||||
|
|
||||||
// A split rather than a Border left slot: the border pinned the sidebar at its
|
// A split rather than a Border left slot: the border pinned the sidebar at its
|
||||||
// MinSize forever, so the user could never trade list width for detail width.
|
// MinSize forever, so the user could never trade list width for detail width.
|
||||||
// The divider lets either pane grow, and neither can be dragged below its own
|
// The divider lets either pane grow, and neither can be dragged below its own
|
||||||
// content minimum.
|
// content minimum.
|
||||||
panel := container.NewHSplit(sidebar, container.NewPadded(dp.container()))
|
panel := container.NewHSplit(sidebar, container.NewPadded(v.dp.container()))
|
||||||
panel.SetOffset(initialSplitOffset(sidebar.MinSize().Width))
|
panel.SetOffset(initialSplitOffset(sidebar.MinSize().Width))
|
||||||
return panel, refreshView
|
return panel
|
||||||
|
}
|
||||||
|
|
||||||
|
// newFolderSelect builds the folder filter. Selecting a folder narrows the list
|
||||||
|
// and, when the selected job is no longer visible, moves the selection to the
|
||||||
|
// first row that is (see jobsViewState.applyFilter).
|
||||||
|
func (v *jobsView) newFolderSelect() *widget.Select {
|
||||||
|
return widget.NewSelect(folderOptions(v.state.jobs), func(value string) {
|
||||||
|
if value == "" {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
v.state.applyFilter(value)
|
||||||
|
v.refresh()
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// newGlobalControls builds the pause control row that sits above the filter.
|
||||||
|
func (v *jobsView) newGlobalControls() fyne.CanvasObject {
|
||||||
|
v.schedulerState = widget.NewLabel("")
|
||||||
|
v.stopAllButton = widget.NewButtonWithIcon("", nil, nil)
|
||||||
|
v.applySchedulerState(v.paused)
|
||||||
|
v.stopAllButton.OnTapped = func() {
|
||||||
|
// SetGlobalPause flips the pause flag, updates every job's next-run text,
|
||||||
|
// and emits the activity record the observer logs. refresh re-derives the
|
||||||
|
// pause state from the Service, so a failed save leaves the control showing
|
||||||
|
// what actually happened.
|
||||||
|
if err := v.svc.SetGlobalPause(!v.paused); err != nil {
|
||||||
|
dialog.ShowError(err, v.w)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
v.refresh()
|
||||||
|
}
|
||||||
|
// The row sits directly under the tab bar with no AppTabs inset, while the
|
||||||
|
// default VBox gap below it is one theme padding — add the same on top so
|
||||||
|
// the button is not flush against the tabs.
|
||||||
|
return container.New(
|
||||||
|
layout.NewCustomPaddedLayout(theme.Padding(), 0, 0, 0),
|
||||||
|
container.NewHBox(v.stopAllButton, v.schedulerState, layout.NewSpacer()),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// applySchedulerState is the one place that draws the pause control and its
|
||||||
|
// status text from a pause value, so refresh can drive it from whatever the
|
||||||
|
// Service reports instead of only the tap handler mirroring its own toggle.
|
||||||
|
func (v *jobsView) applySchedulerState(paused bool) {
|
||||||
|
v.paused = paused
|
||||||
|
if paused {
|
||||||
|
v.schedulerState.SetText("Scheduler paused")
|
||||||
|
v.stopAllButton.SetText("Enable auto")
|
||||||
|
v.stopAllButton.SetIcon(theme.MediaPlayIcon())
|
||||||
|
return
|
||||||
|
}
|
||||||
|
v.schedulerState.SetText("Scheduler running")
|
||||||
|
v.stopAllButton.SetText("Disable auto")
|
||||||
|
v.stopAllButton.SetIcon(theme.MediaPauseIcon())
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -8,7 +8,7 @@ import (
|
|||||||
|
|
||||||
// lastJobLogs returns a fresh slice of the most recent activity entries for the
|
// lastJobLogs returns a fresh slice of the most recent activity entries for the
|
||||||
// "Selected job activity" panel. Logs are stored newest-first (see
|
// "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.
|
// capped at maxJobActivityRows.
|
||||||
func lastJobLogs(logs []event) []event {
|
func lastJobLogs(logs []event) []event {
|
||||||
n := len(logs)
|
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
|
package ui
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"encoding/json"
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
"testing"
|
"testing"
|
||||||
|
|
||||||
"gitea.mixdep.ru/mix/gosentry/src/app"
|
"gitea.mixdep.ru/mix/gosentry/src/app"
|
||||||
@@ -378,7 +381,7 @@ func TestJobsSplitOpensAtTheSidebarWidth(t *testing.T) {
|
|||||||
|
|
||||||
// TestToolbarButtonRedrawsRowAndDetails is the regression guard for F12: the
|
// TestToolbarButtonRedrawsRowAndDetails is the regression guard for F12: the
|
||||||
// toolbar handlers no longer re-read the service or refresh the list
|
// toolbar handlers no longer re-read the service or refresh the list
|
||||||
// themselves, so refreshView alone has to re-snapshot the jobs and repopulate
|
// themselves, so jobsView.refresh alone has to re-snapshot the jobs and repopulate
|
||||||
// the details pane. If it ever stops doing either, the row renders a stale
|
// the details pane. If it ever stops doing either, the row renders a stale
|
||||||
// status and the details lose the selection — neither is a compile error.
|
// status and the details lose the selection — neither is a compile error.
|
||||||
func TestToolbarButtonRedrawsRowAndDetails(t *testing.T) {
|
func TestToolbarButtonRedrawsRowAndDetails(t *testing.T) {
|
||||||
@@ -441,6 +444,78 @@ func TestToolbarButtonRedrawsRowAndDetails(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// TestJobsViewSelectionSurvivesAJobsFileSwitch is the view-level regression
|
||||||
|
// guard for the selection defect. Adopting a different jobs file replaces the
|
||||||
|
// whole list from the Service; the view only hears about it through the refresh
|
||||||
|
// that JobsLoaded triggers, which is exactly what this test calls. With the
|
||||||
|
// selection held as a row index, that refresh redrew the details pane from the
|
||||||
|
// old index — describing whichever job now sat there, or clearing the pane when
|
||||||
|
// the new list was shorter — while the list's highlight stayed where it was.
|
||||||
|
func TestJobsViewSelectionSurvivesAJobsFileSwitch(t *testing.T) {
|
||||||
|
testApp := test.NewApp()
|
||||||
|
defer testApp.Quit()
|
||||||
|
w := testApp.NewWindow("test")
|
||||||
|
defer w.Close()
|
||||||
|
|
||||||
|
store := newTestStore(t)
|
||||||
|
svc := app.NewService(store, []domain.Job{
|
||||||
|
{ID: 1, Name: "First", Schedule: "@every 1m", Command: "echo one", Enabled: true},
|
||||||
|
{ID: 2, Name: "Second", Schedule: "@every 2m", Command: "echo two", Enabled: true},
|
||||||
|
{ID: 3, Name: "Third", Schedule: "@every 3m", Command: "echo three", Enabled: true},
|
||||||
|
})
|
||||||
|
defer svc.Stop()
|
||||||
|
|
||||||
|
content, refresh := newJobsView(w, svc)
|
||||||
|
w.SetContent(content)
|
||||||
|
|
||||||
|
list := jobsList(t, content)
|
||||||
|
list.Select(2)
|
||||||
|
if got := jobsDetailsTitle(t, content); got != "Third" {
|
||||||
|
t.Fatalf("details title after selecting row 2 = %q, want %q", got, "Third")
|
||||||
|
}
|
||||||
|
|
||||||
|
// A second jobs file with different jobs and different IDs, so nothing about
|
||||||
|
// the old selection can resolve into the new list.
|
||||||
|
other := []domain.Job{
|
||||||
|
{ID: 10, Name: "Alpha", Schedule: "@every 1m", Command: "echo alpha", Enabled: true},
|
||||||
|
{ID: 11, Name: "Beta", Schedule: "@every 2m", Command: "echo beta", Enabled: true},
|
||||||
|
}
|
||||||
|
payload, err := json.Marshal(domain.JobsFile{Jobs: other})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("marshal jobs: %v", err)
|
||||||
|
}
|
||||||
|
if err := os.WriteFile(filepath.Join(store.Paths.AppDir, "other.json"), payload, 0o644); err != nil {
|
||||||
|
t.Fatalf("write jobs file: %v", err)
|
||||||
|
}
|
||||||
|
config := svc.Config()
|
||||||
|
config.JobsFile = "other.json"
|
||||||
|
if err := svc.UpdateSettings(config); err != nil {
|
||||||
|
t.Fatalf("UpdateSettings: %v", err)
|
||||||
|
}
|
||||||
|
refresh()
|
||||||
|
|
||||||
|
if got := jobsDetailsTitle(t, content); got != "Alpha" {
|
||||||
|
t.Errorf("details title after the switch = %q, want the first job of the new file %q", got, "Alpha")
|
||||||
|
}
|
||||||
|
if got := list.Length(); got != len(other) {
|
||||||
|
t.Fatalf("list length after the switch = %d, want %d", got, len(other))
|
||||||
|
}
|
||||||
|
// widget.List.Select returns without calling OnSelected when the row is
|
||||||
|
// already highlighted, so a silent Select(0) is what proves the highlight and
|
||||||
|
// the details pane are describing the same job.
|
||||||
|
reselected := false
|
||||||
|
inner := list.OnSelected
|
||||||
|
list.OnSelected = func(id widget.ListItemID) {
|
||||||
|
reselected = true
|
||||||
|
inner(id)
|
||||||
|
}
|
||||||
|
defer func() { list.OnSelected = inner }()
|
||||||
|
list.Select(0)
|
||||||
|
if reselected {
|
||||||
|
t.Error("row 0 was not the highlighted row after the switch, so the highlight and the details pane disagree")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// TestDetailCaptionWidthCoversEveryCaption is the guard that makes the single
|
// TestDetailCaptionWidthCoversEveryCaption is the guard that makes the single
|
||||||
// metadataRows list self-enforcing (F10): every caption it returns must
|
// metadataRows list self-enforcing (F10): every caption it returns must
|
||||||
// measure no wider than captionColumnWidth's result for that same list, or a
|
// measure no wider than captionColumnWidth's result for that same list, or a
|
||||||
|
|||||||
@@ -0,0 +1,136 @@
|
|||||||
|
package ui
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
|
||||||
|
"fyne.io/fyne/v2"
|
||||||
|
"fyne.io/fyne/v2/container"
|
||||||
|
"fyne.io/fyne/v2/dialog"
|
||||||
|
"fyne.io/fyne/v2/layout"
|
||||||
|
"fyne.io/fyne/v2/theme"
|
||||||
|
"fyne.io/fyne/v2/widget"
|
||||||
|
)
|
||||||
|
|
||||||
|
// newToolbar builds the per-job button row under the folder filter. Every
|
||||||
|
// handler works from the selected job — never from a row index — and ends in
|
||||||
|
// refresh, which is what re-reads the Service and redraws the row, the details
|
||||||
|
// pane, and the list highlight.
|
||||||
|
func (v *jobsView) newToolbar() fyne.CanvasObject {
|
||||||
|
return container.NewHBox(
|
||||||
|
v.newAddButton(),
|
||||||
|
v.newEditButton(),
|
||||||
|
v.newRunButton(),
|
||||||
|
v.newPauseButton(),
|
||||||
|
v.newDeleteButton(),
|
||||||
|
layout.NewSpacer(),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (v *jobsView) newAddButton() *widget.Button {
|
||||||
|
return widget.NewButtonWithIcon("New job", theme.ContentAddIcon(), func() {
|
||||||
|
blank := job{Schedule: "@every 1m", Command: "echo GoSentry job ran", Enabled: true}
|
||||||
|
showJobDialog(v.w, "New job", blank, func(saved job) {
|
||||||
|
created, err := v.svc.CreateJob(saved)
|
||||||
|
if err != nil {
|
||||||
|
dialog.ShowError(err, v.w)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
v.state.sync()
|
||||||
|
// The new job may have introduced a folder, so the options are rebuilt
|
||||||
|
// before the filter is pointed at it.
|
||||||
|
v.rebuildFolders()
|
||||||
|
v.state.selectByID(created.ID)
|
||||||
|
if target := filterValue(created.Folder); v.state.folder != allFolders && v.state.folder != target {
|
||||||
|
// The current filter would hide the job the user just created. Switch
|
||||||
|
// to its folder; SetSelected fires OnChanged, which applies the filter
|
||||||
|
// and refreshes.
|
||||||
|
v.folderSelect.SetSelected(target)
|
||||||
|
}
|
||||||
|
v.refresh()
|
||||||
|
})
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
func (v *jobsView) newEditButton() *widget.Button {
|
||||||
|
return widget.NewButtonWithIcon("Edit", theme.DocumentCreateIcon(), func() {
|
||||||
|
current, ok := v.state.selected()
|
||||||
|
if !ok {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
showJobDialog(v.w, "Edit job", current, func(saved job) {
|
||||||
|
// The ID comes from the job the dialog was opened on, so a list that
|
||||||
|
// changed underneath the open dialog cannot redirect the save.
|
||||||
|
saved.ID = current.ID
|
||||||
|
if err := v.svc.UpdateJob(saved); err != nil {
|
||||||
|
dialog.ShowError(err, v.w)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
v.state.sync()
|
||||||
|
// An edit can rename the job's folder, add a new one, or empty the last
|
||||||
|
// job out of an existing one.
|
||||||
|
v.rebuildFolders()
|
||||||
|
v.refresh()
|
||||||
|
})
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
func (v *jobsView) newRunButton() *widget.Button {
|
||||||
|
return widget.NewButtonWithIcon("Run now", theme.MediaPlayIcon(), func() {
|
||||||
|
current, ok := v.state.selected()
|
||||||
|
if !ok {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
// A manual run is allowed even while the scheduler is paused: pause only
|
||||||
|
// stops automatic scheduled runs, not the user's explicit "Run now".
|
||||||
|
if err := v.svc.RunNow(current.ID); err != nil {
|
||||||
|
dialog.ShowError(err, v.w)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
v.refresh()
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
func (v *jobsView) newPauseButton() *widget.Button {
|
||||||
|
return widget.NewButtonWithIcon("Pause", theme.MediaPauseIcon(), func() {
|
||||||
|
current, ok := v.state.selected()
|
||||||
|
if !ok {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if err := v.svc.SetEnabled(current.ID, !current.Enabled); err != nil {
|
||||||
|
dialog.ShowError(err, v.w)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
v.refresh()
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
func (v *jobsView) newDeleteButton() *widget.Button {
|
||||||
|
return widget.NewButtonWithIcon("Delete", theme.DeleteIcon(), func() {
|
||||||
|
deleted, ok := v.state.selected()
|
||||||
|
if !ok {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
// Deletion is confirmed because jobs can represent real system actions.
|
||||||
|
// There is no undo yet, so accidental removal should require one more click.
|
||||||
|
dialog.ShowConfirm("Delete job", fmt.Sprintf("Delete %q?", deleted.Name), func(confirm bool) {
|
||||||
|
if !confirm {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if err := v.svc.DeleteJob(deleted.ID); err != nil {
|
||||||
|
dialog.ShowError(err, v.w)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
// sync drops the deleted job's selection and falls back to the first row
|
||||||
|
// the filter still shows.
|
||||||
|
v.state.sync()
|
||||||
|
v.rebuildFolders()
|
||||||
|
if len(v.state.filtered) == 0 && v.state.folder != allFolders {
|
||||||
|
// The deleted job was the last one in its folder, and that folder is
|
||||||
|
// no longer an option. Fall back to "All" rather than leaving the user
|
||||||
|
// on an empty filter they did not choose.
|
||||||
|
v.folderSelect.SetSelected(allFolders)
|
||||||
|
}
|
||||||
|
v.refresh()
|
||||||
|
}, v.w)
|
||||||
|
})
|
||||||
|
}
|
||||||
+42
-33
@@ -13,30 +13,23 @@ import (
|
|||||||
"fyne.io/fyne/v2/theme"
|
"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
|
// 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
|
// durable model still lives in src/domain, so UI code does not define a second
|
||||||
// copy of the scheduler data.
|
// copy of the scheduler data.
|
||||||
type job = domain.Job
|
type job = domain.Job
|
||||||
type event = domain.RunRecord
|
type event = domain.RunRecord
|
||||||
|
|
||||||
func newMainView(w fyne.Window, svc *app.Service) (fyne.CanvasObject, func(time.Duration, bool)) {
|
func newMainView(w fyne.Window, svc *app.Service, tray *trayState) (fyne.CanvasObject, func(time.Duration, bool)) {
|
||||||
svc.InstallDesktopIcon(appID, assets.IconBytes())
|
// History is session-only: jobs.json never persists JobRuntime.Logs (see
|
||||||
|
// domain.JobRuntime), so there is nothing to seed the History tab with at
|
||||||
// Build the initial event history from the current runtime state. Jobs and
|
// startup. It starts empty and fills as events arrive.
|
||||||
// runtimes are read here only for this one-time initialization; the jobs view
|
events := newHistoryLog(nil)
|
||||||
// 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)
|
|
||||||
|
|
||||||
jobsPanel, refreshJobsView := newJobsView(w, svc)
|
jobsPanel, refreshJobsView := newJobsView(w, svc)
|
||||||
|
|
||||||
history, refreshHistory := newHistoryView(&events)
|
history, refreshHistory := newHistoryView(events)
|
||||||
recordStartup := func(duration time.Duration, windowShown bool) {
|
recordStartup := func(duration time.Duration, windowShown bool) {
|
||||||
// Startup is recorded as an in-memory History event instead of being
|
// Startup is recorded as an in-memory History event instead of being
|
||||||
// persisted into jobs.json. It is session diagnostics, not durable job
|
// 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 {
|
if !windowShown {
|
||||||
detail = "Started in tray in " + duration.Round(time.Millisecond).String()
|
detail = "Started in tray in " + duration.Round(time.Millisecond).String()
|
||||||
}
|
}
|
||||||
events = append(events, newEvent(0, "Application", "Started", detail))
|
events.add(newEvent(0, "Application", "Started", detail))
|
||||||
refreshHistory()
|
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 main thread in both cases, so the engine never mutates Fyne state off
|
||||||
// the UI thread. This is the sole place events touch widgets. (Resolves #4.)
|
// the UI thread. This is the sole place events touch widgets. (Resolves #4.)
|
||||||
svc.Subscribe(app.ObserverFunc(func(ev app.Event) {
|
svc.Subscribe(app.ObserverFunc(func(ev app.Event) {
|
||||||
recorded, isRecorded := ev.(app.RunRecorded)
|
|
||||||
errOccurred, isError := ev.(app.ErrorOccurred)
|
|
||||||
jobsLoaded, isJobsLoaded := ev.(app.JobsLoaded)
|
|
||||||
fyne.Do(func() {
|
fyne.Do(func() {
|
||||||
if isRecorded {
|
// A type switch does not get compiler-enforced exhaustiveness (see
|
||||||
events = append(events, recorded.Record)
|
// app.Event's doc comment) — JobChanged and SchedulerStateChanged
|
||||||
r := recorded.Record
|
// intentionally fall through to the unconditional refresh() below
|
||||||
if r.State == "Failed" &&
|
// without their own case, since a broad state re-read is all they need.
|
||||||
(r.Trigger == "Manual" || r.Trigger == "Schedule") &&
|
switch e := ev.(type) {
|
||||||
|
case app.RunRecorded:
|
||||||
|
events.add(e.Record)
|
||||||
|
if e.Record.State == "Failed" &&
|
||||||
|
(e.Record.Trigger == "Manual" || e.Record.Trigger == "Schedule") &&
|
||||||
svc.ShouldNotifyOnFailure() {
|
svc.ShouldNotifyOnFailure() {
|
||||||
|
timing := notificationTiming{
|
||||||
|
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{
|
fyne.CurrentApp().SendNotification(&fyne.Notification{
|
||||||
Title: "GoSentry: Job Failed",
|
Title: "GoSentry: Job Failed",
|
||||||
Content: r.JobName + ": " + r.Detail,
|
Content: e.Record.JobName + ": " + e.Record.Detail,
|
||||||
|
})
|
||||||
|
timing.AfterSendAt = time.Now()
|
||||||
|
if err := appendNotificationTimingLog(svc.Paths().LogsDir, timing); err != nil {
|
||||||
|
fyne.LogError("Failed to write notification timing log", err)
|
||||||
|
}
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
}
|
case app.ErrorOccurred:
|
||||||
if isError {
|
events.add(newEvent(0, "Service", "Error", e.Err.Error()))
|
||||||
events = append(events, newEvent(0, "Service", "Error", errOccurred.Err.Error()))
|
case app.JobsLoaded:
|
||||||
}
|
|
||||||
if isJobsLoaded {
|
|
||||||
// Selecting an existing jobs file replaces the job list without a
|
// Selecting an existing jobs file replaces the job list without a
|
||||||
// prompt, so History carries the receipt: how many jobs, from where.
|
// prompt, so History carries the receipt: how many jobs, from where.
|
||||||
detail := strconv.Itoa(jobsLoaded.Count) + " jobs from " + jobsLoaded.Path
|
detail := strconv.Itoa(e.Count) + " jobs from " + e.Path
|
||||||
events = append(events, newEvent(0, "Service", "Jobs loaded", detail))
|
events.add(newEvent(0, "Service", "Jobs loaded", detail))
|
||||||
}
|
}
|
||||||
refresh()
|
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()
|
svc.Start()
|
||||||
|
|
||||||
tabs := container.NewAppTabs(
|
tabs := container.NewAppTabs(
|
||||||
container.NewTabItemWithIcon("Jobs", theme.ListIcon(), jobsPanel),
|
container.NewTabItemWithIcon("Jobs", theme.ListIcon(), jobsPanel),
|
||||||
container.NewTabItemWithIcon("History", theme.HistoryIcon(), history),
|
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)
|
tabs.SetTabLocation(container.TabLocationTop)
|
||||||
|
|
||||||
|
|||||||
@@ -66,7 +66,7 @@ func TestMainViewFitsTheDefaultWindowSize(t *testing.T) {
|
|||||||
svc := app.NewService(store, nil)
|
svc := app.NewService(store, nil)
|
||||||
defer svc.Stop()
|
defer svc.Stop()
|
||||||
|
|
||||||
content, _ := newMainView(w, svc)
|
content, _ := newMainView(w, svc, &trayState{})
|
||||||
min := content.MinSize()
|
min := content.MinSize()
|
||||||
if min.Width > defaultWindowWidth || min.Height > defaultWindowHeight {
|
if min.Width > defaultWindowWidth || min.Height > defaultWindowHeight {
|
||||||
t.Errorf("content.MinSize() = %v, want within %vx%v", min, defaultWindowWidth, 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)
|
svc := newTestService(t)
|
||||||
defer svc.Stop()
|
defer svc.Stop()
|
||||||
|
|
||||||
content, recordStartup := newMainView(w, svc)
|
content, recordStartup := newMainView(w, svc, &trayState{})
|
||||||
w.SetContent(content)
|
w.SetContent(content)
|
||||||
|
|
||||||
table := historyTable(t, 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)
|
||||||
|
}
|
||||||
|
}
|
||||||
+42
-13
@@ -6,6 +6,7 @@ import (
|
|||||||
|
|
||||||
"gitea.mixdep.ru/mix/gosentry/assets"
|
"gitea.mixdep.ru/mix/gosentry/assets"
|
||||||
"gitea.mixdep.ru/mix/gosentry/src/app"
|
"gitea.mixdep.ru/mix/gosentry/src/app"
|
||||||
|
"gitea.mixdep.ru/mix/gosentry/src/storage"
|
||||||
|
|
||||||
"fyne.io/fyne/v2"
|
"fyne.io/fyne/v2"
|
||||||
fyneapp "fyne.io/fyne/v2/app"
|
fyneapp "fyne.io/fyne/v2/app"
|
||||||
@@ -16,9 +17,10 @@ import (
|
|||||||
const appID = "ru.mixeme.gosentry.desktop"
|
const appID = "ru.mixeme.gosentry.desktop"
|
||||||
|
|
||||||
// defaultWindowWidth and defaultWindowHeight are the size the window opens at
|
// defaultWindowWidth and defaultWindowHeight are the size the window opens at
|
||||||
// on first launch (later launches restore the last size from preferences).
|
// on every launch. Window size persistence is frozen (see ROADMAP.md), so
|
||||||
// Fyne enforces the assembled content's MinSize as a hard floor over these, so
|
// there is no saved size to restore. Fyne enforces the assembled content's
|
||||||
// they only take effect if the content actually fits within them.
|
// MinSize as a hard floor over these, so they only take effect if the content
|
||||||
|
// actually fits within them.
|
||||||
const defaultWindowWidth = 1024
|
const defaultWindowWidth = 1024
|
||||||
const defaultWindowHeight = 660
|
const defaultWindowHeight = 660
|
||||||
|
|
||||||
@@ -29,7 +31,9 @@ const defaultWindowHeight = 660
|
|||||||
// mainwindow.go split keeps lifecycle separate from view construction.
|
// mainwindow.go split keeps lifecycle separate from view construction.
|
||||||
func Run(startInTray bool) {
|
func Run(startInTray bool) {
|
||||||
started := time.Now()
|
started := time.Now()
|
||||||
instanceListener, primary := acquireSingleInstance(!startInTray)
|
keepInTray := storage.PeekKeepRunningInTray()
|
||||||
|
startHidden := resolveStartHidden(startInTray, keepInTray)
|
||||||
|
instanceListener, primary := acquireSingleInstance(!startHidden)
|
||||||
if !primary {
|
if !primary {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
@@ -56,29 +60,32 @@ func Run(startInTray bool) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
w := a.NewWindow("GoSentry " + app.Version)
|
w := a.NewWindow("GoSentry " + app.Version)
|
||||||
configureSystemTray(a, w)
|
setWindowsNotificationIcon()
|
||||||
prefs := a.Preferences()
|
w.Resize(fyne.NewSize(defaultWindowWidth, defaultWindowHeight))
|
||||||
winW := float32(prefs.FloatWithFallback("window.width", defaultWindowWidth))
|
|
||||||
winH := float32(prefs.FloatWithFallback("window.height", defaultWindowHeight))
|
|
||||||
w.Resize(fyne.NewSize(winW, winH))
|
|
||||||
svc, err := app.Open()
|
svc, err := app.Open()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
w.SetContent(container.NewPadded(widget.NewLabel("Failed to load GoSentry configuration: " + err.Error())))
|
w.SetContent(container.NewPadded(widget.NewLabel("Failed to load GoSentry configuration: " + err.Error())))
|
||||||
a.Run()
|
a.Run()
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
config := svc.Config()
|
||||||
|
keepInTray = config.KeepRunningInTray
|
||||||
|
startHidden = resolveStartHidden(startInTray, keepInTray)
|
||||||
|
tray := &trayState{}
|
||||||
|
tray.apply(a, w, keepInTray, false)
|
||||||
// Apply the persisted theme before building content so the window renders in
|
// Apply the persisted theme before building content so the window renders in
|
||||||
// the chosen theme from the first frame rather than flashing the default one.
|
// the chosen theme from the first frame rather than flashing the default one.
|
||||||
applyTheme(a, svc.Store().Config.Theme)
|
applyTheme(a, config.Theme)
|
||||||
content, recordStartup := newMainView(w, svc)
|
content, recordStartup := newMainView(w, svc, tray)
|
||||||
w.SetContent(content)
|
w.SetContent(content)
|
||||||
serveSingleInstance(instanceListener, w)
|
serveSingleInstance(instanceListener, w, tray)
|
||||||
if startInTray {
|
if startHidden {
|
||||||
// Autostart launches intentionally stay hidden, so "window shown" would be
|
// Autostart launches intentionally stay hidden, so "window shown" would be
|
||||||
// a misleading metric. Record a separate startup event for the tray path
|
// a misleading metric. Record a separate startup event for the tray path
|
||||||
// instead of forcing one timing definition onto two different UX flows.
|
// instead of forcing one timing definition onto two different UX flows.
|
||||||
recordStartup(time.Since(started), false)
|
recordStartup(time.Since(started), false)
|
||||||
a.Run()
|
a.Run()
|
||||||
|
svc.Stop()
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
// Show the window before recording startup time. Measuring earlier, during
|
// Show the window before recording startup time. Measuring earlier, during
|
||||||
@@ -88,4 +95,26 @@ func Run(startInTray bool) {
|
|||||||
w.Show()
|
w.Show()
|
||||||
recordStartup(time.Since(started), true)
|
recordStartup(time.Since(started), true)
|
||||||
a.Run()
|
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(),
|
||||||
|
})
|
||||||
}
|
}
|
||||||
|
|||||||
+94
-45
@@ -26,8 +26,15 @@ var settingsCaptions = []string{
|
|||||||
"GoSentry", "Go", "Fyne", "Repository",
|
"GoSentry", "Go", "Fyne", "Repository",
|
||||||
}
|
}
|
||||||
|
|
||||||
func settingsView(w fyne.Window, svc *app.Service) fyne.CanvasObject {
|
func settingsView(w fyne.Window, svc *app.Service, tray *trayState) fyne.CanvasObject {
|
||||||
store := svc.Store()
|
// saved mirrors the config as last persisted (or freshly loaded at
|
||||||
|
// construction); it is a local copy the closures below compare the form
|
||||||
|
// against and reassign after a successful save, rather than holding onto
|
||||||
|
// the live *storage.Store the Service owns (see app.Service.Config).
|
||||||
|
// paths never changes after construction of this view — AppDir and
|
||||||
|
// ConfigPath are fixed for the process — so it is read once, not refreshed.
|
||||||
|
saved := svc.Config()
|
||||||
|
paths := svc.Paths()
|
||||||
// updateSaveState compares the form to the saved config and enables Save only
|
// updateSaveState compares the form to the saved config and enables Save only
|
||||||
// when something differs. It is defined below (once Save and every field
|
// when something differs. It is defined below (once Save and every field
|
||||||
// exist) but declared here so the field change handlers can reference it.
|
// exist) but declared here so the field change handlers can reference it.
|
||||||
@@ -36,33 +43,65 @@ func settingsView(w fyne.Window, svc *app.Service) fyne.CanvasObject {
|
|||||||
// both the initial load and the Cancel/Defaults buttons below.
|
// both the initial load and the Cancel/Defaults buttons below.
|
||||||
var loadFields func(domain.Config)
|
var loadFields func(domain.Config)
|
||||||
startOnLogin := widget.NewCheck("Start on login", nil)
|
startOnLogin := widget.NewCheck("Start on login", nil)
|
||||||
startOnLogin.SetChecked(store.Config.StartOnLogin)
|
startOnLogin.SetChecked(saved.StartOnLogin)
|
||||||
|
minimizeToTray := widget.NewCheck("Keep running in the system tray", nil)
|
||||||
|
minimizeToTray.SetChecked(saved.KeepRunningInTray)
|
||||||
autostartStatus := widget.NewLabel("")
|
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() {
|
refreshAutostartStatus := func() {
|
||||||
|
if settingsPendingAutostart(startOnLogin, minimizeToTray, saved) {
|
||||||
|
autostartStatus.SetText("Pending: save settings to apply")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
// 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()
|
ok, message := svc.AutostartStatus()
|
||||||
|
fyne.Do(func() {
|
||||||
|
if gen != autostartCheckGen {
|
||||||
|
return
|
||||||
|
}
|
||||||
if ok {
|
if ok {
|
||||||
autostartStatus.SetText("OK: " + message)
|
autostartStatus.SetText("OK: " + message)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
autostartStatus.SetText("Problem: " + message)
|
autostartStatus.SetText("Problem: " + message)
|
||||||
|
})
|
||||||
|
}()
|
||||||
|
}
|
||||||
|
refreshTrayRestartHint := func(pending bool) {
|
||||||
|
if pending {
|
||||||
|
trayRestartHint.SetText("Pending: restart GoSentry after save for the tray icon change to take effect.")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
trayRestartHint.SetText("")
|
||||||
}
|
}
|
||||||
startOnLogin.OnChanged = func(bool) {
|
startOnLogin.OnChanged = func(bool) {
|
||||||
if startOnLogin.Checked != store.Config.StartOnLogin {
|
|
||||||
autostartStatus.SetText("Pending: save settings to apply")
|
|
||||||
} else {
|
|
||||||
refreshAutostartStatus()
|
refreshAutostartStatus()
|
||||||
|
updateSaveState()
|
||||||
}
|
}
|
||||||
|
minimizeToTray.OnChanged = func(bool) {
|
||||||
|
refreshAutostartStatus()
|
||||||
|
refreshTrayRestartHint(minimizeToTray.Checked != saved.KeepRunningInTray)
|
||||||
updateSaveState()
|
updateSaveState()
|
||||||
}
|
}
|
||||||
refreshAutostartStatus()
|
refreshAutostartStatus()
|
||||||
minimizeToTray := widget.NewCheck("Keep running in the system tray", nil)
|
|
||||||
minimizeToTray.SetChecked(store.Config.KeepRunningInTray)
|
|
||||||
minimizeToTray.OnChanged = func(bool) { updateSaveState() }
|
|
||||||
notifications := widget.NewCheck("Show desktop notifications for failed jobs", nil)
|
notifications := widget.NewCheck("Show desktop notifications for failed jobs", nil)
|
||||||
notifications.SetChecked(store.Config.NotifyOnFailure)
|
notifications.SetChecked(saved.NotifyOnFailure)
|
||||||
notifications.OnChanged = func(bool) { updateSaveState() }
|
notifications.OnChanged = func(bool) { updateSaveState() }
|
||||||
themeSelect := widget.NewSelect([]string{themeLabelSystem, themeLabelGoSentry}, nil)
|
themeSelect := widget.NewSelect([]string{themeLabelSystem, themeLabelGoSentry}, nil)
|
||||||
themeSelect.SetSelected(themeLabel(store.Config.Theme))
|
themeSelect.SetSelected(themeLabel(saved.Theme))
|
||||||
// Preview the theme the moment it is picked so the choice is visible before
|
// Preview the theme the moment it is picked so the choice is visible before
|
||||||
// saving; Save persists it. Reverting the selection reverts the preview, and
|
// saving; Save persists it. Reverting the selection reverts the preview, and
|
||||||
// closing without saving falls back to the stored theme on next launch.
|
// closing without saving falls back to the stored theme on next launch.
|
||||||
@@ -74,20 +113,20 @@ func settingsView(w fyne.Window, svc *app.Service) fyne.CanvasObject {
|
|||||||
[]string{string(domain.ExecutionModeParallel), string(domain.ExecutionModeSequential)},
|
[]string{string(domain.ExecutionModeParallel), string(domain.ExecutionModeSequential)},
|
||||||
nil,
|
nil,
|
||||||
)
|
)
|
||||||
executionModeSelect.SetSelected(string(store.Config.ExecutionMode))
|
executionModeSelect.SetSelected(string(saved.ExecutionMode))
|
||||||
executionModeSelect.OnChanged = func(string) { updateSaveState() }
|
executionModeSelect.OnChanged = func(string) { updateSaveState() }
|
||||||
overlapPolicySelect := widget.NewSelect(
|
overlapPolicySelect := widget.NewSelect(
|
||||||
[]string{string(domain.OverlapPolicySkip), string(domain.OverlapPolicyQueue)},
|
[]string{string(domain.OverlapPolicySkip), string(domain.OverlapPolicyQueue)},
|
||||||
nil,
|
nil,
|
||||||
)
|
)
|
||||||
overlapPolicySelect.SetSelected(string(store.Config.OverlapPolicy))
|
overlapPolicySelect.SetSelected(string(saved.OverlapPolicy))
|
||||||
overlapPolicySelect.OnChanged = func(string) { updateSaveState() }
|
overlapPolicySelect.OnChanged = func(string) { updateSaveState() }
|
||||||
defaultTimeout := widget.NewEntry()
|
defaultTimeout := widget.NewEntry()
|
||||||
defaultTimeout.SetPlaceHolder("0 = no timeout")
|
defaultTimeout.SetPlaceHolder("0 = no timeout")
|
||||||
defaultTimeout.SetText(strconv.Itoa(store.Config.DefaultTimeoutSeconds))
|
defaultTimeout.SetText(strconv.Itoa(saved.DefaultTimeoutSeconds))
|
||||||
defaultTimeout.OnChanged = func(string) { updateSaveState() }
|
defaultTimeout.OnChanged = func(string) { updateSaveState() }
|
||||||
jobsFile := widget.NewEntry()
|
jobsFile := widget.NewEntry()
|
||||||
jobsFile.SetText(store.Config.JobsFile)
|
jobsFile.SetText(saved.JobsFile)
|
||||||
jobsFile.OnChanged = func(string) { updateSaveState() }
|
jobsFile.OnChanged = func(string) { updateSaveState() }
|
||||||
// The picker only offers existing files; a jobs file that does not exist yet
|
// The picker only offers existing files; a jobs file that does not exist yet
|
||||||
// is entered by typing its path, which Save then creates.
|
// is entered by typing its path, which Save then creates.
|
||||||
@@ -95,7 +134,7 @@ func settingsView(w fyne.Window, svc *app.Service) fyne.CanvasObject {
|
|||||||
chooseJSONFile(w, jobsFile)
|
chooseJSONFile(w, jobsFile)
|
||||||
})
|
})
|
||||||
logsDir := widget.NewEntry()
|
logsDir := widget.NewEntry()
|
||||||
logsDir.SetText(store.Config.LogsDir)
|
logsDir.SetText(saved.LogsDir)
|
||||||
logsDir.OnChanged = func(string) { updateSaveState() }
|
logsDir.OnChanged = func(string) { updateSaveState() }
|
||||||
logsDirBrowse := widget.NewButtonWithIcon("Browse", theme.FolderOpenIcon(), func() {
|
logsDirBrowse := widget.NewButtonWithIcon("Browse", theme.FolderOpenIcon(), func() {
|
||||||
chooseFolder(w, logsDir)
|
chooseFolder(w, logsDir)
|
||||||
@@ -105,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
|
// manager. It reveals whatever the field currently holds, so an edit can be
|
||||||
// checked before Save.
|
// checked before Save.
|
||||||
logsDirOpen := widget.NewButtonWithIcon("Open", theme.FolderIcon(), func() {
|
logsDirOpen := widget.NewButtonWithIcon("Open", theme.FolderIcon(), func() {
|
||||||
openFolder(w, settingsFolderPath(store.Paths.AppDir, logsDir.Text))
|
openFolder(w, settingsFolderPath(paths.AppDir, logsDir.Text))
|
||||||
})
|
})
|
||||||
maxLogFiles := widget.NewEntry()
|
maxLogFiles := widget.NewEntry()
|
||||||
maxLogFiles.SetText(strconv.Itoa(store.Config.MaxLogFiles))
|
maxLogFiles.SetPlaceHolder("0 = unlimited")
|
||||||
|
maxLogFiles.SetText(strconv.Itoa(saved.MaxLogFiles))
|
||||||
maxLogFiles.OnChanged = func(string) { updateSaveState() }
|
maxLogFiles.OnChanged = func(string) { updateSaveState() }
|
||||||
maxLogAgeDays := widget.NewEntry()
|
maxLogAgeDays := widget.NewEntry()
|
||||||
maxLogAgeDays.SetText(strconv.Itoa(store.Config.MaxLogAgeDays))
|
maxLogAgeDays.SetPlaceHolder("0 = unlimited")
|
||||||
|
maxLogAgeDays.SetText(strconv.Itoa(saved.MaxLogAgeDays))
|
||||||
maxLogAgeDays.OnChanged = func(string) { updateSaveState() }
|
maxLogAgeDays.OnChanged = func(string) { updateSaveState() }
|
||||||
// Autostart status sits on its own row beneath the checkbox (rather than
|
// Autostart status sits on its own row beneath the checkbox (rather than
|
||||||
// beside it) so the Application section fits within a half-width column.
|
// beside it) so the Application section fits within a half-width column.
|
||||||
@@ -120,33 +161,22 @@ func settingsView(w fyne.Window, svc *app.Service) fyne.CanvasObject {
|
|||||||
settingsStatus := widget.NewLabel("")
|
settingsStatus := widget.NewLabel("")
|
||||||
|
|
||||||
saveSettings := widget.NewButtonWithIcon("Save settings", theme.DocumentSaveIcon(), func() {
|
saveSettings := widget.NewButtonWithIcon("Save settings", theme.DocumentSaveIcon(), func() {
|
||||||
files, err := strconv.Atoi(strings.TrimSpace(maxLogFiles.Text))
|
// Only the parse itself happens here: a numeric field has to become an int
|
||||||
if err != nil || files <= 0 {
|
// before it can go into a domain.Config at all. Everything else — required
|
||||||
settingsStatus.SetText("Max log files must be a positive number")
|
// fields, negative numbers, valid enum values — is Service.UpdateSettings'
|
||||||
return
|
// job (see app.validateConfig), so its error is what the user sees rather
|
||||||
}
|
// than a second copy of the same rules with different wording.
|
||||||
days, err := strconv.Atoi(strings.TrimSpace(maxLogAgeDays.Text))
|
files, filesErr := strconv.Atoi(strings.TrimSpace(maxLogFiles.Text))
|
||||||
if err != nil || days <= 0 {
|
days, daysErr := strconv.Atoi(strings.TrimSpace(maxLogAgeDays.Text))
|
||||||
settingsStatus.SetText("Max log age days must be a positive number")
|
timeout, timeoutErr := strconv.Atoi(strings.TrimSpace(defaultTimeout.Text))
|
||||||
return
|
if filesErr != nil || daysErr != nil || timeoutErr != nil {
|
||||||
}
|
settingsStatus.SetText("Max log files, max log age days, and default timeout must be numbers")
|
||||||
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)")
|
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
// Build the new config from the form and hand it to the Service, which
|
// Build the new config from the form and hand it to the Service, which
|
||||||
// validates it, persists config and jobs to the (possibly new) directory,
|
// validates it, persists config and jobs to the (possibly new) directory,
|
||||||
// and runs log cleanup so tightened retention limits take effect at once.
|
// and runs log cleanup so tightened retention limits take effect at once.
|
||||||
config := store.Config
|
config := saved
|
||||||
config.JobsFile = strings.TrimSpace(jobsFile.Text)
|
config.JobsFile = strings.TrimSpace(jobsFile.Text)
|
||||||
config.LogsDir = strings.TrimSpace(logsDir.Text)
|
config.LogsDir = strings.TrimSpace(logsDir.Text)
|
||||||
config.MaxLogFiles = files
|
config.MaxLogFiles = files
|
||||||
@@ -158,16 +188,28 @@ func settingsView(w fyne.Window, svc *app.Service) fyne.CanvasObject {
|
|||||||
config.OverlapPolicy = domain.OverlapPolicy(overlapPolicySelect.Selected)
|
config.OverlapPolicy = domain.OverlapPolicy(overlapPolicySelect.Selected)
|
||||||
config.DefaultTimeoutSeconds = timeout
|
config.DefaultTimeoutSeconds = timeout
|
||||||
config.Theme = themeFromLabel(themeSelect.Selected)
|
config.Theme = themeFromLabel(themeSelect.Selected)
|
||||||
|
previousKeepInTray := saved.KeepRunningInTray
|
||||||
if err := svc.UpdateSettings(config); err != nil {
|
if err := svc.UpdateSettings(config); err != nil {
|
||||||
settingsStatus.SetText("Save failed: " + err.Error())
|
settingsStatus.SetText("Save failed: " + err.Error())
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
// UpdateSettings may re-resolve paths (a jobs-file switch adopts a
|
||||||
|
// different directory), so pick up the fresh copy rather than assuming
|
||||||
|
// config is exactly what landed.
|
||||||
|
saved = svc.Config()
|
||||||
|
paths = svc.Paths()
|
||||||
if err := svc.ApplyAutostart(); err != nil {
|
if err := svc.ApplyAutostart(); err != nil {
|
||||||
refreshAutostartStatus()
|
refreshAutostartStatus()
|
||||||
settingsStatus.SetText("Saved, autostart failed: " + err.Error())
|
settingsStatus.SetText("Saved, autostart failed: " + err.Error())
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
refreshAutostartStatus()
|
refreshAutostartStatus()
|
||||||
|
tray.apply(fyne.CurrentApp(), w, config.KeepRunningInTray, true)
|
||||||
|
if previousKeepInTray != config.KeepRunningInTray {
|
||||||
|
trayRestartHint.SetText(trayRestartHintText)
|
||||||
|
} else {
|
||||||
|
refreshTrayRestartHint(false)
|
||||||
|
}
|
||||||
settingsStatus.SetText("Saved")
|
settingsStatus.SetText("Saved")
|
||||||
// The form now matches the persisted config, so disable Save again.
|
// The form now matches the persisted config, so disable Save again.
|
||||||
updateSaveState()
|
updateSaveState()
|
||||||
@@ -178,7 +220,7 @@ func settingsView(w fyne.Window, svc *app.Service) fyne.CanvasObject {
|
|||||||
// fields compare against their canonical string form; any unparsable text
|
// fields compare against their canonical string form; any unparsable text
|
||||||
// counts as a change so the user can click Save and see the validation error.
|
// counts as a change so the user can click Save and see the validation error.
|
||||||
updateSaveState = func() {
|
updateSaveState = func() {
|
||||||
c := store.Config
|
c := saved
|
||||||
changed := startOnLogin.Checked != c.StartOnLogin ||
|
changed := startOnLogin.Checked != c.StartOnLogin ||
|
||||||
minimizeToTray.Checked != c.KeepRunningInTray ||
|
minimizeToTray.Checked != c.KeepRunningInTray ||
|
||||||
notifications.Checked != c.NotifyOnFailure ||
|
notifications.Checked != c.NotifyOnFailure ||
|
||||||
@@ -215,16 +257,17 @@ func settingsView(w fyne.Window, svc *app.Service) fyne.CanvasObject {
|
|||||||
logsDir.SetText(c.LogsDir)
|
logsDir.SetText(c.LogsDir)
|
||||||
maxLogFiles.SetText(strconv.Itoa(c.MaxLogFiles))
|
maxLogFiles.SetText(strconv.Itoa(c.MaxLogFiles))
|
||||||
maxLogAgeDays.SetText(strconv.Itoa(c.MaxLogAgeDays))
|
maxLogAgeDays.SetText(strconv.Itoa(c.MaxLogAgeDays))
|
||||||
if startOnLogin.Checked != store.Config.StartOnLogin {
|
if settingsPendingAutostart(startOnLogin, minimizeToTray, saved) {
|
||||||
autostartStatus.SetText("Pending: save settings to apply")
|
autostartStatus.SetText("Pending: save settings to apply")
|
||||||
} else {
|
} else {
|
||||||
refreshAutostartStatus()
|
refreshAutostartStatus()
|
||||||
}
|
}
|
||||||
|
refreshTrayRestartHint(minimizeToTray.Checked != saved.KeepRunningInTray)
|
||||||
settingsStatus.SetText("")
|
settingsStatus.SetText("")
|
||||||
updateSaveState()
|
updateSaveState()
|
||||||
}
|
}
|
||||||
cancelSettings := widget.NewButtonWithIcon("Cancel", theme.CancelIcon(), func() {
|
cancelSettings := widget.NewButtonWithIcon("Cancel", theme.CancelIcon(), func() {
|
||||||
loadFields(store.Config)
|
loadFields(saved)
|
||||||
})
|
})
|
||||||
restoreDefaults := widget.NewButtonWithIcon("Defaults", theme.MediaReplayIcon(), func() {
|
restoreDefaults := widget.NewButtonWithIcon("Defaults", theme.MediaReplayIcon(), func() {
|
||||||
loadFields(domain.DefaultConfig())
|
loadFields(domain.DefaultConfig())
|
||||||
@@ -234,12 +277,13 @@ func settingsView(w fyne.Window, svc *app.Service) fyne.CanvasObject {
|
|||||||
startOnLogin: startOnLogin,
|
startOnLogin: startOnLogin,
|
||||||
autostartStatus: autostartStatus,
|
autostartStatus: autostartStatus,
|
||||||
minimizeToTray: minimizeToTray,
|
minimizeToTray: minimizeToTray,
|
||||||
|
trayRestartHint: trayRestartHint,
|
||||||
notifications: notifications,
|
notifications: notifications,
|
||||||
themeSelect: themeSelect,
|
themeSelect: themeSelect,
|
||||||
executionModeSelect: executionModeSelect,
|
executionModeSelect: executionModeSelect,
|
||||||
overlapPolicySelect: overlapPolicySelect,
|
overlapPolicySelect: overlapPolicySelect,
|
||||||
defaultTimeout: defaultTimeout,
|
defaultTimeout: defaultTimeout,
|
||||||
configPath: store.Paths.ConfigPath,
|
configPath: paths.ConfigPath,
|
||||||
jobsFile: jobsFile,
|
jobsFile: jobsFile,
|
||||||
jobsFileBrowse: jobsFileBrowse,
|
jobsFileBrowse: jobsFileBrowse,
|
||||||
logsDir: logsDir,
|
logsDir: logsDir,
|
||||||
@@ -254,6 +298,11 @@ func settingsView(w fyne.Window, svc *app.Service) fyne.CanvasObject {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func settingsPendingAutostart(startOnLogin, minimizeToTray *widget.Check, saved domain.Config) bool {
|
||||||
|
return startOnLogin.Checked != saved.StartOnLogin ||
|
||||||
|
minimizeToTray.Checked != saved.KeepRunningInTray
|
||||||
|
}
|
||||||
|
|
||||||
// Theme dropdown labels. These are the human-facing captions; themeLabel and
|
// Theme dropdown labels. These are the human-facing captions; themeLabel and
|
||||||
// themeFromLabel translate between them and the stored domain.Theme values so the
|
// themeFromLabel translate between them and the stored domain.Theme values so the
|
||||||
// select never leaks the on-disk "system"/"gosentry" strings to the user.
|
// select never leaks the on-disk "system"/"gosentry" strings to the user.
|
||||||
|
|||||||
@@ -20,6 +20,7 @@ type settingsFormFields struct {
|
|||||||
startOnLogin *widget.Check
|
startOnLogin *widget.Check
|
||||||
autostartStatus *widget.Label
|
autostartStatus *widget.Label
|
||||||
minimizeToTray *widget.Check
|
minimizeToTray *widget.Check
|
||||||
|
trayRestartHint *widget.Label
|
||||||
notifications *widget.Check
|
notifications *widget.Check
|
||||||
themeSelect *widget.Select
|
themeSelect *widget.Select
|
||||||
executionModeSelect *widget.Select
|
executionModeSelect *widget.Select
|
||||||
@@ -59,6 +60,7 @@ func newSettingsLayout(f settingsFormFields) fyne.CanvasObject {
|
|||||||
// empty caption, so the Application section fits in a half-width column.
|
// empty caption, so the Application section fits in a half-width column.
|
||||||
settingsRow(capW, "", f.autostartStatus),
|
settingsRow(capW, "", f.autostartStatus),
|
||||||
settingsRow(capW, "Tray", f.minimizeToTray),
|
settingsRow(capW, "Tray", f.minimizeToTray),
|
||||||
|
settingsRow(capW, "", f.trayRestartHint),
|
||||||
settingsRow(capW, "Notifications", f.notifications),
|
settingsRow(capW, "Notifications", f.notifications),
|
||||||
// Theme is the one row here whose value is not text: the Select paints
|
// Theme is the one row here whose value is not text: the Select paints
|
||||||
// a box out to the row's edge, so the section's overlap would leave it
|
// a box out to the row's edge, so the section's overlap would leave it
|
||||||
|
|||||||
@@ -34,11 +34,14 @@ func acquireSingleInstance(showExisting bool) (net.Listener, bool) {
|
|||||||
// If the port is unavailable but does not answer as GoSentry, continue
|
// If the port is unavailable but does not answer as GoSentry, continue
|
||||||
// startup instead of making the application impossible to open because of an
|
// startup instead of making the application impossible to open because of an
|
||||||
// unrelated local listener. In the normal duplicate-start case the dial above
|
// 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
|
return nil, true
|
||||||
}
|
}
|
||||||
|
|
||||||
func serveSingleInstance(listener net.Listener, w fyne.Window) {
|
func serveSingleInstance(listener net.Listener, w fyne.Window, tray *trayState) {
|
||||||
if listener == nil {
|
if listener == nil {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
@@ -56,6 +59,7 @@ func serveSingleInstance(listener net.Listener, w fyne.Window) {
|
|||||||
// Accept runs on its own goroutine, so focusing the window must be
|
// Accept runs on its own goroutine, so focusing the window must be
|
||||||
// marshaled onto the main thread like every other widget update.
|
// marshaled onto the main thread like every other widget update.
|
||||||
fyne.Do(func() {
|
fyne.Do(func() {
|
||||||
|
tray.hidden = false
|
||||||
w.Show()
|
w.Show()
|
||||||
w.RequestFocus()
|
w.RequestFocus()
|
||||||
})
|
})
|
||||||
|
|||||||
+48
-16
@@ -4,12 +4,49 @@ import (
|
|||||||
"runtime"
|
"runtime"
|
||||||
|
|
||||||
"gitea.mixdep.ru/mix/gosentry/assets"
|
"gitea.mixdep.ru/mix/gosentry/assets"
|
||||||
|
"gitea.mixdep.ru/mix/gosentry/src/domain"
|
||||||
|
|
||||||
"fyne.io/fyne/v2"
|
"fyne.io/fyne/v2"
|
||||||
fynedesktop "fyne.io/fyne/v2/driver/desktop"
|
fynedesktop "fyne.io/fyne/v2/driver/desktop"
|
||||||
)
|
)
|
||||||
|
|
||||||
func configureSystemTray(a fyne.App, w fyne.Window) {
|
// 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."
|
||||||
|
|
||||||
|
func resolveStartHidden(cliStartInTray, keepInTray bool) bool {
|
||||||
|
return domain.ResolveStartHidden(cliStartInTray, keepInTray)
|
||||||
|
}
|
||||||
|
|
||||||
|
// 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
|
||||||
|
}
|
||||||
|
t.setWindowCloseBehavior(w, keepInTray)
|
||||||
|
if !keepInTray && revealIfHidden && t.hidden {
|
||||||
|
t.hidden = false
|
||||||
|
w.Show()
|
||||||
|
w.RequestFocus()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (t *trayState) registerSystemTray(a fyne.App, w fyne.Window) {
|
||||||
desk, ok := a.(fynedesktop.App)
|
desk, ok := a.(fynedesktop.App)
|
||||||
if !ok {
|
if !ok {
|
||||||
// Not every Fyne driver exposes desktop tray features. Returning silently
|
// Not every Fyne driver exposes desktop tray features. Returning silently
|
||||||
@@ -34,26 +71,13 @@ func configureSystemTray(a fyne.App, w fyne.Window) {
|
|||||||
// localized label — which our literal "Quit" does not. Setting IsQuit makes
|
// localized label — which our literal "Quit" does not. Setting IsQuit makes
|
||||||
// Fyne reuse this item instead of adding a duplicate, regardless of locale.
|
// Fyne reuse this item instead of adding a duplicate, regardless of locale.
|
||||||
|
|
||||||
// Window size persistence is frozen: w.Canvas().Size() returns the maximized
|
|
||||||
// dimensions when the window is maximized, so saving here would corrupt the
|
|
||||||
// stored size. Needs cross-platform maximized-state detection (IsZoomed /
|
|
||||||
// _NET_WM_STATE / NSWindow.isZoomed) before it can be re-enabled safely.
|
|
||||||
// See ROADMAP.md — "Window size — skip saving when maximized".
|
|
||||||
//
|
|
||||||
// saveWindowSize := func() {
|
|
||||||
// size := w.Canvas().Size()
|
|
||||||
// prefs := a.Preferences()
|
|
||||||
// prefs.SetFloat("window.width", float64(size.Width))
|
|
||||||
// prefs.SetFloat("window.height", float64(size.Height))
|
|
||||||
// }
|
|
||||||
|
|
||||||
quit := fyne.NewMenuItem("Quit", func() {
|
quit := fyne.NewMenuItem("Quit", func() {
|
||||||
// saveWindowSize()
|
|
||||||
a.Quit()
|
a.Quit()
|
||||||
})
|
})
|
||||||
quit.IsQuit = true
|
quit.IsQuit = true
|
||||||
menu := fyne.NewMenu("GoSentry",
|
menu := fyne.NewMenu("GoSentry",
|
||||||
fyne.NewMenuItem("Show", func() {
|
fyne.NewMenuItem("Show", func() {
|
||||||
|
t.hidden = false
|
||||||
w.Show()
|
w.Show()
|
||||||
w.RequestFocus()
|
w.RequestFocus()
|
||||||
}),
|
}),
|
||||||
@@ -62,11 +86,19 @@ func configureSystemTray(a fyne.App, w fyne.Window) {
|
|||||||
)
|
)
|
||||||
desk.SetSystemTrayMenu(menu)
|
desk.SetSystemTrayMenu(menu)
|
||||||
desk.SetSystemTrayWindow(w)
|
desk.SetSystemTrayWindow(w)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (t *trayState) setWindowCloseBehavior(w fyne.Window, keepInTray bool) {
|
||||||
|
if keepInTray {
|
||||||
w.SetCloseIntercept(func() {
|
w.SetCloseIntercept(func() {
|
||||||
// Closing hides the window instead of quitting because scheduler tools are
|
// Closing hides the window instead of quitting because scheduler tools are
|
||||||
// expected to keep working in the background. The explicit Quit tray item
|
// expected to keep working in the background. The explicit Quit tray item
|
||||||
// remains the way to stop the process.
|
// remains the way to stop the process.
|
||||||
// saveWindowSize()
|
t.hidden = true
|
||||||
w.Hide()
|
w.Hide()
|
||||||
})
|
})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
t.hidden = false
|
||||||
|
w.SetCloseIntercept(nil)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,14 @@
|
|||||||
|
package ui
|
||||||
|
|
||||||
|
import "testing"
|
||||||
|
|
||||||
|
func TestResolveStartHiddenUsesDomainHelper(t *testing.T) {
|
||||||
|
// resolveStartHidden is the UI alias used at startup; it must stay aligned
|
||||||
|
// with domain.ResolveStartHidden so run.go and tests share one definition.
|
||||||
|
if got := resolveStartHidden(true, false); got {
|
||||||
|
t.Fatal("expected hidden start to require both CLI flag and keepInTray")
|
||||||
|
}
|
||||||
|
if !resolveStartHidden(true, true) {
|
||||||
|
t.Fatal("expected hidden start when both flags are set")
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user